@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.
Files changed (144) 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/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,3938 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-orchestrator — dsh-Fang 母体的「大脑」(v2.0.0)。
4
+ //
5
+ // 三层拓扑里的第一层:只做任务语义(create / status / promote / archive),
6
+ // 进程机制全部交给 Forker,跨进程控制全部交给分身的 Adapter + src/lib/remote.mjs。
7
+ // 边界:母体不可变;分身零共享;分身崩溃/回收不经过 restart-guard。
8
+ //
9
+ // 所有子插件服务通过 ctx.get(SERVICE.x) 可选获取,缺失时显式降级,
10
+ // 绝不假装成功。orchestrator 自身只在 mother 角色激活(roleGate)。
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import crypto from 'node:crypto'
14
+
15
+ import {
16
+ z, roleGate, resolveFangLayout, resolveProfileDir, resolveSessionsLayout,
17
+ SERVICE, AUDIT, TASK_STATUS, FORK_STATUS, FANG_VERSION, ADAPTER_OP,
18
+ exists, ensureDir, ensureModuleMirror, readJson, writeJsonAtomic, writeFileAtomic, sha256, removeTree, copyTree, digestTree,
19
+ newId, iso, nowMs, bus, sleep, parseWebUrl, probeWebUrl,
20
+ invokeCommercial, commercialConfigSchemaDefaults,
21
+ } from '../kernel/index.mjs'
22
+ import { parseWorkspaceIndex, scanSessionDirs, readBoundedEvents, extractSessionTitle, encodeWorkspaceKey } from '../lib/session-format.mjs'
23
+ import { ensureEssenceRuntimePnpmConfig, readNodeLinker } from '../lib/pnpm-store.mjs'
24
+ import { captureRuntimeProfileIntoEssence } from '../lib/runtime-capture.mjs'
25
+ import { negotiateProtocol } from '../lib/adapter-protocol.mjs'
26
+ import { describeMotherLink, probeLink, readSharedProfileSummary } from '../lib/mother-link.mjs'
27
+ import { MirrorManager } from '../lib/mirror.mjs'
28
+ import { buildMirrorStatusResponse } from '../lib/mirror-etag.mjs'
29
+ import { PreferencesStore } from '../lib/preferences.mjs'
30
+ import { EssenceManager, OFFICIAL_ESSENCE_ID, VIP_QUOTAS } from '../lib/essence.mjs'
31
+ import { applyForkConfigLock, setProfileConfigReadonly } from '../lib/fork-config-lock.mjs'
32
+ import { ensureForkEmptySession, enforceForkSessionCap, summarizeForkSessions, FORK_SESSION_MAX, FORK_SESSION_MIN } from '../lib/fork-sessions.mjs'
33
+ import { DEFAULT_ESSENCE_NAME, planDefaultEssence } from '../lib/default-essence.mjs'
34
+ import { MirrorUpgradeCoordinator } from '../lib/upgrade-coordinator.mjs'
35
+ import { createBusinessProducers } from '../lib/business-producers.mjs'
36
+ import { createManifestOfficialSource } from '../lib/mirror-official-source.mjs'
37
+ import { clearForkConfigLock } from '../lib/fork-config-lock.mjs'
38
+ import { readForkSupervisionSettings, writeForkSupervisionSettings } from '../lib/fork-supervision-settings.mjs'
39
+ import { DEFAULT_EXPECTED_ROUTE_COUNT, readCompatStatus, runCompatGate, writeCompatStatus } from '../lib/compat.mjs'
40
+ import { buildUpgradeStatus } from '../lib/upgrade-status.mjs'
41
+ import { openDecisionEventStream } from '../lib/decision-events.mjs'
42
+ import { authErrorBody, checkRequestAuth, loadAuthToken } from '../lib/http-auth.mjs'
43
+ import { buildEssenceActions, buildTaskActions } from '../lib/ui-actions.mjs'
44
+ import { BranchStore } from '../lib/branch-store.mjs'
45
+ import { ComposerJournal } from '../lib/composer-journal.mjs'
46
+ import { fileURLToPath } from 'node:url'
47
+ import {
48
+ createContractServer, startFileContractServer,
49
+ createRelayStore, relayHandlers, // v3.1.35:contract 1.1 回流接口
50
+ METHODS as CONTRACT_METHODS, CONTRACT_VERSION,
51
+ } from '../contract/index.js'
52
+ import zlib from 'node:zlib'
53
+
54
+ /**
55
+ * Commit a promoted directory by copy → digest → directory swap.
56
+ * The old target is only removed after the new tree has been verified and renamed in.
57
+ */
58
+ export function atomicReplaceDir(sourceDir, targetDir) {
59
+ const suffix = newId('promote')
60
+ const tmp = `${targetDir}.tmp-${suffix}`
61
+ const old = `${targetDir}.old-${suffix}`
62
+ const digestOf = (dir) => {
63
+ const tree = digestTree(dir)
64
+ const keys = Object.keys(tree).sort()
65
+ return sha256(keys.map((key) => `${key}\u0000${tree[key]}`).join('\n'))
66
+ }
67
+ try {
68
+ removeTree(tmp)
69
+ removeTree(old)
70
+ copyTree(sourceDir, tmp)
71
+ const digest = digestOf(tmp)
72
+ if (digest !== digestOf(sourceDir)) throw new Error('promote-digest-mismatch')
73
+ const hadOld = exists(targetDir)
74
+ if (hadOld) fs.renameSync(targetDir, old)
75
+ try {
76
+ fs.renameSync(tmp, targetDir)
77
+ } catch (error) {
78
+ if (hadOld && !exists(targetDir)) {
79
+ try { fs.renameSync(old, targetDir) } catch { /* best effort restore */ }
80
+ }
81
+ throw error
82
+ }
83
+ if (hadOld) removeTree(old)
84
+ return { digest }
85
+ } catch (error) {
86
+ removeTree(tmp)
87
+ if (exists(old) && !exists(targetDir)) {
88
+ try { fs.renameSync(old, targetDir) } catch { /* best effort restore */ }
89
+ }
90
+ throw error
91
+ }
92
+ }
93
+
94
+
95
+ const FORK_BUNDLE_NAMES = Object.freeze(['@twinsearth/fang-dsh-plugin', '@twinsearth/dsh-fang', '@twinsearth/fang-dsh-plugin'])
96
+
97
+ /** 分身 profile 可解析的 fang bundle 路径(npm 安装名/开发 link 名都接受)。 */
98
+ function forkBundlePath(profileDir) {
99
+ for (const name of FORK_BUNDLE_NAMES) {
100
+ const candidate = path.join(String(profileDir ?? ''), 'node_modules', ...name.split('/'))
101
+ if (exists(candidate)) return candidate
102
+ }
103
+ return null
104
+ }
105
+
106
+ export const name = 'fang-orchestrator'
107
+ export const inject = []
108
+
109
+ export const Config = z.object({
110
+ role: z.union([z.const('mother'), z.const('fork')]).default('mother')
111
+ .description('配置层角色;环境 DSH_FANG_ROLE 优先(forker 派生的分身自动为 fork)'),
112
+ officialSourceUrl: z.string().default('')
113
+ .description('官方源 manifest 基础 URL(https;留空 = 无官方源,升级显式 official-source-unavailable)'),
114
+ officialSourceToken: z.string().default('')
115
+ .description('官方源 Bearer token(可选;不会写进日志/状态)'),
116
+ maxForks: z.number().min(1).max(64).default(8)
117
+ .description('同时存活的任务/分身上限'),
118
+ motherProfile: z.string().default('')
119
+ .description('母体 profile 名;留空 = 当前 profile(分身模板来源)'),
120
+ profileTemplate: z.string().default('')
121
+ .description('分身 profile 模板目录;留空 = 当前 profile 目录(Forker 的 cleanTemplate 会覆盖)'),
122
+ fangHome: z.string().default('')
123
+ .description('三区数据根(相当于 FANG_HOME);留空 = 自动(新装默认 D:\\ToFuture,已有旧数据沿用旧布局)'),
124
+ defaultEssenceAuto: z.boolean().default(true)
125
+ .description('新用户自动生成“默认元宝”(仅当没有任何用户元主、且默认插件已有安装时;失败不影响启动)'),
126
+ cliPath: z.string().default('')
127
+ .description('dsh 启动器路径;留空自动探测(PATH 上的 dsh / 安装目录 dsh.cmd)'),
128
+ dataRoot: z.string().default('')
129
+ .description('数据根目录;留空 = $DSH_HOME/fang/<profileTag>'),
130
+ forksRoot: z.string().default('')
131
+ .description('分身 home 根目录;留空 = <dataRoot>/forks'),
132
+ mirrorRoot: z.string().default('')
133
+ .description('母体镜像根目录;留空 = <dataRoot>/mirror(备份/恢复/升级都在这一个文件夹里)'),
134
+ adapterTimeoutMs: z.number().min(100).max(120000).default(10000)
135
+ .description('派生后等待分身 Adapter 就绪的上限(毫秒);超时任务记为 running(adapter 未就绪)'),
136
+ adapterPollMs: z.number().min(50).max(5000).default(400)
137
+ .description('Adapter 握手轮询间隔(毫秒)'),
138
+ importMaxBytes: z.number().min(0).max(268435456).default(33554432)
139
+ .description('派生时导入母体历史会话日志的大小上限(字节);超过则只保留交接摘要,不复制历史'),
140
+ allowProcessRestart: z.boolean().default(false)
141
+ .description('是否允许编排器主动重启母体(默认禁止;重启一律交给 restart-guard)'),
142
+ autoReapIdleMinutes: z.number().min(0).max(1440).default(0)
143
+ .description('空闲多少分钟后自动回收任务(0 = 不自终)'),
144
+ archiveStopTimeoutMs: z.number().min(100).max(30000).default(3000)
145
+ .description('归档时等待分身进程真正退出的上限(毫秒)'),
146
+ commercial: z.object({
147
+ collaboration: z.boolean().default(false).description('联合开发(需 fangCommercial provider)'),
148
+ git: z.boolean().default(false).description('本地 Git 快照(需 provider)'),
149
+ backup: z.boolean().default(false).description('云备份(需 provider)'),
150
+ tokenApi: z.boolean().default(false).description('Token API(不在本插件内实现)'),
151
+ teams: z.boolean().default(false).description('团队权限(需 provider)'),
152
+ }).default({}),
153
+ routes: z.object({
154
+ enabled: z.boolean().default(true).description('是否注册 /fang 路由'),
155
+ prefix: z.string().default('/fang').description('路由前缀'),
156
+ }).default({}),
157
+ authToken: z.string().default('')
158
+ .description('v3.0.5/B1:安装级鉴权 token;留空 = 读 DSH_FANG_AUTH_TOKEN 或 <dataDir>/fang-auth.json(0600,自动生成)'),
159
+ authEnabled: z.boolean().default(true)
160
+ .description('v3.0.5/B1:是否要求 /fang/api 携带鉴权 token(默认开启;仅测试/本地调试可关)'),
161
+ contract: z.object({
162
+ enabled: z.boolean().default(true).description('v3.1.15:是否启用 fang-master 契约端点/服务'),
163
+ token: z.string().default('').description('契约 HTTP Bearer token(留空 = HTTP 契约端点禁用;不写日志/状态)'),
164
+ fileDir: z.string().default('').description('可选:文件 outbox 兜底目录(同机 fallback;留空 = 关闭)'),
165
+ pathPrefix: z.string().default('/fang/api/contract').description('契约路由前缀'),
166
+ }).default({}),
167
+ })
168
+
169
+ const HTML_ESCAPE = (s) => String(s ?? '').replace(/[&<>"']/g, c => (
170
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
171
+ ))
172
+
173
+ /** v3.0.5/B4:对外的任务 URL 一律去掉 ?token=(token 只在内存/打开时临时下发)。 */
174
+ export function stripUrlToken(url) {
175
+ const raw = String(url ?? '')
176
+ if (!raw) return raw || null
177
+ try {
178
+ const parsed = new URL(raw)
179
+ if (!parsed.searchParams.has('token')) return raw
180
+ parsed.searchParams.delete('token')
181
+ return parsed.toString()
182
+ } catch {
183
+ return raw.replace(/([?&])token=[^&#]*&?/i, (_, sep) => (sep === '?' ? '?' : '')).replace(/[?&]$/, '')
184
+ }
185
+ }
186
+
187
+ /** Find a runnable dsh launcher. `.cmd` wrappers are spawned through a shell by the forker. */
188
+ export function resolveCliPath(explicit = '', env = process.env, execPath = process.execPath) {
189
+ // v3.1.33:平台感知。v3.1.32 及以前只探测 Windows 的 dsh.cmd + 硬编码盘符,
190
+ // 在 macOS 上必然找不到 launcher → 回落裸 'dsh' → spawn ENOENT → spawn-error。
191
+ // (MacMini 实测:runtime-spawn-failed: spawn-error, profile=essence-runtime)
192
+ const isWindows = process.platform === 'win32'
193
+ // win 用 resources,mac 的 .app 是 Contents/Resources(大写)——按平台给真实大小写优先。
194
+ const binDirNames = isWindows ? ['resources', 'Resources'] : ['Resources', 'resources']
195
+ const binNames = isWindows ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh', 'dsh.sh']
196
+ const candidates = []
197
+ const push = (value) => { if (value) candidates.push(value) }
198
+
199
+ if (explicit) push(explicit)
200
+ if (env.DSH_CLI) push(env.DSH_CLI)
201
+ if (env.DSH_INSTALL_DIR) {
202
+ for (const dir of binDirNames) {
203
+ for (const name of binNames) push(path.join(env.DSH_INSTALL_DIR, dir, 'runtime', 'cli', 'bin', name))
204
+ }
205
+ }
206
+ for (const dir of String(env.PATH ?? env.Path ?? '').split(path.delimiter)) {
207
+ if (!dir) continue
208
+ for (const name of binNames) push(path.join(dir, name))
209
+ }
210
+ // 从当前宿主可执行文件反推安装根:
211
+ // win : <install>\DeepSeek Harness.exe → <install>\resources\runtime\cli\bin\dsh.cmd
212
+ // mac : <app>/Contents/MacOS/DeepSeek Harness → <app>/Contents/Resources/runtime/cli/bin/dsh
213
+ try {
214
+ let dir = path.dirname(String(execPath ?? ''))
215
+ for (let i = 0; i < 6 && dir; i += 1) {
216
+ for (const sub of binDirNames) {
217
+ for (const name of binNames) {
218
+ push(path.join(dir, sub, 'runtime', 'cli', 'bin', name))
219
+ push(path.join(dir, 'runtime', 'cli', 'bin', name))
220
+ }
221
+ }
222
+ const parent = path.dirname(dir)
223
+ if (parent === dir) break
224
+ dir = parent
225
+ }
226
+ } catch { /* keep legacy candidates */ }
227
+
228
+ if (isWindows) {
229
+ push('E:\\DS\\resources\\runtime\\cli\\bin\\dsh.cmd')
230
+ push('C:\\Program Files\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd')
231
+ } else {
232
+ // macOS / Linux 常见安装位置(含 .app 包内 CLI)
233
+ push('/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh')
234
+ push(path.join(String(env.HOME ?? ''), 'Applications', 'DeepSeek Harness.app', 'Contents', 'Resources', 'runtime', 'cli', 'bin', 'dsh'))
235
+ push('/opt/homebrew/bin/dsh')
236
+ push('/usr/local/bin/dsh')
237
+ push('/usr/bin/dsh')
238
+ }
239
+
240
+ for (const c of candidates) {
241
+ try { if (c && fs.existsSync(c)) return c } catch { /* next */ }
242
+ }
243
+ return explicit || 'dsh'
244
+ }
245
+
246
+ export function isShellLauncher(cliPath) {
247
+ return /\.(cmd|bat)$/i.test(String(cliPath))
248
+ }
249
+
250
+ /** Durable task registry (v1 files stay readable: they carry the same forkId field). */
251
+ function loadRegistry(file) {
252
+ const raw = readJson(file, [])
253
+ if (Array.isArray(raw)) return raw
254
+ if (raw && Array.isArray(raw.forks)) return raw.forks
255
+ return []
256
+ }
257
+
258
+ function saveRegistry(file, entries) {
259
+ writeJsonAtomic(file, { version: 2, at: iso(), forks: entries })
260
+ }
261
+
262
+ /** Normalize a lineage seed payload; returns null when absent. Bounded by 64 KiB. */
263
+ export function normalizeSeed(seed) {
264
+ if (!seed || typeof seed !== 'object') return null
265
+ const sessionId = String(seed.sessionId ?? '').trim()
266
+ const markdown = String(seed.markdown ?? '')
267
+ if (!sessionId && !markdown) return null
268
+ if (markdown.length > 64 * 1024) throw new Error('seed-too-large')
269
+ return { sessionId, markdown: markdown || `# lineage seed\n\n- session: ${sessionId}\n- mode: lineage-only\n` }
270
+ }
271
+
272
+ export function apply(ctx, config) {
273
+ const cfg = { ...Config({}), ...(config ?? {}) }
274
+ const gate = roleGate('orchestrator', { cfgRole: cfg.role, logger: ctx.logger })
275
+ if (!gate.active) return
276
+ const role = gate.role
277
+
278
+ const layout = resolveFangLayout({ dataRoot: cfg.dataRoot, forksRoot: cfg.forksRoot, fangHome: cfg.fangHome })
279
+ const branchStore = new BranchStore(layout.dataDir)
280
+ const composerJournal = new ComposerJournal(layout.dataDir)
281
+ // v3.0.5/B1:安装级 token(0600 文件 / env / 显式配置);关闭仅限测试
282
+ const authToken = cfg.authEnabled === false
283
+ ? ''
284
+ : loadAuthToken(layout.dataDir, cfg.authToken || process.env.DSH_FANG_AUTH_TOKEN)
285
+ const motherProfile = cfg.motherProfile || layout.profileTag
286
+ ensureDir(layout.dataDir)
287
+ ensureDir(layout.forkHomesDir)
288
+
289
+ /** @type {Map<string, any>} */
290
+ const tasks = new Map()
291
+ // v2 owns tasks.json. A v1 install only has forks.json; read it once so a live upgrade
292
+ // does not lose the task list, but never write it back (the forker owns that file).
293
+ const registrySource = exists(layout.tasksFile) ? layout.tasksFile : layout.forksFile
294
+ for (const f of loadRegistry(registrySource)) {
295
+ if (f && typeof f.forkId === 'string') {
296
+ const task = normalizeTask(f)
297
+ tasks.set(task.taskId, task)
298
+ }
299
+ }
300
+
301
+ const safeGet = (key) => { try { return ctx.get(key) } catch { return undefined } }
302
+ // v3.3.3: queue journal is an optional sub-plugin. Its host half provides
303
+ // SERVICE.queueJournal; without it the journal/queue routes must fail closed
304
+ // and the client half is never registered (row disabled -> no UI).
305
+ const queueJournalEnabled = () => Boolean(safeGet(SERVICE.queueJournal))
306
+ /**
307
+ * 宿主内部生产者 → 决策中心(best-effort,绝不改变原流程结果)。
308
+ * 用于 MirrorUpgrade / EssenceManager 这类仓内对象;外部系统(PM/PJM/Worker/
309
+ * HarnessMM/PluginUpdater/SuperAgent)走 POST /fang/api/decisions/ingest 桥。
310
+ */
311
+ const raiseDecisionTicket = (spec = {}) => { try {
312
+ const center = safeGet(SERVICE.decisionCenter)
313
+ if (!center || typeof center.submit !== 'function') return null
314
+ const submitted = center.submit({
315
+ level: spec.level ?? 'yellow',
316
+ title: spec.title,
317
+ context: spec.context,
318
+ options: spec.options,
319
+ actions: spec.actions,
320
+ timeoutMinutes: spec.timeoutMinutes ?? 1440,
321
+ dedupeKey: spec.dedupeKey,
322
+ source: {
323
+ sessionId: String(spec.sessionId ?? 'orchestrator'),
324
+ agentRole: spec.agentRole ?? 'system',
325
+ roleSource: 'host:orchestrator',
326
+ roleTrusted: true,
327
+ taskName: String(spec.sourceName ?? spec.producer ?? 'orchestrator'),
328
+ },
329
+ metadata: spec.metadata,
330
+ })
331
+ return submitted?.ok ? submitted.ticket : null
332
+ } catch { return null }
333
+ }
334
+ const businessProducers = createBusinessProducers({ submit: (spec) => raiseDecisionTicket(spec) })
335
+ const audit = (kind, data) => {
336
+ try { safeGet(SERVICE.auditor)?.append(kind, data) } catch { /* auditor optional */ }
337
+ try { bus.emit('fang/audit', { kind, data }) } catch { /* ignore */ }
338
+ }
339
+ const persist = () => {
340
+ try {
341
+ // v3.0.5/B4:tasks.json 不落 token,URL 也不带 ?token=(打开时走 open-url 临时下发)
342
+ saveRegistry(layout.tasksFile, [...tasks.values()].map(task => ({
343
+ ...task, token: '', url: stripUrlToken(task.url),
344
+ })))
345
+ } catch { /* best effort */ }
346
+ }
347
+ const forker = () => safeGet(SERVICE.forker)
348
+ const cliPath = () => (cfg.cliPath ? cfg.cliPath : resolveCliPath('', process.env))
349
+ // The booted profile's own directory is authoritative (launcher sets DSH_PROFILE_DIR);
350
+ // resolving "$DSH_HOME/profiles/<tag>" from argv can point at a template that never existed.
351
+ // 分身模板必须来自一个**真的装了本 bundle** 的 profile;否则分身的 node_modules junction
352
+ // 指到一个没有 @twinsearth/fang-dsh-plugin 的 profile,fork 里所有 /fang 路由都会 404(实测事故)。
353
+ const templateDir = () => {
354
+ const candidates = [cfg.profileTemplate, process.env.DSH_PROFILE_DIR, resolveProfileDir(motherProfile), resolveProfileDir(layout.profileTag)]
355
+ const usable = candidates.filter((dir) => typeof dir === "string" && dir.trim()
356
+ && exists(path.join(dir, "node_modules", "@local", "dsh-fang")))
357
+ if (usable.length > 0) return usable[0]
358
+ return candidates.find((dir) => typeof dir === "string" && dir.trim()) ?? resolveProfileDir(layout.profileTag)
359
+ }
360
+ const commercialHooks = commercialConfigSchemaDefaults()
361
+ for (const key of Object.keys(commercialHooks)) {
362
+ if (cfg.commercial && cfg.commercial[key] === true) commercialHooks[key] = true
363
+ }
364
+
365
+ function normalizeTask(f) {
366
+ const taskId = f.taskId ?? f.forkId
367
+ const status = f.status === FORK_STATUS.RUNNING ? TASK_STATUS.RUNNING
368
+ : f.status === FORK_STATUS.CRASHED ? TASK_STATUS.FAILED
369
+ : f.status === FORK_STATUS.STOPPED ? TASK_STATUS.ARCHIVED
370
+ : f.status ?? TASK_STATUS.PROVISIONING
371
+ return {
372
+ taskId,
373
+ forkId: taskId,
374
+ title: f.title ?? f.taskTitle ?? taskId,
375
+ taskTitle: f.title ?? f.taskTitle ?? taskId,
376
+ profile: f.profile,
377
+ homeDir: f.homeDir,
378
+ status,
379
+ createdAt: f.createdAt ?? nowMs(),
380
+ restarts: f.restarts ?? 0,
381
+ pid: f.pid ?? null,
382
+ port: f.port ?? null,
383
+ url: f.url ?? null,
384
+ token: f.token ?? (f.url ? extractTokenSafe(f.url) : ''),
385
+ adapter: f.adapter ?? { ok: false, error: 'not-handshaked' },
386
+ collaborators: Array.isArray(f.collaborators) ? f.collaborators : [],
387
+ template: f.template ?? '',
388
+ essenceId: f.essenceId ?? null,
389
+ sourceEssence: f.sourceEssence ?? null,
390
+ }
391
+ }
392
+
393
+ function extractTokenSafe(url) {
394
+ try {
395
+ const u = new URL(String(url))
396
+ return u.searchParams.get('token') ?? ''
397
+ } catch { return '' }
398
+ }
399
+
400
+ /** Layout of one fork's private $DSH_HOME (its buffer lives inside it). */
401
+ function forkLayout(info) {
402
+ return resolveFangLayout({
403
+ env: { ...process.env, DSH_HOME: info.homeDir },
404
+ argv: ['--profile', info.profile],
405
+ })
406
+ }
407
+
408
+ // ── 跨进程 remote 客户端(懒加载,模块缺失时显式降级)────────────────────────
409
+ let remoteModulePromise = null
410
+ const remoteModule = async () => {
411
+ if (!remoteModulePromise) {
412
+ remoteModulePromise = import('../lib/remote.mjs').catch(() => null)
413
+ }
414
+ return remoteModulePromise
415
+ }
416
+ const remoteCache = new Map()
417
+ async function remoteFor(task) {
418
+ if (!task?.url) return null
419
+ // v3 Bearer token 只从 Forker 内存取(不落盘、不进 task 持久化字段)。
420
+ const adapterToken = (() => {
421
+ try {
422
+ const f = forker()
423
+ return f && typeof f.adapterToken === 'function' ? String(f.adapterToken(task.taskId) ?? '') : ''
424
+ } catch {
425
+ return ''
426
+ }
427
+ })()
428
+ const cached = remoteCache.get(task.taskId)
429
+ if (cached && cached.url === task.url && cached.adapterToken === adapterToken) return cached.client
430
+ const mod = await remoteModule()
431
+ if (!mod || typeof mod.createRemote !== 'function') return null
432
+ const client = mod.createRemote({
433
+ url: task.url,
434
+ token: task.token || (mod.extractToken ? mod.extractToken(task.url) : ''),
435
+ adapterToken,
436
+ timeoutMs: 8000,
437
+ })
438
+ remoteCache.set(task.taskId, { url: task.url, adapterToken, client })
439
+ return client
440
+ }
441
+
442
+ /** Poll the fork-side Adapter until it answers hello (bounded by adapterTimeoutMs). */
443
+ async function handshake(task) {
444
+ const client = await remoteFor(task)
445
+ if (!client) {
446
+ task.adapter = { ok: false, error: 'remote-module-missing', at: nowMs() }
447
+ return task.adapter
448
+ }
449
+ const deadline = nowMs() + cfg.adapterTimeoutMs
450
+ let last = null
451
+ while (nowMs() < deadline) {
452
+ const res = await client.hello()
453
+ if (res?.ok && res.data?.ok) {
454
+ // v3 版本协商(PROTOCOL-V3 §2):旧分身没有 /adapter/version(404)→ 按 legacy 接受;
455
+ // 新分身回 protocol 且超出母体支持区间 → fail-closed,绝不假装 READY。
456
+ let protocol = Number(res.data.protocol) || 0
457
+ let sessionId = String(res.data.sessionId ?? '')
458
+ try {
459
+ const ver = typeof client.version === 'function' ? await client.version() : null
460
+ if (ver?.ok && ver.data) {
461
+ const negotiated = negotiateProtocol(ver.data.protocol)
462
+ if (!negotiated.ok) {
463
+ task.adapter = { ok: false, error: negotiated.reason, protocol: ver.data.protocol, at: nowMs() }
464
+ audit(AUDIT.ADAPTER_HANDSHAKE, { taskId: task.taskId, ok: false, error: negotiated.reason, protocol: ver.data.protocol })
465
+ return task.adapter
466
+ }
467
+ protocol = negotiated.protocol
468
+ sessionId = String(ver.data.sessionId ?? sessionId)
469
+ }
470
+ } catch {
471
+ /* 版本探测失败不改变 hello 的结论;按 hello 自报的 protocol 记录 */
472
+ }
473
+ task.adapter = {
474
+ ok: true,
475
+ version: res.data.version ?? '',
476
+ protocol,
477
+ sessionId,
478
+ role: res.data.role ?? 'fork',
479
+ taskId: res.data.taskId ?? task.taskId,
480
+ at: nowMs(),
481
+ }
482
+ audit(AUDIT.ADAPTER_HANDSHAKE, { taskId: task.taskId, ok: true, version: task.adapter.version, protocol })
483
+ return task.adapter
484
+ }
485
+ last = res
486
+ await sleep(cfg.adapterPollMs)
487
+ }
488
+ task.adapter = { ok: false, error: last?.error ?? last?.kind ?? 'timeout', at: nowMs() }
489
+ audit(AUDIT.ADAPTER_HANDSHAKE, { taskId: task.taskId, ok: false, error: task.adapter.error })
490
+ return task.adapter
491
+ }
492
+
493
+ function taskView(t) {
494
+ const live = forker()?.status?.(t.taskId)
495
+ const running = live ? !!live.running : t.status === TASK_STATUS.RUNNING || t.status === TASK_STATUS.READY
496
+ const status = !running ? (t.status === TASK_STATUS.ARCHIVED ? TASK_STATUS.ARCHIVED : TASK_STATUS.FAILED)
497
+ : (t.adapter?.ok ? TASK_STATUS.READY : TASK_STATUS.RUNNING)
498
+ const phase = !running
499
+ ? (t.status === TASK_STATUS.ARCHIVED ? 'archived' : (t.status === TASK_STATUS.FAILED ? 'failed' : 'stopped'))
500
+ : (t.adapter?.ok ? 'running' : 'starting')
501
+ return {
502
+ taskId: t.taskId,
503
+ forkId: t.taskId,
504
+ title: t.title,
505
+ taskTitle: t.title,
506
+ status,
507
+ phase,
508
+ health: { adapter: t.adapter?.ok ? 'ok' : 'pending', error: t.adapter?.error ?? '' },
509
+ // v3.0.7/S1:动作可用性由服务端给出,UI 不再自行推断
510
+ ...(() => {
511
+ const ui = buildTaskActions({ running, archived: t.status === TASK_STATUS.ARCHIVED })
512
+ return { actions: ui.actions, blocks: ui.blocks }
513
+ })(),
514
+ sessionTitle: t.importedSession?.title ?? t.seed?.title ?? '',
515
+ startedAt: t.startedAt ?? live?.startedAt ?? null,
516
+ updatedAt: t.updatedAt ?? live?.exitedAt ?? t.createdAt,
517
+ projectBytes: t.projectBytes ?? 0,
518
+ essenceId: t.essenceId ?? null,
519
+ // 视图层才扫描会话(normalizeTask 在 apply 早期执行,不能引用后声明的 scanForkSessions)。
520
+ sessions: (() => { const list = scanForkSessions(t); return { count: list.length, min: FORK_SESSION_MIN, max: FORK_SESSION_MAX, violation: list.length > FORK_SESSION_MAX, belowFloor: list.length < FORK_SESSION_MIN } })(),
521
+ // 来源原身已被删除:分身仍可运行/归档/还原/删除,但不能重新派生。
522
+ essenceDeleted: Boolean(t.essenceId) && (t.essenceDeleted === true || isTombstoned(t.essenceId)),
523
+ // 方案 B:同一分身可挂多个分支会话,但同时只有一个 active。
524
+ branch: (() => {
525
+ try {
526
+ const store = branchStore.list(t.taskId)
527
+ return { activeBranchId: store.activeBranchId ?? t.activeBranchId ?? null, sessionId: store.sessionId ?? null, count: Array.isArray(store.branches) ? store.branches.length : 0 }
528
+ } catch {
529
+ return { activeBranchId: t.activeBranchId ?? null, sessionId: null, count: 0 }
530
+ }
531
+ })(),
532
+ pid: live?.pid ?? t.pid ?? null,
533
+ port: t.port ?? null,
534
+ url: stripUrlToken(t.url),
535
+ homeDir: t.homeDir,
536
+ profile: t.profile,
537
+ createdAt: t.createdAt,
538
+ restarts: t.restarts ?? 0,
539
+ adapter: { ok: !!t.adapter?.ok, version: t.adapter?.version ?? '', protocol: t.adapter?.protocol ?? 0, error: t.adapter?.error ?? '' },
540
+ collaborators: t.collaborators ?? [],
541
+ seed: t.seed ?? null,
542
+ importedSession: t.importedSession ?? null,
543
+ archivedAt: t.archivedAt ?? null,
544
+ archiveRemote: t.archiveRemote ?? null,
545
+ }
546
+ }
547
+
548
+ /**
549
+ * 把一个母体会话日志有界地导入分身自己的 $DSH_HOME(会话目录 + workspace 索引),
550
+ * 让分身原生 UI 能看到这段历史;超过 importMaxBytes 则不复制,只保留交接种子。
551
+ */
552
+ const importSessionIntoFork = (homeDir, meta) => {
553
+ const sessionId = String(meta?.sessionId ?? '').trim()
554
+ const sourceFile = String(meta?.sourceFile ?? '').trim()
555
+ if (!sessionId || !sourceFile) return { ok: false, reason: 'bad-import-meta' }
556
+ let bytes = 0
557
+ try { bytes = fs.statSync(sourceFile).size } catch { return { ok: false, reason: 'source-missing' } }
558
+ if (bytes > cfg.importMaxBytes) return { ok: false, reason: 'too-large', bytes, limit: cfg.importMaxBytes }
559
+ const workspacePath = String(meta?.workspacePath ?? '').trim() || 'imported-session'
560
+ const workspaceKey = encodeWorkspaceKey(workspacePath)
561
+ const workspaceTitle = String(meta?.workspaceTitle ?? '').trim() || '导入的会话'
562
+ const destDir = path.join(homeDir, 'sessions', workspaceKey, sessionId)
563
+ ensureDir(destDir)
564
+ fs.copyFileSync(sourceFile, path.join(destDir, 'session.v4.jsonl.zstd'))
565
+
566
+ const storagesDir = path.join(homeDir, 'storages')
567
+ ensureDir(storagesDir)
568
+ const indexFile = path.join(storagesDir, 'workspace.json')
569
+ let doc = null
570
+ try { doc = readJson(indexFile) } catch { doc = null }
571
+ if (!doc || typeof doc !== 'object') {
572
+ doc = { unit: { name: 'workspace', version: 2 }, global: { initialized: true, defaultWorkspaceId: null, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] }, tables: { workspaces: {} } }
573
+ }
574
+ doc.tables = doc.tables && typeof doc.tables === 'object' ? doc.tables : {}
575
+ doc.tables.workspaces = doc.tables.workspaces && typeof doc.tables.workspaces === 'object' ? doc.tables.workspaces : {}
576
+ doc.global = doc.global && typeof doc.global === 'object' ? doc.global : { initialized: true, defaultWorkspaceId: null, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] }
577
+ doc.global.workspaceIds = Array.isArray(doc.global.workspaceIds) ? doc.global.workspaceIds : []
578
+ const workspaceId = `ws-${sha256(workspacePath).slice(0, 12)}`
579
+ const existing = doc.tables.workspaces[workspaceId]
580
+ const sessionIds = Array.isArray(existing?.sessionIds) ? existing.sessionIds.filter((id) => typeof id === 'string') : []
581
+ if (!sessionIds.includes(sessionId)) sessionIds.push(sessionId)
582
+ doc.tables.workspaces[workspaceId] = {
583
+ path: workspacePath,
584
+ title: workspaceTitle,
585
+ sessionIds,
586
+ createdAt: existing?.createdAt ?? (Number.isFinite(meta?.createdAt) ? iso(meta.createdAt) : iso(nowMs())),
587
+ updatedAt: iso(nowMs()),
588
+ }
589
+ if (!doc.global.workspaceIds.includes(workspaceId)) doc.global.workspaceIds.push(workspaceId)
590
+ if (!doc.global.defaultWorkspaceId) doc.global.defaultWorkspaceId = workspaceId
591
+ doc.global.initialized = true
592
+ writeJsonAtomic(indexFile, doc)
593
+ return { ok: true, bytes, sessionId, workspacePath, workspaceKey, workspaceTitle, indexFile }
594
+ }
595
+
596
+ // v3.1.35:分身会话数约束 1..3(用户规格)。
597
+ // · 下限 1:分身 Web 里至少有一个会话(空白派生时自动补空会话);
598
+ // · 上限 3:多出的移到 quarantine-sessions/(不删除);
599
+ const scanForkSessions = (task) => {
600
+ const root = path.join(String(task?.homeDir ?? ''), 'sessions')
601
+ const out = []
602
+ try {
603
+ for (const workspace of fs.readdirSync(root, { withFileTypes: true })) {
604
+ if (!workspace.isDirectory()) continue
605
+ const wsDir = path.join(root, workspace.name)
606
+ for (const session of fs.readdirSync(wsDir, { withFileTypes: true })) {
607
+ if (!session.isDirectory()) continue
608
+ const full = path.join(wsDir, session.name)
609
+ let mtime = 0
610
+ try { mtime = fs.statSync(full).mtimeMs } catch { mtime = 0 }
611
+ out.push({ workspaceKey: workspace.name, sessionId: session.name, path: full, mtime })
612
+ }
613
+ }
614
+ } catch { /* 没有 sessions 目录 */ }
615
+ return out.sort((a, b) => b.mtime - a.mtime)
616
+ }
617
+ const applyForkSessionGuard = (task) => {
618
+ // 受管的分支子会话不参与“单会话”隔离(它们由分支库登记,属于同一分身的合法分支)。
619
+ const branchSessionIds = new Set()
620
+ try {
621
+ for (const branch of branchStore.list(task.taskId).branches ?? []) {
622
+ if (branch?.sessionId) branchSessionIds.add(String(branch.sessionId))
623
+ }
624
+ const storeSession = branchStore.load?.(task.taskId)?.sessionId
625
+ if (storeSession) branchSessionIds.add(String(storeSession))
626
+ } catch { /* 无分支库 */ }
627
+ const sessions = scanForkSessions(task).filter((session) => !branchSessionIds.has(session.sessionId))
628
+ const bindBranches = (keptId) => { try { branchStore?.bindSession?.(task.taskId, keptId) } catch { /* ignore */ } }
629
+ if (sessions.length <= FORK_SESSION_MAX) {
630
+ const kept = sessions[0]?.sessionId ?? null
631
+ bindBranches(kept)
632
+ return { ok: true, kept, keptCount: sessions.length, quarantined: [] }
633
+ }
634
+ const keep = sessions[0]
635
+ const rest = sessions.slice(FORK_SESSION_MAX)
636
+ const stamp = String(nowMs())
637
+ const quarantined = []
638
+ for (const session of rest) {
639
+ const target = path.join(task.homeDir, 'quarantine-sessions', stamp, session.workspaceKey, session.sessionId)
640
+ try {
641
+ ensureDir(path.dirname(target))
642
+ try { fs.renameSync(session.path, target) } catch { copyTree(session.path, target); removeTree(session.path) }
643
+ quarantined.push({ workspaceKey: session.workspaceKey, sessionId: session.sessionId, movedTo: target, at: nowMs() })
644
+ } catch { /* 移动失败保留原样,不破坏数据 */ }
645
+ }
646
+ if (quarantined.length > 0) {
647
+ task.quarantinedSessions = (Array.isArray(task.quarantinedSessions) ? task.quarantinedSessions : []).concat(quarantined)
648
+ persist()
649
+ audit('fork-session-quarantined', { taskId: task.taskId, kept: keep.sessionId, moved: quarantined.length })
650
+ }
651
+ bindBranches(keep.sessionId)
652
+ return { ok: true, kept: keep.sessionId, keptCount: FORK_SESSION_MAX, quarantined }
653
+ }
654
+
655
+ /**
656
+ * v3.1.35:分身会话下限。已有会话则不动;没有则造一个空会话(DSH 合法格式)。
657
+ * 分支会话也算已有会话——它们属于同一分身。
658
+ */
659
+ const ensureForkSessionFloor = async ({ homeDir, task, cwd }) => {
660
+ const existing = summarizeForkSessions(homeDir)
661
+ if (!existing.belowFloor) return { ok: true, created: false, count: existing.count }
662
+ const workspacePath = String(cwd ?? '').trim()
663
+ || String(task?.importedSession?.workspacePath ?? '').trim()
664
+ || layout.forkHomesDir
665
+ const created = ensureForkEmptySession(homeDir, {
666
+ workspacePath,
667
+ workspaceTitle: path.basename(workspacePath) || '新会话',
668
+ })
669
+ // 补完空会话后立刻套用上限,避免越界。
670
+ try { enforceForkSessionCap(homeDir, { max: FORK_SESSION_MAX }) } catch { /* 上限失败不阻塞派生 */ }
671
+ return { ...created, count: summarizeForkSessions(homeDir).count }
672
+ }
673
+
674
+ const tombstonesFile = path.join(layout.dataDir, 'deleted-essences.json')
675
+ const loadTombstones = () => {
676
+ try {
677
+ const doc = readJson(tombstonesFile, {})
678
+ return Array.isArray(doc?.ids) ? doc.ids.map(String) : []
679
+ } catch { return [] }
680
+ }
681
+ const isTombstoned = (essenceId) => loadTombstones().includes(String(essenceId ?? ''))
682
+ const addTombstone = (essenceId, name) => {
683
+ const ids = new Set(loadTombstones())
684
+ ids.add(String(essenceId))
685
+ writeJsonAtomic(tombstonesFile, { ids: [...ids], updatedAt: nowMs(), last: { id: String(essenceId), name: String(name ?? ''), at: nowMs() } })
686
+ }
687
+
688
+ // v3.0.9 默认元宝:新用户免创建,自动生成一个带默认插件清单的元主。
689
+ const defaultEssenceFile = path.join(layout.dataDir, 'default-essence.json')
690
+ const readDefaultEssenceId = () => {
691
+ try { return String(readJson(defaultEssenceFile, {})?.essenceId ?? '') } catch { return '' }
692
+ }
693
+ const writeDefaultEssenceId = (essenceId) => writeJsonAtomic(defaultEssenceFile, { essenceId: String(essenceId), at: nowMs() })
694
+ const listInstalledPlugins = async () => {
695
+ try {
696
+ const pm = safeGet('pluginManager')
697
+ if (!pm || typeof pm.listPlugins !== 'function') return null
698
+ const out = await pm.listPlugins()
699
+ if (Array.isArray(out)) return out
700
+ if (Array.isArray(out?.plugins)) return out.plugins
701
+ return null
702
+ } catch { return null }
703
+ }
704
+
705
+ /**
706
+ * 只读:重启守卫 + supervisor 契约状态。
707
+ * status() 只带摘要(Client Header 用),/fang/api/restart 带完整视图。
708
+ * 任何字段缺失都按「不可用/未知」返回,绝不伪造已武装或已执行。
709
+ */
710
+ const restartView = () => {
711
+ const guard = safeGet(SERVICE.restart)
712
+ if (!guard) return { available: false }
713
+ try {
714
+ const state = typeof guard.state === 'function' ? guard.state() : null
715
+ const policy = typeof guard.policy === 'function' ? guard.policy() : null
716
+ const contract = typeof guard.executionContract === 'function' ? guard.executionContract() : null
717
+ const approvals = typeof guard.approvals === 'function' ? guard.approvals({ limit: 20 }) : null
718
+ return { available: true, state, policy, contract, approvals, decisionOnly: true, executed: false }
719
+ } catch (error) {
720
+ return { available: true, error: 'restart-guard-status-failed', detail: String(error?.message ?? error) }
721
+ }
722
+ }
723
+
724
+ const restartSummary = (view) => {
725
+ if (!view?.available) return { available: false }
726
+ if (view.error) return { available: true, error: view.error }
727
+ const s = view.state ?? {}
728
+ const p = view.policy ?? {}
729
+ return {
730
+ available: true,
731
+ state: s.state ?? null,
732
+ pending: s.pending ?? null,
733
+ mode: p.mode ?? s.mode ?? null,
734
+ authMode: p.authMode ?? s.authMode ?? null,
735
+ strictRedLine: p.strictRedLine ?? null,
736
+ redLineAutoAllowed: p.redLineAutoAllowed === true || s.redLineAutoAllowed === true,
737
+ allowProcessRestart: p.allowProcessRestart === true || s.allowProcessRestart === true,
738
+ tempEffectiveManual: p.tempEffectiveManual === true || s.tempEffectiveManual === true,
739
+ decisionOnly: true,
740
+ contractVersion: s.contractVersion ?? null,
741
+ manifestTtlMs: p.manifestTtlMs ?? s.manifestTtlMs ?? null,
742
+ approvals: view.approvals?.counts ?? null,
743
+ }
744
+ }
745
+
746
+ const service = {
747
+ role,
748
+ version: FANG_VERSION,
749
+ dataDir: layout.dataDir,
750
+
751
+ /** v2 主入口:派生一个任务(= 一个分身)。 */
752
+ async createTask(input = {}) {
753
+ const title = String(input.title ?? input.taskTitle ?? '').trim() || `任务 ${tasks.size + 1}`
754
+ const running = [...tasks.values()].filter(t => {
755
+ const s = taskView(t).status
756
+ return s === TASK_STATUS.RUNNING || s === TASK_STATUS.READY
757
+ }).length
758
+ if (running >= cfg.maxForks) {
759
+ return { ok: false, error: 'quota-exceeded', detail: `已达上限 ${cfg.maxForks},请先归档旧任务` }
760
+ }
761
+ const f = forker()
762
+ if (!f || typeof f.spawn !== 'function') {
763
+ return { ok: false, error: 'forker-unavailable', detail: 'fang-forker 未加载' }
764
+ }
765
+
766
+ const taskId = newId('task')
767
+ const profile = `fang-${taskId.replace(/^task_/, '').slice(0, 12)}`
768
+ const homeDir = path.join(layout.forkHomesDir, taskId)
769
+ const template = String(input.template ?? '').trim()
770
+ // v3.0.9:从原身派生时,模板来源是该原身的 merged/profile(分身只生产,不回写原身)。
771
+ // v3.0.9:未指定来源时,若存在“默认元宝”,快速派生直接以它为模板(新用户小白路径)。
772
+ let essenceIdInput = String(input.essenceId ?? '').trim()
773
+ if (!essenceIdInput && !template) {
774
+ const presetId = readDefaultEssenceId()
775
+ if (presetId && essenceManager.getMeta(presetId)?.id) essenceIdInput = presetId
776
+ }
777
+ let sourceEssence = null
778
+ if (essenceIdInput) {
779
+ const essenceId = essenceIdInput
780
+ const meta = essenceManager.getMeta(essenceId)
781
+ if (!meta || typeof meta.id !== 'string') {
782
+ return { ok: false, error: isTombstoned(essenceId) ? 'essence-deleted' : 'unknown-essence' }
783
+ }
784
+ if (meta.archived === true) return { ok: false, error: 'essence-archived' }
785
+ await essenceManager.ensureFresh(essenceId)
786
+ const fresh = essenceManager.getMeta(essenceId)
787
+ sourceEssence = { id: essenceId, name: meta.name, fingerprint: fresh?.lastMergedFingerprint ?? null }
788
+ }
789
+ const profileTemplate = sourceEssence
790
+ ? essenceManager.mergedProfileDir(sourceEssence.id)
791
+ : (template || templateDir())
792
+ const task = normalizeTask({
793
+ taskId,
794
+ title,
795
+ profile,
796
+ homeDir,
797
+ status: TASK_STATUS.PROVISIONING,
798
+ createdAt: nowMs(),
799
+ collaborators: Array.isArray(input.collaborators) ? input.collaborators : [],
800
+ template,
801
+ essenceId: sourceEssence?.id ?? null,
802
+ sourceEssence,
803
+ adapter: { ok: false, error: 'handshaking' },
804
+ })
805
+ tasks.set(taskId, task)
806
+ persist()
807
+
808
+ try {
809
+ const provisioned = await f.provision({
810
+ forkId: taskId,
811
+ taskTitle: title,
812
+ homeDir,
813
+ profile,
814
+ profileTemplate,
815
+ // 从原身派生:配置必须真的来自原身 merged(不能被 clean template 覆盖)。
816
+ // node_modules:优先元主自己的模块树;默认元宝/新建原身可能尚未物化 node_modules,
817
+ // 此时回退母体模块树,保证分身仍能解析 @twinsearth/fang-dsh-plugin(否则是僵尸分身)。
818
+ ...(sourceEssence ? {
819
+ cleanTemplate: false,
820
+ nodeModulesSource: (() => {
821
+ const essenceModules = path.join(profileTemplate, 'node_modules')
822
+ if (exists(essenceModules)) return essenceModules
823
+ return path.join(templateDir(), 'node_modules')
824
+ })(),
825
+ } : {}),
826
+ })
827
+ if (provisioned && provisioned.ok === false) {
828
+ task.status = TASK_STATUS.FAILED
829
+ persist()
830
+ return { ok: false, error: 'provision-failed', detail: provisioned.error }
831
+ }
832
+ audit(AUDIT.FORK_CREATED, { taskId, forkId: taskId, title, homeDir, profile })
833
+
834
+ // 防御:分身的 profile 必须能解析本 bundle;否则派生出来的是一个"没有 /fang 路由"的
835
+ // 僵尸分身(adapter 握手会得到 http-404)。发现即回收并如实报错。
836
+ // npm 安装名是 @twinsearth/fang-dsh-plugin;旧包名 @twinsearth/dsh-fang 与开发 link @twinsearth/fang-dsh-plugin 继续兼容。
837
+ const forkProfileDir = path.join(homeDir, 'profiles', profile)
838
+ const forkBundle = forkBundlePath(forkProfileDir)
839
+ if (!forkBundle) {
840
+ task.status = TASK_STATUS.FAILED
841
+ tasks.delete(taskId)
842
+ persist()
843
+ try { await f.reap(taskId) } catch { /* 回收失败不改变结论 */ }
844
+ audit(AUDIT.FORK_CRASHED, { taskId, error: 'fork-bundle-unavailable', forkProfileDir })
845
+ return { ok: false, error: 'fork-bundle-unavailable', detail: forkProfileDir }
846
+ }
847
+
848
+
849
+ // v2.1 lineage seed: a bounded handoff digest copied into the fork's own home.
850
+ // The fork reads it; the mother stays immutable (no session mount/copy).
851
+ const seed = normalizeSeed(input.seed ?? (input.seedSessionId ? { sessionId: input.seedSessionId } : null))
852
+ if (seed) {
853
+ try {
854
+ const seedsDir = path.join(homeDir, 'fang-seeds')
855
+ ensureDir(seedsDir)
856
+ const seedId = newId('seed')
857
+ const seedPath = path.join(seedsDir, seedId + '.md')
858
+ writeFileAtomic(seedPath, seed.markdown)
859
+ task.seed = {
860
+ seedId,
861
+ path: seedPath,
862
+ sessionId: seed.sessionId ?? '',
863
+ sha256: sha256(seed.markdown),
864
+ bytes: Buffer.byteLength(seed.markdown, 'utf8'),
865
+ }
866
+ audit(AUDIT.FORK_CREATED + '-seed', { taskId, seedId, sessionId: task.seed.sessionId, bytes: task.seed.bytes })
867
+ } catch (e) {
868
+ task.seed = null
869
+ audit(AUDIT.FORK_CRASHED + '-seed', { taskId, error: String(e?.message ?? e) })
870
+ }
871
+ }
872
+
873
+ if (input.importSession && typeof input.importSession === 'object') {
874
+ const importSpec = input.importSession
875
+ // 新入口:只给 sessionId(元主栏“派生”按钮)→ 由会话目录整目录复制进分身。
876
+ if (importSpec.sessionId && !importSpec.sourceFile) {
877
+ try {
878
+ const imported = importSessionsInto(homeDir, [importSpec.sessionId])
879
+ task.importedSession = imported[0] ? { ok: true, ...imported[0] } : { ok: false, reason: 'not-imported' }
880
+ audit(AUDIT.FORK_CREATED + '-import', { taskId, ...task.importedSession })
881
+ } catch (e) {
882
+ task.importedSession = { ok: false, reason: 'import-failed', error: String(e?.message ?? e) }
883
+ audit(AUDIT.FORK_CRASHED + '-import', { taskId, error: task.importedSession.error })
884
+ }
885
+ persist()
886
+ } else {
887
+ try {
888
+ task.importedSession = importSessionIntoFork(homeDir, importSpec)
889
+ audit(AUDIT.FORK_CREATED + '-import', { taskId, ...task.importedSession })
890
+ } catch (e) {
891
+ task.importedSession = { ok: false, reason: 'import-failed', error: String(e?.message ?? e) }
892
+ audit(AUDIT.FORK_CRASHED + '-import', { taskId, error: task.importedSession.error })
893
+ }
894
+ persist()
895
+ }
896
+ }
897
+
898
+ // v3.1.35:分身会话下限 1 —— 没有导入会话时补一个空会话,
899
+ // 让分身 Web 打开就有可用的会话(用户规格:默认不能为 0)。
900
+ try {
901
+ const floors = await ensureForkSessionFloor({ homeDir, task, cwd: input.cwd })
902
+ task.sessionFloor = floors
903
+ if (floors?.created) {
904
+ audit(AUDIT.FORK_CREATED + '-session-floor', { taskId, sessionId: floors.sessionId, workspaceKey: floors.workspaceKey })
905
+ }
906
+ } catch (e) {
907
+ task.sessionFloor = { ok: false, error: String(e?.message ?? e) }
908
+ audit(AUDIT.FORK_CREATED + '-session-floor-failed', { taskId, error: task.sessionFloor.error })
909
+ }
910
+
911
+ const started = await f.spawn({
912
+ forkId: taskId,
913
+ homeDir,
914
+ profile,
915
+ cliPath: cliPath(),
916
+ env: { DSH_FANG_ROLE: 'fork', DSH_FANG_TASK_ID: taskId, DSH_FANG_MOTHER: layout.dataDir },
917
+ })
918
+ if (!started || started.ok === false) {
919
+ task.status = TASK_STATUS.FAILED
920
+ persist()
921
+ audit(AUDIT.FORK_CRASHED, { taskId, error: started?.error ?? 'spawn-failed' })
922
+ bus.emit('fang/task-failed', { taskId, error: started?.error ?? 'spawn-failed' })
923
+ return { ok: false, error: 'spawn-failed', detail: started?.error ?? 'unknown' }
924
+ }
925
+ task.status = TASK_STATUS.RUNNING
926
+ task.pid = started.pid ?? null
927
+ task.port = started.port ?? null
928
+ task.url = started.url ?? null
929
+ task.token = started.token ?? extractTokenSafe(task.url)
930
+ persist()
931
+ audit(AUDIT.FORK_STARTED, { taskId, pid: task.pid, port: task.port, profile })
932
+ bus.emit('fang/task-created', { taskId, forkId: taskId, title, url: task.url, port: task.port })
933
+
934
+ const reachable = await probeWebUrl(task.url).catch(() => ({ ok: false }))
935
+ const adapter = await handshake(task)
936
+ task.status = adapter.ok ? TASK_STATUS.READY : TASK_STATUS.RUNNING
937
+ persist()
938
+ audit(AUDIT.TASK_READY, { taskId, adapter, reachable: !!reachable.ok })
939
+
940
+ // 商用钩子:联合开发(无 provider 时返回 not-configured,不阻塞)。
941
+ if (task.collaborators.length && commercialHooks.collaboration) {
942
+ await invokeCommercial({
943
+ commercialHooks, provider: safeGet(SERVICE.commercial), capability: 'collaboration',
944
+ method: 'publishChange', args: [{ taskId, pluginId: '', files: [] }],
945
+ })
946
+ }
947
+
948
+ return {
949
+ ok: true,
950
+ taskId,
951
+ forkId: taskId,
952
+ title,
953
+ url: stripUrlToken(task.url),
954
+ port: task.port,
955
+ homeDir,
956
+ profile,
957
+ adapter: { ok: adapter.ok, version: adapter.version ?? '', error: adapter.error ?? '' },
958
+ reachable: !!reachable.ok,
959
+ seed: task.seed,
960
+ }
961
+ } catch (e) {
962
+ task.status = TASK_STATUS.FAILED
963
+ persist()
964
+ audit(AUDIT.FORK_CRASHED, { taskId, error: String(e?.message ?? e) })
965
+ bus.emit('fang/task-failed', { taskId, error: String(e?.message ?? e) })
966
+ return { ok: false, error: 'create-task-threw', detail: String(e?.message ?? e) }
967
+ }
968
+ },
969
+
970
+ /** v1 兼容别名:fork 就是一个任务。 */
971
+ async fork(request = {}) {
972
+ return service.createTask({ title: request.taskTitle ?? request.title, template: request.basePlugin })
973
+ },
974
+
975
+ listTasks() {
976
+ return [...tasks.values()].map(taskView).sort((a, b) => a.createdAt - b.createdAt)
977
+ },
978
+
979
+ /** v3.0.6:任务重命名(archived 拒绝;1–80 字符)。 */
980
+ renameTask(taskId, title) {
981
+ const id = String(taskId ?? '').trim()
982
+ const task = tasks.get(id)
983
+ if (!task) return { ok: false, error: 'unknown-task' }
984
+ if (task.status === TASK_STATUS.ARCHIVED) return { ok: false, error: 'task-archived' }
985
+ const clean = String(title ?? '').trim()
986
+ if (clean.length === 0 || clean.length > 80) return { ok: false, error: 'invalid-title' }
987
+ task.title = clean
988
+ task.updatedAt = nowMs()
989
+ persist()
990
+ audit(AUDIT.FORK_CREATED + '-rename', { taskId: id, title: clean })
991
+ return { ok: true, task: taskView(task) }
992
+ },
993
+
994
+ /** v3.0.6:复制分身访问信息(不克隆实例;token 不返回,打开走 open-url)。 */
995
+ copyTaskInfo(taskId) {
996
+ const task = tasks.get(String(taskId ?? '').trim())
997
+ if (!task) return { ok: false, error: 'unknown-task' }
998
+ return { ok: true, taskId: task.taskId, title: task.title, url: stripUrlToken(task.url), port: task.port ?? null }
999
+ },
1000
+
1001
+ /** v3.0.6:导出分身元数据清单(完整项目/会话打包留给后续版本)。 */
1002
+ downloadTaskManifest(taskId) {
1003
+ const task = tasks.get(String(taskId ?? '').trim())
1004
+ if (!task) return { ok: false, error: 'unknown-task' }
1005
+ return {
1006
+ ok: true,
1007
+ manifest: {
1008
+ version: 1,
1009
+ taskId: task.taskId,
1010
+ title: task.title,
1011
+ createdAt: task.createdAt,
1012
+ status: taskView(task).phase,
1013
+ port: task.port ?? null,
1014
+ importedSession: task.importedSession ?? null,
1015
+ seed: task.seed ?? null,
1016
+ files: [],
1017
+ note: 'v3.0.6 metadata-only; full project/session archive is planned for v3.0.7',
1018
+ },
1019
+ }
1020
+ },
1021
+
1022
+ listForks() {
1023
+ return service.listTasks()
1024
+ },
1025
+
1026
+ /** 分身看护开关:持久化设置 + forker 运行时 supervise 状态。 */
1027
+ forkSupervision() {
1028
+ const persisted = readForkSupervisionSettings(layout.dataDir)
1029
+ const f = forker()
1030
+ const live = f && typeof f.getSupervision === 'function' ? f.getSupervision() : null
1031
+ return {
1032
+ ok: true,
1033
+ persisted: {
1034
+ autostartForks: persisted.autostartForks,
1035
+ supervise: persisted.supervise,
1036
+ updatedAt: persisted.updatedAt,
1037
+ exists: persisted.exists,
1038
+ },
1039
+ effective: {
1040
+ autostartForks: persisted.autostartForks,
1041
+ supervise: live ? live.enabled : persisted.supervise,
1042
+ },
1043
+ forker: live,
1044
+ }
1045
+ },
1046
+
1047
+ /** 更新看护开关;supervise 会立即应用到 forker,无需重启宿主。 */
1048
+ setForkSupervision(input = {}) {
1049
+ const patch = {}
1050
+ if (typeof input?.autostartForks === 'boolean') patch.autostartForks = input.autostartForks
1051
+ if (typeof input?.supervise === 'boolean') patch.supervise = input.supervise
1052
+ if (!Object.keys(patch).length) return { ok: false, error: 'no-settings' }
1053
+ const written = writeForkSupervisionSettings(layout.dataDir, patch)
1054
+ if (!written.ok) return written
1055
+ let applied = null
1056
+ if (patch.supervise !== undefined) {
1057
+ const f = forker()
1058
+ if (f && typeof f.setSupervise === 'function') applied = f.setSupervise(patch.supervise)
1059
+ }
1060
+ try { audit(AUDIT.FORK_SUPERVISION, { ...patch, applied: applied?.enabled ?? null }) } catch { /* audit optional */ }
1061
+ return { ...service.forkSupervision(), applied }
1062
+ },
1063
+
1064
+ async getTaskStatus(taskId) {
1065
+ const task = tasks.get(String(taskId ?? ''))
1066
+ if (!task) return { ok: false, error: 'unknown-task' }
1067
+ const view = taskView(task)
1068
+ const client = task.adapter?.ok ? await remoteFor(task) : null
1069
+ let adapterStatus = null
1070
+ if (client) {
1071
+ const res = await client.status()
1072
+ adapterStatus = res.ok ? res.data : { ok: false, error: res.error ?? res.kind }
1073
+ }
1074
+ return {
1075
+ ok: true,
1076
+ taskId: task.taskId,
1077
+ status: view.status,
1078
+ title: view.title,
1079
+ fork: {
1080
+ pid: view.pid, port: view.port, url: view.url, homeDir: view.homeDir,
1081
+ profile: view.profile, createdAt: view.createdAt,
1082
+ },
1083
+ adapter: view.adapter,
1084
+ adapterStatus,
1085
+ }
1086
+ },
1087
+
1088
+ async getForkHealth(forkId) {
1089
+ const status = await service.getTaskStatus(forkId)
1090
+ if (status.ok === false) return status
1091
+ const probe = status.fork.url
1092
+ ? await probeWebUrl(status.fork.url).catch(() => ({ ok: false }))
1093
+ : { ok: false, reason: 'no-url' }
1094
+ return {
1095
+ ok: probe.ok,
1096
+ forkId,
1097
+ taskId: forkId,
1098
+ status: status.status,
1099
+ pid: status.fork.pid,
1100
+ port: status.fork.port,
1101
+ url: status.fork.url,
1102
+ probe,
1103
+ adapter: status.adapter,
1104
+ }
1105
+ },
1106
+
1107
+ /**
1108
+ * v2 提升:从分身取回插件源码 → 母体阈值复核 → Router 初判 → 审批 → A/B → staging。
1109
+ * 与 v1 的 promote() 区别:走 Adapter 跨进程取源码与分身评分,不再假设母体能读分身磁盘。
1110
+ */
1111
+ async promoteTask(taskId, pluginId) {
1112
+ const id = String(taskId ?? '')
1113
+ const plugin = String(pluginId ?? '').trim()
1114
+ if (!id) return { ok: false, error: 'task-id-required' }
1115
+ if (!plugin) return { ok: false, error: 'plugin-id-required' }
1116
+ const task = tasks.get(id)
1117
+ if (!task) return { ok: false, error: 'unknown-task' }
1118
+ if (!task.adapter?.ok) return { ok: false, error: 'adapter-unavailable', detail: task.adapter?.error ?? '' }
1119
+
1120
+ const client = await remoteFor(task)
1121
+ if (!client) return { ok: false, error: 'remote-unavailable' }
1122
+
1123
+ const read = await client.command(ADAPTER_OP.READ_PLUGIN, { pluginId: plugin })
1124
+ if (!read.ok || read.data?.ok === false) {
1125
+ return { ok: false, error: 'read-plugin-failed', detail: read.data?.error ?? read.error ?? '' }
1126
+ }
1127
+ const files = read.data?.result?.files ?? {}
1128
+ if (!Object.keys(files).length) return { ok: false, error: 'plugin-not-found', detail: plugin }
1129
+
1130
+ const forkScore = await client.command(ADAPTER_OP.SCORE_PLUGIN, { pluginId: plugin })
1131
+ const forkAssessment = forkScore.ok && forkScore.data?.ok ? forkScore.data.result : null
1132
+
1133
+ // Materialize the fetched source into a mother-side work dir, then re-score with
1134
+ // the MOTHER threshold policy (母体更严格,且这是真正写进生产的判定).
1135
+ const workDir = path.join(layout.dataDir, 'work', `${id}-${plugin}`)
1136
+ const discardWorkDir = () => { try { removeTree(workDir) } catch { /* best effort */ } }
1137
+ try {
1138
+ removeTree(workDir)
1139
+ ensureDir(workDir)
1140
+ for (const [rel, content] of Object.entries(files)) {
1141
+ const target = resolveInside(workDir, rel)
1142
+ if (!target) { discardWorkDir(); return { ok: false, error: 'unsafe-plugin-path', detail: rel } }
1143
+ writeFileAtomic(target, String(content))
1144
+ }
1145
+ } catch (e) {
1146
+ discardWorkDir()
1147
+ return { ok: false, error: 'materialize-failed', detail: String(e?.message ?? e) }
1148
+ }
1149
+
1150
+ const scorer = safeGet(SERVICE.scorer)
1151
+ const motherAssessment = scorer?.scoreDir ? scorer.scoreDir(workDir, null, { environment: 'mother', deep: true }) : null
1152
+ if (!motherAssessment) { discardWorkDir(); return { ok: false, error: 'scorer-unavailable' } }
1153
+ if (motherAssessment.grade === 'D' || motherAssessment.grade === 'E') {
1154
+ discardWorkDir()
1155
+ return { ok: false, error: 'grade-too-low', grade: motherAssessment.grade, score: motherAssessment.S }
1156
+ }
1157
+
1158
+ const router = safeGet(SERVICE.router)
1159
+ const route = router?.decide
1160
+ ? router.decide({
1161
+ plugin: { name: plugin, source: 'local-author' },
1162
+ assessment: motherAssessment,
1163
+ source: 'mother',
1164
+ })
1165
+ : null
1166
+ if (route && route.route !== 'sandbox') {
1167
+ discardWorkDir()
1168
+ return { ok: false, error: 'route-refused', route: route.route, reason: route.reason }
1169
+ }
1170
+
1171
+ const approver = safeGet(SERVICE.approver)
1172
+ if (!approver) { discardWorkDir(); return { ok: false, error: 'approver-unavailable' } }
1173
+
1174
+ // 团队权限钩子(无 provider 时放行并记录 basis)。
1175
+ let teamBasis = { commercial: false }
1176
+ if (commercialHooks.teams) {
1177
+ const check = await invokeCommercial({
1178
+ commercialHooks, provider: safeGet(SERVICE.commercial), capability: 'teams',
1179
+ method: 'check', args: [{ action: 'promote', group: plugin }],
1180
+ })
1181
+ teamBasis = { commercial: true, ...(check.ok ? check.value : { allowed: true, error: check.error }) }
1182
+ if (teamBasis.allowed === false) { discardWorkDir(); return { ok: false, error: 'team-denied', basis: teamBasis } }
1183
+ }
1184
+
1185
+ const decision = typeof approver.requestPromotion === 'function'
1186
+ ? await approver.requestPromotion({
1187
+ pluginId: plugin,
1188
+ taskId: id,
1189
+ detail: `来源任务 ${task.title}`,
1190
+ impact: { taskId: id, pluginId: plugin, forkGrade: forkAssessment?.grade ?? null, motherGrade: motherAssessment.grade },
1191
+ })
1192
+ : await approver.request({
1193
+ kind: 'promotion',
1194
+ risk: 'red',
1195
+ title: `提升插件 ${plugin} → 母体`,
1196
+ detail: `来源任务 ${id}(${task.title})`,
1197
+ impact: { taskId: id, pluginId: plugin, grade: motherAssessment.grade },
1198
+ })
1199
+ const outcome = decision?.outcome ?? 'timeout'
1200
+ audit(AUDIT.APPROVAL_DECIDED, { kind: 'promotion', taskId: id, pluginId: plugin, outcome })
1201
+ if (outcome !== 'approved') { discardWorkDir(); return { ok: false, error: 'denied', outcome } }
1202
+
1203
+ const target = path.join(layout.dataDir, 'promoted', plugin)
1204
+ try {
1205
+ if (exists(target)) {
1206
+ const snap = await approver.snapshot?.({ tag: plugin, dir: target })
1207
+ if (!snap || snap.ok === false) {
1208
+ discardWorkDir()
1209
+ return { ok: false, error: 'snapshot-failed', detail: String(snap?.error ?? snap?.reason ?? 'snapshot unavailable') }
1210
+ }
1211
+ }
1212
+ const commit = atomicReplaceDir(workDir, target)
1213
+ discardWorkDir()
1214
+ audit(AUDIT.PROMOTE_COMMITTED, {
1215
+ taskId: id, pluginId: plugin, target, digest: commit.digest,
1216
+ forkGrade: forkAssessment?.grade ?? null, motherGrade: motherAssessment.grade,
1217
+ })
1218
+ } catch (e) {
1219
+ discardWorkDir()
1220
+ audit(AUDIT.PROMOTE_FAILED, { taskId: id, pluginId: plugin, error: String(e?.message ?? e) })
1221
+ return { ok: false, error: 'promote-failed', detail: String(e?.message ?? e) }
1222
+ }
1223
+
1224
+ // 商用钩子:云备份(不阻塞、失败只记录)。
1225
+ let backup = { ok: false, error: 'not-configured' }
1226
+ if (commercialHooks.backup) {
1227
+ backup = await invokeCommercial({
1228
+ commercialHooks, provider: safeGet(SERVICE.commercial), capability: 'backup',
1229
+ method: 'upload', args: [{ kind: 'plugin', id: plugin, key: plugin, body: files }],
1230
+ })
1231
+ }
1232
+
1233
+ return {
1234
+ ok: true,
1235
+ outcome: 'approved',
1236
+ taskId: id,
1237
+ pluginId: plugin,
1238
+ target,
1239
+ forkAssessment,
1240
+ motherAssessment,
1241
+ route: route?.route ?? null,
1242
+ teamBasis,
1243
+ backup,
1244
+ note: '已写入母体 staging;重启母体后生效(重启需 restart-guard 审批)',
1245
+ }
1246
+ },
1247
+
1248
+ /** v1 兼容提升:本地路径直接读分身 buffer(同机、旧流程)。 */
1249
+ async promote(forkId, pluginId) {
1250
+ const info = tasks.get(String(forkId ?? ''))
1251
+ if (!info) return { ok: false, error: 'unknown-fork' }
1252
+ if (!pluginId) return { ok: false, error: 'plugin-id-required' }
1253
+ const src = path.join(forkLayout(info).bufferDir, String(pluginId))
1254
+ if (!exists(src)) return { ok: false, error: 'not-in-fork-buffer', detail: src }
1255
+
1256
+ const scorer = safeGet(SERVICE.scorer)
1257
+ const assessment = scorer?.scoreDir ? scorer.scoreDir(src, null, { environment: 'mother' }) : null
1258
+ if (assessment && (assessment.grade === 'D' || assessment.grade === 'E')) {
1259
+ return { ok: false, error: 'grade-too-low', grade: assessment.grade, score: assessment.S }
1260
+ }
1261
+ const approver = safeGet(SERVICE.approver)
1262
+ if (!approver) return { ok: false, error: 'approver-unavailable' }
1263
+ const decision = await approver.request({
1264
+ kind: 'promotion',
1265
+ risk: 'red',
1266
+ title: `提升插件 ${pluginId} → 母体`,
1267
+ detail: `来源分身 ${forkId}(${info.title})`,
1268
+ impact: { forkId, pluginId, grade: assessment?.grade ?? 'unknown' },
1269
+ })
1270
+ if ((decision?.outcome ?? 'timeout') !== 'approved') {
1271
+ return { ok: false, error: 'denied', outcome: decision?.outcome ?? 'timeout' }
1272
+ }
1273
+ const target = path.join(layout.dataDir, 'promoted', String(pluginId))
1274
+ try {
1275
+ if (exists(target)) {
1276
+ const snap = await approver.snapshot?.({ tag: String(pluginId), dir: target })
1277
+ if (!snap || snap.ok === false) {
1278
+ return { ok: false, error: 'snapshot-failed', detail: String(snap?.error ?? snap?.reason ?? 'snapshot unavailable') }
1279
+ }
1280
+ }
1281
+ const commit = atomicReplaceDir(src, target)
1282
+ audit(AUDIT.PROMOTE_COMMITTED, { forkId, pluginId, target, digest: commit.digest })
1283
+ } catch (e) {
1284
+ return { ok: false, error: 'promote-failed', detail: String(e?.message ?? e) }
1285
+ }
1286
+ return { ok: true, outcome: 'approved', target, pluginId, assessment }
1287
+ },
1288
+
1289
+ /**
1290
+ * v2.1 门户入口:派生一个任务,可选地用母体只读会话做 lineage 种子。
1291
+ * 只读会话目录(session catalog)缺失时退化为 lineage-only,不阻塞派生。
1292
+ */
1293
+ async derive(input = {}) {
1294
+ // v3.1.30:派生分身必须导入「一个」进行中会话。
1295
+ // · 不传 sessionId → 明确拒绝(不再静默退化成空白分身)
1296
+ // · 传了多个 sessionIds → 拒绝(一次只能导入一个,避免来源混淆)
1297
+ // 元主 Web 自身启动不经过这里,因此「元主无对话启动」不受影响。
1298
+ const rawMulti = Array.isArray(input.sessionIds)
1299
+ ? input.sessionIds.map((id) => String(id ?? '').trim()).filter(Boolean)
1300
+ : []
1301
+ if (rawMulti.length > 1) {
1302
+ return { ok: false, error: 'session-multi-not-allowed', detail: '一次只能导入一个会话(收到 ' + rawMulti.length + ' 个)' }
1303
+ }
1304
+ const sessionId = (rawMulti[0] ?? String(input.sessionId ?? '')).trim()
1305
+ if (!sessionId) {
1306
+ return { ok: false, error: 'session-required', detail: '派生分身需要先选择一个进行中的会话(一次只能导入一个)' }
1307
+ }
1308
+ let seed = null
1309
+ let importSession = null
1310
+ {
1311
+ const catalog = safeGet(SERVICE.sessionCatalog)
1312
+ if (!catalog || typeof catalog.seed !== 'function') {
1313
+ return { ok: false, error: 'session-catalog-unavailable' }
1314
+ }
1315
+ const seeded = await catalog.seed(sessionId)
1316
+ if (!seeded || seeded.ok === false) {
1317
+ const code = seeded?.code ?? seeded?.error ?? 'unknown'
1318
+ return { ok: false, error: code === 'session-empty' ? 'session-empty' : 'session-seed-failed', detail: code }
1319
+ }
1320
+ seed = { sessionId, markdown: seeded.markdown }
1321
+ importSession = {
1322
+ sessionId,
1323
+ sourceFile: seeded.sourceFile,
1324
+ workspacePath: seeded.workspacePath,
1325
+ workspaceTitle: seeded.workspaceTitle,
1326
+ workspaceId: seeded.workspaceId,
1327
+ createdAt: seeded.createdAt,
1328
+ cwd: seeded.cwd,
1329
+ agentPreset: seeded.agentPreset,
1330
+ sourceBytes: seeded.sourceBytes,
1331
+ }
1332
+ }
1333
+ const title = String(input.title ?? '').trim()
1334
+ || (seed ? `从会话继续 · ${seed.sessionId.slice(-8)}` : `派生任务 ${tasks.size + 1}`)
1335
+ const out = await service.createTask({ title, seed, importSession, collaborators: input.collaborators })
1336
+ return { ...out, derivedFrom: seed ? { sessionId: seed.sessionId } : null, importedSession: out.importedSession ?? null }
1337
+ },
1338
+
1339
+ /**
1340
+ * 分身(任务)内部的会话目录:只读该分身自己的 $DSH_HOME/sessions。
1341
+ * 用于控制面板右栏「分身项目 & 会话」;有界解帧取会话标题,未知/超限如实降级。
1342
+ */
1343
+ async listForkSessions(taskId, options = {}) {
1344
+ const task = tasks.get(String(taskId ?? ''))
1345
+ if (!task) return { ok: false, error: 'unknown-task' }
1346
+ const limit = Math.min(50, Math.max(1, Number(options.limit) || 20))
1347
+ const sessionsRoot = path.join(task.homeDir, "sessions")
1348
+ const storagesRoot = path.join(task.homeDir, "storages")
1349
+ if (!exists(sessionsRoot)) return { ok: true, taskId: task.taskId, sessions: [], degraded: ['sessions-root-missing'] }
1350
+ let index = { workspaces: [], global: { archivedSessionIds: [], pinnedSessionIds: [] }, degraded: [] }
1351
+ try {
1352
+ index = parseWorkspaceIndex(fs.readFileSync(path.join(storagesRoot, "workspace.json"), "utf8"))
1353
+ } catch (e) {
1354
+ index = { workspaces: [], global: { archivedSessionIds: [], pinnedSessionIds: [] }, degraded: [e?.code === 'ENOENT' ? 'index-not-found' : 'index-read-failed'] }
1355
+ }
1356
+ const fsApi = { statSync: fs.statSync, readdirSync: fs.readdirSync, openSync: fs.openSync, readSync: fs.readSync, closeSync: fs.closeSync }
1357
+ let dirs = []
1358
+ try {
1359
+ dirs = fs.readdirSync(sessionsRoot, { withFileTypes: true })
1360
+ .filter((entry) => entry && typeof entry.isDirectory === "function" && entry.isDirectory())
1361
+ .map((entry) => entry.name)
1362
+ } catch { /* 空目录/权限 */ }
1363
+ const rows = scanSessionDirs({
1364
+ sessionsDir: sessionsRoot,
1365
+ workspaceIds: dirs,
1366
+ archivedSessionIds: index.global.archivedSessionIds,
1367
+ pinnedSessionIds: index.global.pinnedSessionIds,
1368
+ fsApi,
1369
+ }).slice(0, limit)
1370
+ const keyMap = new Map()
1371
+ for (const ws of index.workspaces) keyMap.set(encodeWorkspaceKey(ws.path), ws)
1372
+ const sessions = rows.map((row) => {
1373
+ const workspace = keyMap.get(row.workspaceId) ?? null
1374
+ let title = null; let titleSource = null; let preview = null
1375
+ const file = path.join(sessionsRoot, row.workspaceId, row.sessionId, "session.v4.jsonl.zstd")
1376
+ try {
1377
+ if (exists(file)) {
1378
+ const decoded = readBoundedEvents({ file, maxFrames: 32, maxCompressedBytes: 1024 * 1024, fsApi, zstdDecompressSync: zlib.zstdDecompressSync })
1379
+ const meta = extractSessionTitle(decoded.events)
1380
+ title = meta.title; titleSource = meta.titleSource; preview = meta.preview
1381
+ }
1382
+ } catch { /* 标题失败不阻塞列出会话 */ }
1383
+ return {
1384
+ sessionId: row.sessionId,
1385
+ title: title ?? (workspace ? workspace.title : "未命名会话"),
1386
+ titleSource: titleSource ?? 'workspace',
1387
+ preview,
1388
+ updatedAt: row.updatedAt,
1389
+ workspaceTitle: workspace ? workspace.title : row.workspaceId,
1390
+ archived: row.archived,
1391
+ pinned: row.pinned,
1392
+ }
1393
+ })
1394
+ return { ok: true, taskId: task.taskId, sessions, degraded: index.degraded ?? [] }
1395
+ },
1396
+ /** v2 归档:先让分身 flush + 落审计,再**停止进程但保留 home**(读档/还原/删除见 restoreTask/deleteArchived)。 */
1397
+ async archiveTask(taskId) {
1398
+ const id = String(taskId ?? '')
1399
+ const task = tasks.get(id)
1400
+ if (!task) return { ok: false, error: 'unknown-task' }
1401
+ if (task.status === TASK_STATUS.ARCHIVED) {
1402
+ return { ok: true, archived: true, already: true, taskId: id, title: task.title, homeKept: true }
1403
+ }
1404
+ const summary = taskView(task)
1405
+ let remote = { ok: false, error: 'adapter-unavailable' }
1406
+ if (task.adapter?.ok) {
1407
+ const client = await remoteFor(task)
1408
+ if (client) {
1409
+ const res = await client.command(ADAPTER_OP.ARCHIVE, {})
1410
+ remote = { ok: !!(res.ok && res.data?.ok !== false), error: res.data?.error ?? res.error }
1411
+ }
1412
+ }
1413
+
1414
+ // 归档 = 停止进程 + 保留 home;必须确认进程真的退了,否则把失败如实返回,
1415
+ // 不能把「列表里没了、进程还在」当成归档成功。
1416
+ const f = forker()
1417
+ if (!f || typeof f.stop !== 'function') return { ok: false, error: 'forker-unavailable', taskId: id, remote }
1418
+ try {
1419
+ const stopped = await f.stop(id)
1420
+ if (stopped && stopped.ok === false && stopped.error !== 'not-found') {
1421
+ return { ok: false, error: 'stop-failed', detail: stopped.error ?? 'stop-failed', taskId: id, remote }
1422
+ }
1423
+ } catch (e) {
1424
+ return { ok: false, error: 'stop-failed', detail: String(e?.message ?? e), taskId: id, remote }
1425
+ }
1426
+ const deadline = nowMs() + cfg.archiveStopTimeoutMs
1427
+ let alive = true
1428
+ while (nowMs() < deadline) {
1429
+ alive = typeof f.alive === 'function' ? !!f.alive(id) : !!(f.status?.(id)?.running)
1430
+ if (!alive) break
1431
+ await sleep(100)
1432
+ }
1433
+ if (alive) {
1434
+ audit(AUDIT.TASK_ARCHIVED, { taskId: id, title: task.title, ok: false, error: 'stop-failed', remote })
1435
+ return { ok: false, error: 'stop-failed', detail: 'fork process is still alive after stop', taskId: id, remote, alive: true }
1436
+ }
1437
+
1438
+ task.status = TASK_STATUS.ARCHIVED
1439
+ task.archivedAt = nowMs()
1440
+ task.archiveRemote = remote
1441
+ task.pid = null
1442
+ task.port = null
1443
+ task.url = null
1444
+ task.token = ''
1445
+ task.adapter = { ok: false, error: 'archived', at: nowMs() }
1446
+ persist()
1447
+ remoteCache.delete(id)
1448
+ audit(AUDIT.TASK_ARCHIVED, { taskId: id, title: task.title, remote, stopped: true, homeKept: true })
1449
+ bus.emit('fang/task-archived', { taskId: id, title: task.title })
1450
+ return {
1451
+ ok: true,
1452
+ archived: true,
1453
+ taskId: id,
1454
+ title: task.title,
1455
+ summary,
1456
+ remote,
1457
+ stopped: true,
1458
+ homeKept: true,
1459
+ }
1460
+ },
1461
+
1462
+ /** 还原存档:用原 home/profile 重新 spawn(新端口 + 新 token),再握手。 */
1463
+ /** 分身配置重置:把来源原身的 merged/profile 重新覆盖到分身 profile(保留 node_modules 与会话)。 */
1464
+ async resetForkConfig(taskId) {
1465
+ const id = String(taskId ?? '')
1466
+ const task = tasks.get(id)
1467
+ if (!task) return { ok: false, error: 'unknown-task' }
1468
+ if (!task.essenceId) return { ok: false, error: 'no-source-essence' }
1469
+ const meta = essenceManager.getMeta(task.essenceId)
1470
+ if (!meta || typeof meta.id !== 'string') return { ok: false, error: 'source-essence-missing' }
1471
+ if (meta.archived === true) return { ok: false, error: 'source-essence-archived' }
1472
+ await essenceManager.ensureFresh(task.essenceId)
1473
+ const source = essenceManager.mergedProfileDir(task.essenceId)
1474
+ const target = path.join(task.homeDir, 'profiles', task.profile)
1475
+ if (!exists(target)) return { ok: false, error: 'fork-profile-missing' }
1476
+ setProfileConfigReadonly(target, false)
1477
+ const picked = []
1478
+ for (const rel of ['package.json', 'cordis.patch.yml', 'cordis.yml']) {
1479
+ const from = path.join(source, rel)
1480
+ if (!exists(from)) continue
1481
+ fs.copyFileSync(from, path.join(target, rel))
1482
+ picked.push(rel)
1483
+ }
1484
+ const settings = path.join(source, 'settings')
1485
+ if (exists(settings)) {
1486
+ const to = path.join(target, 'settings')
1487
+ if (exists(to)) removeTree(to)
1488
+ copyTree(settings, to)
1489
+ picked.push('settings/')
1490
+ }
1491
+ // 重置后重新加回“生产盘”锁(插件安装/设置入口仍禁用),并把配置设回只读。
1492
+ const configLock = applyForkConfigLock(target)
1493
+ const profileReadonly = setProfileConfigReadonly(target, true)
1494
+ task.configSyncedAt = nowMs()
1495
+ task.configSyncedFrom = task.essenceId
1496
+ persist()
1497
+ audit('fork-config-reset', { taskId: id, essenceId: task.essenceId, files: picked, configLock, profileReadonly })
1498
+ return { ok: true, taskId: id, essenceId: task.essenceId, files: picked, configLock, profileReadonly }
1499
+ },
1500
+
1501
+ /**
1502
+ * v3.1.35:分身会话护栏(原「强制单会话」)。运行中拒绝;
1503
+ * 停止/归档态把超过 FORK_SESSION_MAX(3) 的会话移到 quarantine-sessions/(不删除)。
1504
+ * 方法名保留 enforceSingleSession 以免破坏既有调用方与存档。
1505
+ */
1506
+ enforceSingleSession(taskId) {
1507
+ const id = String(taskId ?? '')
1508
+ const task = tasks.get(id)
1509
+ if (!task) return { ok: false, error: 'unknown-task' }
1510
+ const live = forker()?.status?.(id)
1511
+ if (live?.running) return { ok: false, error: 'task-running', taskId: id }
1512
+ return { ...applyForkSessionGuard(task), taskId: id }
1513
+ },
1514
+
1515
+ async restoreTask(taskId) {
1516
+ const id = String(taskId ?? '')
1517
+ const task = tasks.get(id)
1518
+ if (!task) return { ok: false, error: 'unknown-task' }
1519
+ if (task.status !== TASK_STATUS.ARCHIVED) return { ok: false, error: 'not-archived', taskId: id }
1520
+ if (!task.homeDir || !exists(task.homeDir)) {
1521
+ return { ok: false, error: 'archive-home-missing', taskId: id, homeDir: task.homeDir ?? '' }
1522
+ }
1523
+ try { assertInside(layout.forkHomesDir, task.homeDir, 'fork-home') } catch (error) {
1524
+ return { ok: false, error: 'path-escape', taskId: id, detail: String(error?.message ?? error) }
1525
+ }
1526
+ const forkProfileDir = path.join(task.homeDir, 'profiles', task.profile)
1527
+ const forkBundle = forkBundlePath(forkProfileDir)
1528
+ if (!forkBundle) {
1529
+ return { ok: false, error: 'fork-bundle-unavailable', taskId: id, detail: forkProfileDir }
1530
+ }
1531
+ const f = forker()
1532
+ if (!f || typeof f.spawn !== 'function') return { ok: false, error: 'forker-unavailable', taskId: id }
1533
+ // 从原身派生的分身:还原前先把配置重置回来源原身(分身只生产,不保留配置改动)。
1534
+ let configReset = null
1535
+ if (task.essenceId) {
1536
+ try { configReset = await service.resetForkConfig(id) } catch (error) {
1537
+ configReset = { ok: false, error: String(error?.message ?? error) }
1538
+ }
1539
+ }
1540
+ // 还原前强制单会话(多余会话移到 quarantine,不删除),再做配置重置后的启动。
1541
+ let sessionGuard = null
1542
+ try { sessionGuard = applyForkSessionGuard(task) } catch { sessionGuard = { ok: false } }
1543
+ const started = await f.spawn({
1544
+ forkId: id,
1545
+ homeDir: task.homeDir,
1546
+ profile: task.profile,
1547
+ cliPath: cliPath(),
1548
+ env: { DSH_FANG_ROLE: 'fork', DSH_FANG_TASK_ID: id, DSH_FANG_MOTHER: layout.dataDir },
1549
+ })
1550
+ if (!started || started.ok === false) {
1551
+ audit(AUDIT.TASK_RESTORED, { taskId: id, ok: false, error: started?.error ?? 'spawn-failed' })
1552
+ return { ok: false, error: 'spawn-failed', detail: started?.error ?? 'unknown', taskId: id }
1553
+ }
1554
+ task.status = TASK_STATUS.RUNNING
1555
+ task.archivedAt = null
1556
+ task.archiveRemote = null
1557
+ task.pid = started.pid ?? null
1558
+ task.port = started.port ?? null
1559
+ task.url = started.url ?? null
1560
+ task.token = started.token ?? extractTokenSafe(task.url)
1561
+ task.adapter = { ok: false, error: 'handshaking' }
1562
+ persist()
1563
+ audit(AUDIT.FORK_STARTED, { taskId: id, pid: task.pid, port: task.port, profile: task.profile, restored: true })
1564
+ const adapter = await handshake(task)
1565
+ task.status = adapter.ok ? TASK_STATUS.READY : TASK_STATUS.RUNNING
1566
+ persist()
1567
+ audit(AUDIT.TASK_RESTORED, { taskId: id, ok: true, adapter })
1568
+ bus.emit('fang/task-restored', { taskId: id, url: task.url, port: task.port })
1569
+ return { ok: true, restored: true, taskId: id, title: task.title, url: task.url, port: task.port, adapter, configReset, sessionGuard }
1570
+ },
1571
+
1572
+ /**
1573
+ * watchdog 启动时的恢复入口:把「已登记但进程没在跑」的非归档任务重新 spawn。
1574
+ * 不创建新任务、不动归档任务;失败的任务不重试(避免崩溃循环),只如实报告。
1575
+ * @param {{taskIds?: string[]}} [input]
1576
+ */
1577
+ async autostartForks(input = {}) {
1578
+ const f = forker()
1579
+ if (!f || typeof f.spawn !== 'function') {
1580
+ return { ok: false, error: 'forker-unavailable', started: [], failed: [], skipped: [] }
1581
+ }
1582
+ const wanted = Array.isArray(input?.taskIds) && input.taskIds.length
1583
+ ? new Set(input.taskIds.map(String))
1584
+ : null
1585
+ const report = { ok: true, started: [], failed: [], skipped: [] }
1586
+ for (const task of tasks.values()) {
1587
+ const id = task.taskId
1588
+ if (wanted && !wanted.has(id)) continue
1589
+ if (task.status === TASK_STATUS.ARCHIVED) {
1590
+ report.skipped.push({ taskId: id, reason: 'archived' })
1591
+ continue
1592
+ }
1593
+ let live = null
1594
+ try { live = f.status?.(id) ?? null } catch { live = null }
1595
+ if (live?.running === true || live?.state === 'running') {
1596
+ report.skipped.push({ taskId: id, reason: 'already-running' })
1597
+ continue
1598
+ }
1599
+ if (!task.homeDir || !exists(task.homeDir) || !task.profile) {
1600
+ report.failed.push({ taskId: id, error: 'home-missing' })
1601
+ continue
1602
+ }
1603
+ try { assertInside(layout.forkHomesDir, task.homeDir, 'fork-home') } catch {
1604
+ report.failed.push({ taskId: id, error: 'path-escape' })
1605
+ continue
1606
+ }
1607
+ const forkBundle = forkBundlePath(path.join(task.homeDir, 'profiles', task.profile))
1608
+ if (!forkBundle) {
1609
+ report.failed.push({ taskId: id, error: 'fork-bundle-unavailable' })
1610
+ continue
1611
+ }
1612
+ task.status = TASK_STATUS.PROVISIONING
1613
+ persist()
1614
+ let started
1615
+ try {
1616
+ started = await f.spawn({
1617
+ forkId: id,
1618
+ homeDir: task.homeDir,
1619
+ profile: task.profile,
1620
+ cliPath: cliPath(),
1621
+ env: { DSH_FANG_ROLE: 'fork', DSH_FANG_TASK_ID: id, DSH_FANG_MOTHER: layout.dataDir },
1622
+ })
1623
+ } catch (error) {
1624
+ started = { ok: false, error: String(error?.message ?? error) }
1625
+ }
1626
+ if (!started || started.ok === false) {
1627
+ task.status = TASK_STATUS.FAILED
1628
+ persist()
1629
+ audit(AUDIT.FORK_CRASHED, { taskId: id, error: started?.error ?? 'spawn-failed', reason: 'autostart' })
1630
+ report.failed.push({ taskId: id, error: started?.error ?? 'spawn-failed' })
1631
+ continue
1632
+ }
1633
+ task.status = TASK_STATUS.RUNNING
1634
+ task.pid = started.pid ?? null
1635
+ task.port = started.port ?? null
1636
+ task.url = started.url ?? null
1637
+ task.token = started.token ?? extractTokenSafe(task.url)
1638
+ task.adapter = { ok: false, error: 'handshaking' }
1639
+ persist()
1640
+ audit(AUDIT.FORK_STARTED, { taskId: id, pid: task.pid, port: task.port, profile: task.profile, reason: 'autostart' })
1641
+ bus.emit('fang/fork-started', { taskId: id, forkId: id, pid: task.pid, port: task.port })
1642
+ report.started.push({ taskId: id, pid: task.pid, port: task.port })
1643
+ // 握手放到后台:watchdog 的启动请求不应该被 N 个分身的启动时间串行阻塞。
1644
+ void handshake(task).then((adapter) => {
1645
+ if (!adapter?.ok) return
1646
+ const current = tasks.get(id)
1647
+ if (!current || current.pid !== task.pid) return
1648
+ current.status = TASK_STATUS.READY
1649
+ current.adapter = adapter
1650
+ persist()
1651
+ }).catch(() => {})
1652
+ }
1653
+ return report
1654
+ },
1655
+
1656
+ /** 永久删除存档:reap(杀进程 + 删 home + 删记录);forker 记录已丢时按 home 兜底清理。 */
1657
+ async deleteArchivedTask(taskId) {
1658
+ const id = String(taskId ?? '')
1659
+ const task = tasks.get(id)
1660
+ if (!task) return { ok: false, error: 'unknown-task' }
1661
+ if (task.status !== TASK_STATUS.ARCHIVED) return { ok: false, error: 'not-archived', taskId: id }
1662
+ const f = forker()
1663
+ let reaped = { ok: true }
1664
+ try {
1665
+ if (f && typeof f.reap === 'function') reaped = await f.reap(id)
1666
+ } catch (e) {
1667
+ reaped = { ok: false, error: String(e?.message ?? e) }
1668
+ }
1669
+ if (reaped && reaped.ok === false && reaped.error !== 'not-found') {
1670
+ return { ok: false, error: 'reap-failed', detail: reaped.error, taskId: id }
1671
+ }
1672
+ // forker 记录不存在(宿主重启/旧归档)时,仍清理 task.homeDir(限定在 forks 根内)。
1673
+ if (reaped?.error === 'not-found' && task.homeDir) {
1674
+ const root = path.resolve(layout.forkHomesDir) + path.sep
1675
+ const target = path.resolve(task.homeDir)
1676
+ if (target.startsWith(root) && exists(target)) {
1677
+ try { removeTree(target) } catch (e) { return { ok: false, error: 'remove-failed', detail: String(e?.message ?? e), taskId: id } }
1678
+ }
1679
+ }
1680
+ tasks.delete(id)
1681
+ remoteCache.delete(id)
1682
+ persist()
1683
+ audit(AUDIT.FORK_REAPED, { taskId: id, title: task.title, archivedDelete: true })
1684
+ bus.emit('fang/fork-reaped', { forkId: id })
1685
+ return { ok: true, deleted: true, taskId: id, title: task.title }
1686
+ },
1687
+
1688
+ async reap(taskId) {
1689
+ const task = tasks.get(String(taskId ?? ''))
1690
+ if (!task) return { ok: false, error: 'unknown-fork' }
1691
+ const f = forker()
1692
+ let reaped = { ok: true }
1693
+ try {
1694
+ reaped = f && typeof f.reap === 'function' ? await f.reap(task.taskId) : { ok: true }
1695
+ } catch (e) {
1696
+ reaped = { ok: false, error: String(e?.message ?? e) }
1697
+ }
1698
+ if (reaped && reaped.ok === false) {
1699
+ task.status = TASK_STATUS.FAILED
1700
+ persist()
1701
+ return { ok: false, error: 'reap-failed', detail: reaped.error }
1702
+ }
1703
+ tasks.delete(task.taskId)
1704
+ remoteCache.delete(task.taskId)
1705
+ persist()
1706
+ audit(AUDIT.FORK_REAPED, { taskId: task.taskId })
1707
+ bus.emit('fang/fork-reaped', { forkId: task.taskId })
1708
+ return { ok: true, forkId: task.taskId, removed: reaped?.removed ?? true }
1709
+ },
1710
+
1711
+ status() {
1712
+ const list = [...tasks.values()].map(taskView)
1713
+ const ready = list.filter(t => t.status === TASK_STATUS.READY).length
1714
+ const running = list.filter(t => t.status === TASK_STATUS.RUNNING).length
1715
+ return {
1716
+ version: FANG_VERSION,
1717
+ role,
1718
+ profileTag: layout.profileTag,
1719
+ dataDir: layout.dataDir,
1720
+ // 三区路径(无需 D: 访问即可在面板/接口核对目录是否已迁移)
1721
+ layout: {
1722
+ fangHome: layout.fangHome || '',
1723
+ dataDir: layout.dataDir,
1724
+ essenceRoot: layout.essenceRoot,
1725
+ forkHomesDir: layout.forkHomesDir,
1726
+ runtimeRoot: layout.runtimeRoot,
1727
+ sessionsRoot: layout.sessionsRoot || '',
1728
+ },
1729
+ mother: { profile: motherProfile, cliPath: cliPath(), template: templateDir() },
1730
+ tasks: { total: list.length, ready, running, max: cfg.maxForks, list },
1731
+ // v1 兼容视图:Client 徽标与旧调用方仍读 forks.*
1732
+ forks: { total: list.length, running: ready + running, max: cfg.maxForks, list },
1733
+ audit: { checkedAt: bootState.at, ok: bootState.ok, findings: bootState.findings },
1734
+ commercial: { ...commercialHooks },
1735
+ services: {
1736
+ forker: !!safeGet(SERVICE.forker),
1737
+ scorer: !!safeGet(SERVICE.scorer),
1738
+ healer: !!safeGet(SERVICE.healer),
1739
+ approver: !!safeGet(SERVICE.approver),
1740
+ hotReloader: !!safeGet(SERVICE.hotReloader),
1741
+ restart: !!safeGet(SERVICE.restart),
1742
+ buffer: !!safeGet(SERVICE.buffer),
1743
+ auditor: !!safeGet(SERVICE.auditor),
1744
+ router: !!safeGet(SERVICE.router),
1745
+ adapter: !!safeGet(SERVICE.adapter),
1746
+ commercial: !!safeGet(SERVICE.commercial),
1747
+ sessionCatalog: !!safeGet(SERVICE.sessionCatalog),
1748
+ decisionCenter: !!safeGet(SERVICE.decisionCenter),
1749
+ queueJournal: queueJournalEnabled(),
1750
+ },
1751
+ // 重启守卫摘要(只读):Client Header 用它显示 mode/红灯策略;无守卫时 available:false。
1752
+ restart: restartSummary(restartView()),
1753
+ }
1754
+ },
1755
+
1756
+ /** 母体重启的唯一入口:转交 restart-guard,自身永不直接退出进程。 */
1757
+ async requestMotherRestart(reason = 'manual') {
1758
+ const guard = safeGet(SERVICE.restart)
1759
+ if (!guard || typeof guard.request !== 'function') {
1760
+ return { ok: false, error: 'restart-guard-unavailable' }
1761
+ }
1762
+ const decision = await guard.request({
1763
+ reason: String(reason),
1764
+ risk: 'red',
1765
+ scope: 'mother',
1766
+ origin: 'fang:orchestrator',
1767
+ })
1768
+ // auto-allow is a successful policy decision, not a denial.
1769
+ const allowed = decision?.outcome === 'approved' || decision?.outcome === 'auto-allow'
1770
+ return { ok: allowed, ...decision }
1771
+ },
1772
+ }
1773
+
1774
+ ctx.effect(() => ctx.provide(SERVICE.orchestrator, service))
1775
+
1776
+ // v3.1.15:fang-master 契约服务(dsh-Master 通过 local/http/file/legacy 四层传输调用)。
1777
+ const contractCfg = {
1778
+ enabled: true,
1779
+ token: '',
1780
+ fileDir: '',
1781
+ pathPrefix: '/fang/api/contract',
1782
+ ...(config && typeof config.contract === 'object' ? config.contract : {}),
1783
+ }
1784
+ const contractEnv = (env) => {
1785
+ const out = {}
1786
+ if (!env || typeof env !== 'object') return out
1787
+ for (const [key, value] of Object.entries(env)) {
1788
+ if (!String(key).startsWith('DSH_MASTER_')) continue
1789
+ if (typeof value === 'string') out[String(key)] = value.slice(0, 32_000)
1790
+ }
1791
+ return out
1792
+ }
1793
+ const contractService = (key, method, required = true) => {
1794
+ const found = safeGet(SERVICE[key])
1795
+ if (found && typeof found[method] === 'function') return found
1796
+ if (!required) return null
1797
+ const error = new Error(`fang service ${key}.${method} is unavailable`)
1798
+ error.code = 'fang-service-unavailable'
1799
+ throw error
1800
+ }
1801
+ // v3.1.35:contract 1.1 的回流存储。peerLabel 是对端自报标识(不参与鉴权),
1802
+ // 必须写 'dsh-fang-bundle' —— 房主按此判定「已与真实瓮城对接」。
1803
+ const relayStore = createRelayStore({
1804
+ peerLabel: 'dsh-fang-bundle',
1805
+ role: gate.role,
1806
+ instanceId: `fang-${gate.role}-${process.pid}`,
1807
+ })
1808
+ const contractServer = createContractServer({
1809
+ role: gate.role,
1810
+ instanceId: `fang-${gate.role}-${process.pid}`,
1811
+ handlers: {
1812
+ // v3.1.35:fang.relay.publish / fang.relay.status(按注册表自动进 hello().methods)
1813
+ ...relayHandlers(relayStore),
1814
+ [CONTRACT_METHODS.FORK_SPAWN]: async (params) => {
1815
+ const forker = contractService('forker', 'spawn')
1816
+ const input = params && typeof params === 'object' ? params : {}
1817
+ return await forker.spawn({ ...input, env: contractEnv(input.env) })
1818
+ },
1819
+ [CONTRACT_METHODS.FORK_STOP]: async (params) => {
1820
+ const forker = contractService('forker', 'stop', false)
1821
+ if (!forker) { const error = new Error('fang forker stop is unavailable'); error.code = 'fang-service-unavailable'; throw error }
1822
+ return await forker.stop(String(params?.forkId ?? params?.taskId ?? ''))
1823
+ },
1824
+ [CONTRACT_METHODS.FORK_LIST]: async () => {
1825
+ const forker = contractService('forker', 'list', false)
1826
+ if (forker) return await forker.list()
1827
+ return service.listForks()
1828
+ },
1829
+ [CONTRACT_METHODS.DECISION_CREATE]: async (params) => await contractService('decisionCenter', 'create').create(params ?? {}),
1830
+ [CONTRACT_METHODS.DECISION_RESOLVE]: async (params) => await contractService('decisionCenter', 'resolve').resolve(params?.id, params?.option, { by: params?.by ?? 'dsh-master-contract', note: params?.note, custom: params?.custom }),
1831
+ [CONTRACT_METHODS.SESSION_LIST]: async (params) => {
1832
+ const catalog = contractService('sessionCatalog', 'list', false)
1833
+ if (catalog) return await catalog.list(params ?? {})
1834
+ const error = new Error('fang session catalog is unavailable'); error.code = 'fang-service-unavailable'; throw error
1835
+ },
1836
+ [CONTRACT_METHODS.ORCHESTRATOR_STATUS]: async () => service.status(),
1837
+ },
1838
+ })
1839
+ try { ctx.effect(() => ctx.provide(SERVICE.contract, contractServer)) } catch { /* fake ctx */ }
1840
+ if (contractCfg.enabled !== false && String(contractCfg.fileDir ?? '').trim()) {
1841
+ try {
1842
+ const fileServer = startFileContractServer({ dir: String(contractCfg.fileDir), server: contractServer, logger: ctx.logger })
1843
+ ctx.effect(() => () => fileServer.stop())
1844
+ } catch (error) {
1845
+ try { ctx.logger?.warn?.(`[fang-contract] file fallback disabled: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
1846
+ }
1847
+ }
1848
+
1849
+ // 启动时把持久化的 supervise 开关应用到已经挂载的 forker(插件顺序可能晚于本插件)。
1850
+ ctx.inject([SERVICE.forker], (child) => {
1851
+ try {
1852
+ const f = child?.get?.(SERVICE.forker) ?? child?.[SERVICE.forker] ?? child?.forker ?? null
1853
+ const stored = readForkSupervisionSettings(layout.dataDir)
1854
+ if (f && typeof f.setSupervise === 'function' && stored.supervise === true) f.setSupervise(true)
1855
+ } catch { /* forker optional */ }
1856
+ })
1857
+
1858
+ // ── v2.9.0 母体连接:只描述 + 只读探测,不持有连接状态 ──────────────────
1859
+ const linkProfileDir = () => {
1860
+ try {
1861
+ const dir = resolveProfileDir?.()
1862
+ return typeof dir === 'string' ? dir : ''
1863
+ } catch {
1864
+ return ''
1865
+ }
1866
+ }
1867
+ const linkService = {
1868
+ /** 母体连接描述:前缀/门户/接入说明/共享配置摘要(不包含任何 token)。 */
1869
+ describe: () => describeMotherLink({
1870
+ role: 'mother',
1871
+ version: FANG_VERSION,
1872
+ prefix: cfg.routes?.prefix ?? '/fang',
1873
+ sharedConfig: readSharedProfileSummary({ profileDir: linkProfileDir() }),
1874
+ }),
1875
+ /** 只读探测一个 http(s) 母体地址的 /api/status;失败以结构化错误返回。 */
1876
+ probe: (input = {}) => probeLink({
1877
+ target: input?.target,
1878
+ prefix: cfg.routes?.prefix ?? '/fang',
1879
+ timeoutMs: input?.timeoutMs,
1880
+ }),
1881
+ }
1882
+ ctx.effect(() => ctx.provide(SERVICE.link, linkService))
1883
+
1884
+ // ── v2.11.0 母体镜像:备份 / 恢复 / 升级 / 自动备份(只管理文件夹 1)──────
1885
+ const mirrorRoot = cfg.mirrorRoot ? String(cfg.mirrorRoot) : path.join(layout.dataDir, 'mirror')
1886
+ // v3.0.3:客户端升级后的兼容性门禁(模块导入 + 路由契约;失败保持子插件关闭)
1887
+ const compatRoot = fileURLToPath(new URL('../../', import.meta.url))
1888
+ let compatWebServer = null
1889
+ const rawConfig = config && typeof config === 'object' ? config : {}
1890
+ const runCompatCheck = async () => {
1891
+ if (typeof rawConfig.compatGate === 'function') {
1892
+ // 测试/嵌入注入点:不改变生产默认(bundle 配置无法携带函数)。
1893
+ const injected = await rawConfig.compatGate()
1894
+ writeCompatStatus(layout.dataDir, injected)
1895
+ return injected
1896
+ }
1897
+ const status = await runCompatGate({
1898
+ rootDir: compatRoot,
1899
+ expectedRouteCount: DEFAULT_EXPECTED_ROUTE_COUNT,
1900
+ routePaths: compatWebServer !== null && typeof compatWebServer.paths === 'function'
1901
+ ? compatWebServer.paths()
1902
+ : null,
1903
+ })
1904
+ writeCompatStatus(layout.dataDir, status)
1905
+ return status
1906
+ }
1907
+ let upgradeCoordinator = null
1908
+ // 官方源:rawConfig.officialSource 优先(测试/嵌入可注入对象);否则按 URL 构建 manifest adapter。
1909
+ let officialSource = rawConfig.officialSource ?? null
1910
+ if (officialSource === null && String(cfg.officialSourceUrl ?? '').trim()) {
1911
+ try {
1912
+ officialSource = createManifestOfficialSource({
1913
+ baseUrl: cfg.officialSourceUrl,
1914
+ token: cfg.officialSourceToken,
1915
+ headers: {},
1916
+ })
1917
+ } catch (error) {
1918
+ try { ctx.logger?.warn?.(`[mirror] official source disabled: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
1919
+ officialSource = null
1920
+ }
1921
+ }
1922
+ const mirrorManager = new MirrorManager({
1923
+ mirrorRoot,
1924
+ // 测试/嵌入注入点:官方源与 compat gate 都可替换;生产 bundle 可配置 manifest URL。
1925
+ officialSource,
1926
+ logger: ctx.logger ?? {},
1927
+ usageProvider: () => ({
1928
+ activeEssenceCount: 0,
1929
+ activeSessionCount: Array.isArray(service.listForks?.())
1930
+ ? service.listForks().filter(fork => fork.status === 'running').length
1931
+ : 0,
1932
+ }),
1933
+ compatGate: () => runCompatCheck(),
1934
+ onCompatResult: (compat) => { writeCompatStatus(layout.dataDir, compat) },
1935
+ postUpgradeHook: () => {
1936
+ if (upgradeCoordinator === null) return
1937
+ // 升级成功 → 元主重算;失败必须让人看见(决策中心 ticket),不吞掉。
1938
+ Promise.resolve(upgradeCoordinator.onUpgradeSuccess()).catch((error) => {
1939
+ raiseDecisionTicket({
1940
+ level: 'yellow',
1941
+ title: '升级后元主重算失败',
1942
+ context: `镜像升级成功,但元主重算失败:${String(error?.message ?? error)}`,
1943
+ options: [{ value: 'retry', label: '重试重算' }, { value: 'ignore', label: '忽略' }],
1944
+ dedupeKey: 'mirror-upgrade:recompute-failed',
1945
+ sourceName: 'MirrorUpgrade',
1946
+ })
1947
+ })
1948
+ },
1949
+ })
1950
+ mirrorManager.ensureLayout()
1951
+ const preferencesStore = new PreferencesStore({ mirrorRoot, logger: ctx.logger ?? {} })
1952
+ preferencesStore.load()
1953
+ const essenceManager = new EssenceManager({
1954
+ essencesRoot: layout.essenceRoot,
1955
+ mirrorRoot,
1956
+ logger: ctx.logger ?? {},
1957
+ })
1958
+ // v3.0.4:首次启动 seed 官方推荐原身(锁定、预装默认插件);失败不阻塞主插件
1959
+ let officialEssence = null
1960
+ try {
1961
+ officialEssence = essenceManager.ensureOfficialEssence()
1962
+ } catch (error) {
1963
+ ctx.logger?.warn?.('[essence] official essence seed failed', { error: String(error?.message ?? error) })
1964
+ }
1965
+ upgradeCoordinator = new MirrorUpgradeCoordinator({ essence: essenceManager, logger: ctx.logger ?? {} })
1966
+ // 元主运行态的 forkId 命名(稳定、可用于 forker 记录与端口池)
1967
+ const runtimeForkId = (essenceId) => 'rt-' + String(essenceId).replace(/^essence-/, '').replace(/[^A-Za-z0-9-]/g, '-')
1968
+ // v3.0.9 三区口径:运行态 home 统一在 runtimeRoot(FANG_HOME 下是 sessions/<essenceId>/home)。
1969
+ const runtimeHomeFor = (essenceId) => path.join(layout.runtimeRoot, String(essenceId), 'home')
1970
+ // 路径守卫:任何拼接出来的目标必须落在给定根内(拒绝 .. / 绝对路径逃逸)。
1971
+ const assertInside = (root, target, label) => {
1972
+ const resolvedRoot = path.resolve(String(root))
1973
+ const resolved = path.resolve(String(target))
1974
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
1975
+ throw new Error(`path-escape: ${label} (${resolved} not under ${resolvedRoot})`)
1976
+ }
1977
+ return resolved
1978
+ }
1979
+ // v3.0.9 会话配额:数该元主运行态隔离 home 里的会话目录(归档数不再计入——归档目录由实例自管)。
1980
+ const countEssenceSessions = (essenceId, meta) => {
1981
+ const home = path.join(runtimeHomeFor(essenceId), 'sessions')
1982
+ let used = 0
1983
+ try {
1984
+ for (const workspace of fs.readdirSync(home, { withFileTypes: true })) {
1985
+ if (!workspace.isDirectory()) continue
1986
+ const wsDir = path.join(home, workspace.name)
1987
+ for (const session of fs.readdirSync(wsDir, { withFileTypes: true })) {
1988
+ if (session.isDirectory()) used += 1
1989
+ }
1990
+ }
1991
+ } catch { used = 0 }
1992
+ const tier = String(meta?.tier ?? 'free')
1993
+ const limit = Number(VIP_QUOTAS[tier]?.maxActiveSessions) || VIP_QUOTAS.free.maxActiveSessions
1994
+ return { used, limit, over: used >= limit }
1995
+ }
1996
+
1997
+ // 把母体的工作区索引按“被导入的会话”过滤后合并进隔离 home,界面上按工作区名分组而不是 key。
1998
+ const mergeWorkspaceIndexFor = (homeDir, importedSessions) => {
1999
+ try {
2000
+ const motherLayout = resolveSessionsLayout()
2001
+ const motherRaw = exists(motherLayout.workspaceFile) ? fs.readFileSync(motherLayout.workspaceFile, 'utf8') : ''
2002
+ const mother = parseWorkspaceIndex(motherRaw)
2003
+ const keys = new Set(importedSessions.map((session) => String(session.workspaceKey)))
2004
+ const ids = new Set(importedSessions.map((session) => String(session.sessionId)))
2005
+ const targetFile = path.join(homeDir, 'storages', 'workspace.json')
2006
+ const existing = exists(targetFile) ? parseWorkspaceIndex(fs.readFileSync(targetFile, 'utf8')) : { workspaces: [], global: { archivedSessionIds: [], pinnedSessionIds: [] } }
2007
+ const byPath = new Map(existing.workspaces.map((workspace) => [workspace.path, workspace]))
2008
+ for (const workspace of mother.workspaces) {
2009
+ if (!keys.has(encodeWorkspaceKey(workspace.path))) continue
2010
+ const previous = byPath.get(workspace.path)
2011
+ const sessionIds = [...new Set([...(previous?.sessionIds ?? []), ...workspace.sessionIds.filter((id) => ids.has(id))])]
2012
+ byPath.set(workspace.path, { ...workspace, sessionIds })
2013
+ }
2014
+ const workspaces = [...byPath.values()]
2015
+ const archivedSessionIds = [...new Set([
2016
+ ...(existing.global?.archivedSessionIds ?? []),
2017
+ ...mother.global.archivedSessionIds.filter((id) => ids.has(id)),
2018
+ ])]
2019
+ const pinnedSessionIds = [...new Set([
2020
+ ...(existing.global?.pinnedSessionIds ?? []),
2021
+ ...mother.global.pinnedSessionIds.filter((id) => ids.has(id)),
2022
+ ])]
2023
+ const tables = {}
2024
+ for (const workspace of workspaces) {
2025
+ tables[workspace.id] = {
2026
+ path: workspace.path, title: workspace.title, sessionIds: workspace.sessionIds,
2027
+ createdAt: workspace.createdAt, updatedAt: workspace.updatedAt,
2028
+ }
2029
+ }
2030
+ ensureDir(path.dirname(targetFile))
2031
+ writeJsonAtomic(targetFile, {
2032
+ unit: { name: 'workspace', version: 2 },
2033
+ global: {
2034
+ initialized: true,
2035
+ defaultWorkspaceId: workspaces[0]?.id ?? null,
2036
+ workspaceIds: workspaces.map((workspace) => workspace.id),
2037
+ archivedSessionIds,
2038
+ pinnedSessionIds,
2039
+ },
2040
+ tables: { workspaces: tables },
2041
+ })
2042
+ return { ok: true, workspaces: workspaces.length }
2043
+ } catch (error) {
2044
+ return { ok: false, error: String(error?.message ?? error) }
2045
+ }
2046
+ }
2047
+
2048
+ const importSessionsInto = (homeDir, sessionIds) => {
2049
+ const ids = [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map((id) => String(id ?? '').trim()).filter(Boolean))]
2050
+ if (ids.length === 0) return []
2051
+ if (ids.length > 5) throw new Error('too-many-sessions (max 5)')
2052
+ const catalog = safeGet(SERVICE.sessionCatalog)
2053
+ if (!catalog || typeof catalog.listSessions !== 'function') throw new Error('session-catalog-unavailable')
2054
+ const listed = catalog.listSessions({ limit: 500, includeArchived: false })
2055
+ const rows = Array.isArray(listed?.sessions) ? listed.sessions : []
2056
+ const sessionsRoot = String(listed?.sessionsRoot ?? '')
2057
+ if (!sessionsRoot) throw new Error('sessions-root-missing')
2058
+ const byId = new Map(rows.map((row) => [String(row.sessionId), row]))
2059
+ const imported = []
2060
+ for (const id of ids) {
2061
+ const row = byId.get(id)
2062
+ if (!row) throw new Error('unknown-session: ' + id)
2063
+ if (row.archived === true) throw new Error('archived-session-not-importable: ' + id)
2064
+ const workspaceKey = String(row.workspaceKey ?? '')
2065
+ if (!workspaceKey || workspaceKey.includes('..') || workspaceKey.includes('/') || workspaceKey.includes('\\')) {
2066
+ throw new Error('invalid-workspace-key: ' + workspaceKey)
2067
+ }
2068
+ const source = path.join(sessionsRoot, workspaceKey, id)
2069
+ if (!exists(source)) throw new Error('session-dir-missing: ' + id)
2070
+ const target = assertInside(homeDir, path.join(homeDir, 'sessions', workspaceKey, id), 'session-import')
2071
+ ensureDir(path.dirname(target))
2072
+ if (exists(target)) removeTree(target)
2073
+ copyTree(source, target)
2074
+ imported.push({ sessionId: id, title: row.title ?? null, workspaceKey })
2075
+ }
2076
+ // 工作区索引合并:让实例按工作区名分组,而不是显示 --E-GPT-- 这样的 key。
2077
+ const index = mergeWorkspaceIndexFor(homeDir, imported)
2078
+ if (index.ok === false) {
2079
+ try { audit('workspace-index-merge-failed', { homeDir, error: index.error }) } catch { /* ignore */ }
2080
+ }
2081
+ return imported
2082
+ }
2083
+
2084
+ const mirrorService = {
2085
+ status: () => {
2086
+ const status = mirrorManager.getStatus()
2087
+ let preferences = null
2088
+ let sync = null
2089
+ try { preferences = preferencesStore.get() } catch { preferences = null }
2090
+ try { sync = readJson(path.join(mirrorRoot, 'sync-state.json'), null) } catch { sync = null }
2091
+ return { ...status, preferences, sync }
2092
+ },
2093
+ getPreferences: () => preferencesStore.get(),
2094
+ setPreferences: (patch = {}) => preferencesStore.update(patch),
2095
+ listEssences: () => {
2096
+ const defaultEssence = essenceManager.getDefault()
2097
+ // 元主是否落后于母体镜像:lastMergedFingerprint !== 当前镜像指纹。
2098
+ let mirrorFingerprint = ''
2099
+ try { mirrorFingerprint = String(essenceManager.loadMirror().fingerprint ?? '') } catch { /* mirror 不可读时全部标 stale=false */ }
2100
+ const essences = essenceManager.list().map((meta) => {
2101
+ const ui = buildEssenceActions({ locked: meta.locked === true, archived: meta.archived === true })
2102
+ const stale = mirrorFingerprint.length > 0 && meta.archived !== true
2103
+ && String(meta.lastMergedFingerprint ?? '') !== mirrorFingerprint
2104
+ const sessionQuota = countEssenceSessions(meta.id, meta)
2105
+ // 元宝卡片第二行:生成时间 + 最后一次与母体同步时间(merged/fingerprint.json)。
2106
+ let lastMergedAt = null
2107
+ try {
2108
+ const fingerprint = readJson(path.join(essenceManager.essenceRoot(meta.id), 'merged', 'fingerprint.json'), null)
2109
+ const value = Number(fingerprint?.mergedAt)
2110
+ if (Number.isFinite(value) && value > 0) lastMergedAt = value
2111
+ } catch { /* profile 尚未物化 */ }
2112
+ if (!lastMergedAt && Number(meta.updatedAt) > 0) lastMergedAt = Number(meta.updatedAt)
2113
+ return {
2114
+ ...meta,
2115
+ createdAt: Number(meta.createdAt) || null,
2116
+ lastMergedAt,
2117
+ lastSyncAt: lastMergedAt,
2118
+ locked: meta.locked === true,
2119
+ official: meta.official === true,
2120
+ archived: meta.archived === true,
2121
+ stale,
2122
+ sessions: sessionQuota,
2123
+ // 设计稿字段:quota.sessions = { used, limit, over }
2124
+ quota: { sessions: sessionQuota },
2125
+ // 运行态:面板据此把「打开」切换成「停止」。
2126
+ runtime: (() => {
2127
+ try {
2128
+ const live = forker()?.status?.(runtimeForkId(meta.id))
2129
+ return live ? { running: live.running === true, pid: live.pid ?? null, port: live.port ?? null } : { running: false, pid: null, port: null }
2130
+ } catch { return { running: false, pid: null, port: null } }
2131
+ })(),
2132
+ pluginCount: (() => { try { return essenceManager.pluginCount(meta.id) } catch { return 0 } })(),
2133
+ actions: ui.actions,
2134
+ blocks: ui.blocks,
2135
+ }
2136
+ })
2137
+ return {
2138
+ officialId: OFFICIAL_ESSENCE_ID,
2139
+ official: officialEssence,
2140
+ defaultId: defaultEssence.essenceId,
2141
+ defaultEssence,
2142
+ essences,
2143
+ mirrorFingerprint,
2144
+ staleCount: essences.filter(item => item.stale === true).length,
2145
+ capacity: { slots: 7, defaultLimit: 5, used: essences.filter(item => item.archived !== true).length },
2146
+ }
2147
+ },
2148
+ createEssence: (input = {}) => essenceManager.create({
2149
+ name: input?.name, tier: input?.tier, parentId: input?.parentId ?? null,
2150
+ defaultPatchEntries: Array.isArray(input?.defaultPatchEntries) ? input.defaultPatchEntries : undefined,
2151
+ }),
2152
+ defaultPreset: async () => {
2153
+ const entries = await listInstalledPlugins()
2154
+ const plan = planDefaultEssence(entries ?? [])
2155
+ const existingId = readDefaultEssenceId()
2156
+ const meta = existingId ? essenceManager.getMeta(existingId) : null
2157
+ return { ok: true, pluginManager: Array.isArray(entries), plan, defaultEssenceId: meta?.id ? existingId : '', exists: Boolean(meta?.id) }
2158
+ },
2159
+ ensureDefaultEssence: async ({ apply = false, force = false, mode = 'copy-local' } = {}) => {
2160
+ const existingId = readDefaultEssenceId()
2161
+ const existing = existingId ? essenceManager.getMeta(existingId) : null
2162
+ if (existing?.id) return { ok: true, existed: true, created: false, essenceId: existingId, mode }
2163
+ const entries = await listInstalledPlugins()
2164
+ const plan = planDefaultEssence(entries ?? [])
2165
+ const userEssences = essenceManager.list().filter((meta) => meta.official !== true)
2166
+ if (!force && userEssences.length > 0) return { ok: false, error: 'user-essence-exists', plan, mode }
2167
+ if (apply !== true) return { ok: true, dryRun: true, plan, mode }
2168
+ if (mode === 'preset') {
2169
+ const patchEntries = []
2170
+ const pending = []
2171
+ for (const group of plan.groups) {
2172
+ for (const plugin of group.plugins) {
2173
+ if (!plugin.installed) { pending.push({ spec: plugin.spec, group: group.key }); continue }
2174
+ patchEntries.push({ id: plugin.pluginId || plugin.spec, name: plugin.spec, disabled: group.enabled !== true })
2175
+ }
2176
+ }
2177
+ if (patchEntries.length === 0) return { ok: false, error: 'no-installed-default-plugins', plan, pending, mode }
2178
+ const created = essenceManager.create({ name: DEFAULT_ESSENCE_NAME, tier: 'free', defaultPatchEntries: patchEntries })
2179
+ writeDefaultEssenceId(created.id)
2180
+ try {
2181
+ writeJsonAtomic(path.join(essenceManager.essenceRoot(created.id), 'default-preset.json'), {
2182
+ at: nowMs(), mode, version: plan.version, groups: plan.groups, patchEntries, pending,
2183
+ })
2184
+ } catch { /* marker 失败不影响创建 */ }
2185
+ audit('default-essence-created', { essenceId: created.id, mode, installed: patchEntries.length, pending: pending.length })
2186
+ return { ok: true, created: true, mode, essenceId: created.id, plan, pending }
2187
+ }
2188
+ // copy-local(默认):只复制本机插件的启用/禁用 + 设置;不复制任何会话/数据/代码/凭据。
2189
+ let captured
2190
+ try {
2191
+ captured = essenceManager.captureFromProfile(templateDir(), { name: DEFAULT_ESSENCE_NAME, tier: 'free' })
2192
+ } catch (error) {
2193
+ return { ok: false, error: 'capture-failed', detail: String(error?.message ?? error), plan, mode }
2194
+ }
2195
+ const created = captured.meta ?? captured
2196
+ writeDefaultEssenceId(created.id)
2197
+ try {
2198
+ writeJsonAtomic(path.join(essenceManager.essenceRoot(created.id), 'default-preset.json'), {
2199
+ at: nowMs(), mode, source: templateDir(),
2200
+ captured: captured.captured ?? null,
2201
+ conflicts: captured.conflicts ?? [],
2202
+ note: '只复制插件启用状态与设置;未复制会话/项目/凭据/Token',
2203
+ })
2204
+ } catch { /* marker 失败不影响创建 */ }
2205
+ audit('default-essence-created', { essenceId: created.id, mode, source: templateDir() })
2206
+ return { ok: true, created: true, mode, essenceId: created.id, plan, captured: captured.captured ?? null, conflicts: captured.conflicts ?? [] }
2207
+ },
2208
+ renameEssence: (essenceId, name) => essenceManager.rename(essenceId, name),
2209
+ archiveEssence: (essenceId, archived = true) => essenceManager.archive(essenceId, archived === true),
2210
+ deleteEssence: (essenceId) => {
2211
+ const id = String(essenceId ?? '').trim()
2212
+ const meta = essenceManager.getMeta(id)
2213
+ // 删除配置/插件目录,但**保留运行态会话**:旧布局把运行时 home 放在原身目录里,先搬出去。
2214
+ const legacyHome = path.join(essenceManager.essenceRoot(id), 'runtime', 'home')
2215
+ const preserved = runtimeHomeFor(id)
2216
+ try {
2217
+ if (exists(legacyHome) && !exists(preserved)) {
2218
+ ensureDir(path.dirname(preserved))
2219
+ copyTree(legacyHome, preserved)
2220
+ }
2221
+ } catch { /* 保留失败不阻塞删除,如实返回 */ }
2222
+ const removed = essenceManager.remove(id)
2223
+ addTombstone(id, meta?.name)
2224
+ return { ...removed, essenceId: id, tombstone: true, sessionsPreserved: exists(preserved) }
2225
+ },
2226
+ copyEssence: (essenceId, name) => {
2227
+ const result = essenceManager.copy(essenceId, { name })
2228
+ // v3.0.9:沙盒会话(运行态隔离 home 里的 sessions)随副本一起复制,副本可继续开发。
2229
+ const candidates = [
2230
+ path.join(runtimeHomeFor(String(essenceId)), 'sessions'),
2231
+ path.join(essenceManager.essenceRoot(String(essenceId)), 'runtime', 'home', 'sessions'),
2232
+ path.join(layout.dataDir, 'runtime', runtimeForkId(String(essenceId)), 'home', 'sessions'),
2233
+ ]
2234
+ const source = candidates.find((dir) => exists(dir))
2235
+ let sessionsCopied = 0
2236
+ if (source) {
2237
+ const target = path.join(runtimeHomeFor(result.id), 'sessions')
2238
+ try {
2239
+ copyTree(source, target)
2240
+ const walk = (dir) => {
2241
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2242
+ const file = path.join(dir, entry.name)
2243
+ if (entry.isDirectory()) walk(file)
2244
+ else if (entry.isFile()) sessionsCopied += 1
2245
+ }
2246
+ }
2247
+ walk(target)
2248
+ } catch { /* 会话复制失败不阻塞配置副本,如实返回 0 */ }
2249
+ // 工作区索引也随副本走,否则副本里会话会按 workspace key 显示。
2250
+ const sourceIndex = path.join(path.dirname(path.dirname(source)), 'storages', 'workspace.json')
2251
+ if (exists(sourceIndex)) {
2252
+ const targetIndex = path.join(runtimeHomeFor(result.id), 'storages', 'workspace.json')
2253
+ try { ensureDir(path.dirname(targetIndex)); fs.copyFileSync(sourceIndex, targetIndex) } catch { /* ignore */ }
2254
+ }
2255
+ }
2256
+ return { ...result, copy: { ...(result.copy ?? {}), sessionsCopied } }
2257
+ },
2258
+ // v3.0.9 母体 → 元主:克隆母体 profile 的可写层(patch/依赖/设置),不带凭据与会话。
2259
+ captureEssence: (input = {}) => {
2260
+ const result = essenceManager.captureFromProfile(templateDir(), {
2261
+ name: input?.name,
2262
+ tier: input?.tier,
2263
+ parentId: input?.parentId ?? null,
2264
+ includeDependencies: input?.includeDependencies !== false,
2265
+ includeSettings: input?.includeSettings !== false,
2266
+ })
2267
+ const defaultEssence = input?.setDefault === true ? essenceManager.setDefault(result.meta.id) : null
2268
+ return { ...result, default: defaultEssence }
2269
+ },
2270
+ setDefaultEssence: (essenceId) => essenceManager.setDefault(essenceId),
2271
+ downloadEssence: (essenceId, options = {}) => {
2272
+ const bundle = essenceManager.downloadBundle(essenceId, { full: options?.full === true })
2273
+ if (bundle.full !== true) return bundle
2274
+ // 整包迁移还要带走运行态沙盒:会话 + 工作区索引(运行态 home 在原身文件夹之外)。
2275
+ const id = String(essenceId)
2276
+ const home = runtimeHomeFor(id)
2277
+ const addFile = (relative, file) => {
2278
+ try {
2279
+ const bytes = fs.readFileSync(file)
2280
+ bundle.files.push({ path: relative, base64: bytes.toString('base64') })
2281
+ bundle.bytes += bytes.length
2282
+ } catch { /* 读失败跳过,不阻塞导出 */ }
2283
+ }
2284
+ const indexFile = path.join(home, 'storages', 'workspace.json')
2285
+ if (exists(indexFile)) addFile('runtime/home/storages/workspace.json', indexFile)
2286
+ const sessionsDir = path.join(home, 'sessions')
2287
+ if (exists(sessionsDir)) {
2288
+ const walk = (dir, rel) => {
2289
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2290
+ const fullPath = path.join(dir, entry.name)
2291
+ const nextRel = rel ? rel + '/' + entry.name : entry.name
2292
+ if (entry.isDirectory()) walk(fullPath, nextRel)
2293
+ else if (entry.isFile()) addFile('runtime/home/sessions/' + nextRel, fullPath)
2294
+ }
2295
+ }
2296
+ walk(sessionsDir, '')
2297
+ }
2298
+ return bundle
2299
+ },
2300
+ backupEssence: (essenceId) => essenceManager.backup(essenceId),
2301
+ restoreEssence: (essenceId) => essenceManager.restoreBackup(essenceId),
2302
+ importEssence: (input = {}) => essenceManager.importBundle(input?.bundle ?? input, {
2303
+ name: input?.name, tier: input?.tier, parentId: input?.parentId ?? null,
2304
+ }),
2305
+ // v3.1.0 元主运行态:用元主 merged/profile 起一个**隔离 home** 的配置态实例。
2306
+ // 隔离边界:DSH_HOME = sessions/<essenceId>/home(自己的 sessions/storages),
2307
+ // node_modules 也属于元主自己(D: 盘),不再 junction 到母体 C: 盘模块树;
2308
+ // 分身才复用元主/母体的 node_modules junction。
2309
+ startEssenceRuntime: async (essenceId, input = {}) => {
2310
+ const id = String(essenceId ?? '').trim()
2311
+ const meta = essenceManager.getMeta(id)
2312
+ if (!meta || typeof meta.id !== 'string') throw new Error('unknown-essence')
2313
+ if (meta.archived === true) throw new Error('essence-archived')
2314
+ // 进行中会话配额(默认 10;归档不限;达到上限拒绝再开实例)。
2315
+ const sessionQuota = countEssenceSessions(id, meta)
2316
+ if (sessionQuota.over) throw new Error(`session-quota-exceeded: ${sessionQuota.used}/${sessionQuota.limit}`)
2317
+ const f = forker()
2318
+ if (!f || typeof f.provision !== 'function' || typeof f.spawn !== 'function') throw new Error('forker-unavailable')
2319
+ const forkId = runtimeForkId(id)
2320
+ const runtimeProfileDirEarly = path.join(runtimeHomeFor(id), 'profiles', 'essence-runtime')
2321
+ // v3.1.8:上次运行态里的插件安装/更新/卸载先捕获回元宝(overlay + merged + 模块树),
2322
+ // 否则 ensureFresh/provision 会用旧的元宝配置重放,把用户的工作静默丢掉。
2323
+ let capture = null
2324
+ try {
2325
+ const runtimeStatus = f.status?.(forkId) ?? null
2326
+ if (runtimeStatus?.running !== true && exists(runtimeProfileDirEarly)) {
2327
+ capture = captureRuntimeProfileIntoEssence({
2328
+ essenceManager, essenceId: id, runtimeProfileDir: runtimeProfileDirEarly, logger: ctx.logger,
2329
+ })
2330
+ }
2331
+ } catch (error) {
2332
+ capture = { ok: false, captured: false, error: 'capture-threw', detail: String(error?.message ?? error) }
2333
+ }
2334
+ await essenceManager.ensureFresh(id)
2335
+ const merged = essenceManager.mergedProfileDir(id)
2336
+ if (!exists(merged)) throw new Error('merged-profile-missing')
2337
+ assertInside(layout.essenceRoot, merged, 'essence-merged')
2338
+ const motherModules = path.join(templateDir(), 'node_modules')
2339
+ const essenceModules = path.join(merged, 'node_modules')
2340
+ // 元主自己的 node_modules:必须是 D: 盘真实目录,不能是到母体 C: 盘的 junction。
2341
+ // 旧布局/新建元主可能缺失;首次打开时从母体复制一份,之后各自独立升级。
2342
+ if (!exists(essenceModules)) {
2343
+ try {
2344
+ if (exists(motherModules)) copyTree(motherModules, essenceModules)
2345
+ else ensureDir(essenceModules)
2346
+ } catch (error) {
2347
+ throw new Error(`essence-node-modules-copy-failed: ${String(error?.message ?? error)}`)
2348
+ }
2349
+ }
2350
+ // 复制来的模块树记录了母体 store;元主/运行态的 pnpm 必须显式用同一个 store,
2351
+ // 否则项目在 D: 盘、模块来自 C: store 时会报 ERR_PNPM_UNEXPECTED_STORE。
2352
+ // v3.1.8:pnpm 11 只从 pnpm-workspace.yaml 读取非鉴权设置(.npmrc 只剩鉴权/网络键),
2353
+ // 且 modules manifest 里记录的 virtual store 仍是母体路径;两处都由
2354
+ // ensureEssenceRuntimePnpmConfig 在运行态 profile 上修正(见下方 spawn 前)。
2355
+ // v3.0.9 三区:运行态 home 在 runtimeRoot 下(FANG_HOME 时是 sessions/<essenceId>/home),
2356
+ // 原身文件夹只保留配置,项目/会话/任务文件不落原身目录。
2357
+ const homeDir = runtimeHomeFor(id)
2358
+ assertInside(layout.runtimeRoot, homeDir, 'runtime-home')
2359
+ // 运行态 profile 的 node_modules 必须重新指向该元主自己的模块树。
2360
+ // makeJunction 对已存在的链接不会替换,所以先安全移除旧 junction(不进入目标)。
2361
+ const runtimeProfileDir = runtimeProfileDirEarly
2362
+ const runtimeModules = path.join(runtimeProfileDir, 'node_modules')
2363
+ // 迁移/旧版本可能留下“指向不存在 merged/profile/node_modules”的 junction;
2364
+ // fs.existsSync 对 broken junction 返回 false,必须用 lstat 才能发现并清掉。
2365
+ try {
2366
+ const stat = fs.lstatSync(runtimeModules)
2367
+ if (stat.isSymbolicLink() || !exists(runtimeModules)) removeTree(runtimeModules)
2368
+ } catch { /* 不存在 */ }
2369
+ const legacyHomes = [
2370
+ path.join(layout.dataDir, 'runtime', forkId, 'home'),
2371
+ path.join(essenceManager.essenceRoot(id), 'runtime', 'home'),
2372
+ ]
2373
+ if (!exists(homeDir)) {
2374
+ const legacyHome = legacyHomes.find((dir) => exists(dir))
2375
+ if (legacyHome) {
2376
+ try { ensureDir(path.dirname(homeDir)); copyTree(legacyHome, homeDir) } catch { /* 迁移失败不阻塞 */ }
2377
+ }
2378
+ }
2379
+ const provisioned = await f.provision({
2380
+ forkId, taskTitle: `元主运行时 · ${String(meta.name ?? id)}`,
2381
+ homeDir, profile: 'essence-runtime', profileTemplate: merged,
2382
+ // 元主运行态必须继承元主的插件/设置;clean template 只用于空白分身,
2383
+ // 否则官方/用户元主打开后看不到本机插件列表(v3.0.10 实测)。
2384
+ cleanTemplate: false,
2385
+ nodeModulesSource: essenceModules,
2386
+ })
2387
+ if (!provisioned.ok) throw new Error(String(provisioned.error ?? 'runtime-provision-failed'))
2388
+ // 历史版本把生产盘锁写进了 essence-runtime;配置实例必须能管理插件,升级后清掉。
2389
+ try { clearForkConfigLock(runtimeProfileDir) } catch { /* best effort */ }
2390
+ // v3.1.7:运行态 profile 的 node_modules 必须是 per-entry mirror(不是整树 junction)。
2391
+ // 整树 junction 会让插件 importer 落在 DSH profile 作用域之外,运行态内 w2m 等插件
2392
+ // 无法解析 @deepseek-ai/*,退回离线 shim,工具 schema 非法 → 模型请求被供应商拒绝。
2393
+ try {
2394
+ const repaired = ensureModuleMirror(runtimeModules)
2395
+ if (repaired.ok === false) throw new Error(repaired.error ?? 'unknown')
2396
+ } catch (error) {
2397
+ throw new Error(`runtime-node-modules-mirror-failed: ${String(error?.message ?? error)}`)
2398
+ }
2399
+ // v3.1.8:让运行态内的 pnpm 真正可用(元宝「插件」页更新/卸载插件走 DSH plugin manager)。
2400
+ // 失败不阻塞实例启动(实例仍可跑会话),但要把原因带回给调用方/面板。
2401
+ let pnpmConfig = null
2402
+ try {
2403
+ pnpmConfig = ensureEssenceRuntimePnpmConfig({
2404
+ profileDir: runtimeProfileDir,
2405
+ modulesDir: runtimeModules,
2406
+ sourceModulesDir: essenceModules,
2407
+ nodeLinker: readNodeLinker(templateDir()),
2408
+ })
2409
+ } catch (error) {
2410
+ pnpmConfig = { ok: false, error: 'pnpm-config-threw', detail: String(error?.message ?? error) }
2411
+ }
2412
+ // 先导入选中的进行中会话,再拉起实例(实例启动时就能看到它们)。
2413
+ const importedSessions = importSessionsInto(homeDir, input?.sessionIds)
2414
+ // 注意:forker.spawn 需要显式 homeDir/profile(它不从 provision 记录里猜)。
2415
+ const started = await f.spawn({
2416
+ forkId,
2417
+ homeDir,
2418
+ profile: 'essence-runtime',
2419
+ cliPath: cliPath(),
2420
+ // v3.1.16:原身运行态同样需要母体 dataDir,房主 Router 才能零配置推导
2421
+ // 契约 file 桥(contract-outbox);普通分身在 846/1461/1534 行已有同名注入。
2422
+ env: { DSH_FANG_ROLE: 'fork', DSH_FANG_TASK_ID: forkId, DSH_FANG_ESSENCE: '1', DSH_FANG_MOTHER: layout.dataDir },
2423
+ })
2424
+ if (!started.ok) {
2425
+ // v3.1.33:把真实 errno 与启动规格带进错误信息,避免只剩一个笼统的 spawn-error。
2426
+ const detail = started.message ? ` message=${started.message}` : ''
2427
+ const spec = started.spec ? ` spec=${started.spec.command} ${(started.spec.args ?? []).join(' ')}` : ''
2428
+ const logTail = Array.isArray(started.log) && started.log.length > 0
2429
+ ? ` log=${String(started.log.slice(-3).join(' | ')).slice(0, 400)}`
2430
+ : ''
2431
+ throw new Error(
2432
+ `runtime-spawn-failed: ${started.error ?? 'unknown'} (forkId=${forkId}, homeDir=${homeDir}, profile=essence-runtime)`
2433
+ + detail + spec + logTail,
2434
+ )
2435
+ }
2436
+ return {
2437
+ ok: true, essenceId: id, forkId,
2438
+ url: started.url, port: started.port, pid: started.pid,
2439
+ sandboxHome: homeDir,
2440
+ importedSessions,
2441
+ pnpmConfig,
2442
+ capture,
2443
+ }
2444
+ },
2445
+ stopEssenceRuntime: async (essenceId) => {
2446
+ const id = String(essenceId ?? '').trim()
2447
+ const f = forker()
2448
+ if (!f || typeof f.stop !== 'function') return { ok: false, error: 'forker-unavailable' }
2449
+ const forkId = runtimeForkId(id)
2450
+ const out = f.stop(forkId)
2451
+ // v3.1.8:停止后把运行态最后一次保存的插件配置捕获回元宝;捕获失败不改变停止结果。
2452
+ let capture = null
2453
+ try {
2454
+ const runtimeProfileDir = path.join(runtimeHomeFor(id), 'profiles', 'essence-runtime')
2455
+ capture = captureRuntimeProfileIntoEssence({
2456
+ essenceManager, essenceId: id, runtimeProfileDir, logger: ctx.logger,
2457
+ })
2458
+ } catch (error) {
2459
+ capture = { ok: false, captured: false, error: 'capture-threw', detail: String(error?.message ?? error) }
2460
+ }
2461
+ return { ok: true, essenceId: id, forkId, stopped: out?.ok !== false, capture }
2462
+ },
2463
+ essenceRuntimeStatus: (essenceId) => {
2464
+ const id = String(essenceId ?? '').trim()
2465
+ const f = forker()
2466
+ if (!f || typeof f.status !== 'function') return { ok: false, error: 'forker-unavailable' }
2467
+ const forkId = runtimeForkId(id)
2468
+ let runtime = null
2469
+ try { runtime = f.status(forkId) ?? null } catch { runtime = null }
2470
+ return { ok: true, essenceId: id, forkId, runtime }
2471
+ },
2472
+ /** 打开前可选导入的「进行中」会话(最多 50 条给选择器,提交时服务端再限 5 条)。 */
2473
+ essenceOpenCandidates: () => {
2474
+ const catalog = safeGet(SERVICE.sessionCatalog)
2475
+ if (!catalog || typeof catalog.listSessions !== 'function') return { ok: false, error: 'session-catalog-unavailable' }
2476
+ const listed = catalog.listSessions({ limit: 200, includeArchived: false })
2477
+ const sessions = (Array.isArray(listed?.sessions) ? listed.sessions : [])
2478
+ .filter((row) => row?.archived !== true)
2479
+ .slice(0, 50)
2480
+ .map((row) => ({
2481
+ sessionId: row.sessionId,
2482
+ title: row.title ?? null,
2483
+ workspaceTitle: row.workspaceTitle ?? null,
2484
+ updatedAt: row.updatedAt ?? 0,
2485
+ }))
2486
+ return { ok: true, sessions, total: sessions.length }
2487
+ },
2488
+ /** 元主详情页数据:元数据 + overlay 插件条目 + 设置项 + 备份/运行态/日志(不含凭据)。 */
2489
+ essenceDetail: (essenceId) => {
2490
+ const id = String(essenceId ?? '').trim()
2491
+ const meta = essenceManager.getMeta(id)
2492
+ if (!meta || typeof meta.id !== 'string') throw new Error('unknown-essence')
2493
+ const root = essenceManager.essenceRoot(id)
2494
+ const overlay = essenceManager.loadOverlay(root)
2495
+ const patchEntries = (overlay.patchEntries || []).map((entry) => ({
2496
+ id: String(entry?.id ?? ''),
2497
+ name: String(entry?.name ?? ''),
2498
+ disabled: entry?.disabled === true,
2499
+ }))
2500
+ const f = forker()
2501
+ let runtime = null
2502
+ let logs = ''
2503
+ try { runtime = f?.status?.(runtimeForkId(id)) ?? null } catch { runtime = null }
2504
+ try { logs = String(f?.logs?.(runtimeForkId(id), 120) || '') } catch { logs = '' }
2505
+ return {
2506
+ ok: true,
2507
+ essenceId: id,
2508
+ meta,
2509
+ overlay: { patchEntries, settings: Object.keys(overlay.settings || {}) },
2510
+ sessions: countEssenceSessions(id, meta),
2511
+ backup: exists(path.join(root, 'Backup')),
2512
+ runtime,
2513
+ logs,
2514
+ }
2515
+ },
2516
+ /** 打开运行态:返回带临时 token 的 URL(仅内存,不落盘)。 */ openEssenceRuntime: (essenceId) => {
2517
+ const id = String(essenceId ?? '').trim()
2518
+ const f = forker()
2519
+ if (!f || typeof f.openUrl !== 'function') return { ok: false, error: 'forker-unavailable' }
2520
+ const out = f.openUrl(runtimeForkId(id))
2521
+ if (!out || out.ok === false) return { ok: false, error: out?.error ?? 'not-running' }
2522
+ return { ok: true, essenceId: id, url: out.url, port: out.port ?? null }
2523
+ },
2524
+ // v3.0.9 升级链路 2.1:把本机母体 profile(PC 客户端那份)克隆成镜像基础层。
2525
+ cloneMotherProfile: (input = {}) => {
2526
+ const sourceDir = path.resolve(String(input?.sourceDir || templateDir()))
2527
+ const packageFile = path.join(sourceDir, 'package.json')
2528
+ if (!exists(packageFile)) throw new Error('mother profile has no package.json: ' + sourceDir)
2529
+ const activeProfile = path.join(mirrorRoot, 'active', 'profile')
2530
+ const relative = path.relative(path.resolve(mirrorRoot), sourceDir)
2531
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
2532
+ throw new Error('clone: source is already inside the mirror')
2533
+ }
2534
+ let backup = null
2535
+ try { backup = mirrorManager.backup({ label: '克隆母体前自动备份' }) } catch { /* 首次克隆没有可备份内容 */ }
2536
+ if (exists(activeProfile)) removeTree(activeProfile)
2537
+ ensureDir(activeProfile)
2538
+ const picked = []
2539
+ for (const rel of ['package.json', 'cordis.patch.yml', 'cordis.yml']) {
2540
+ const from = path.join(sourceDir, rel)
2541
+ if (!exists(from)) continue
2542
+ fs.copyFileSync(from, path.join(activeProfile, rel))
2543
+ picked.push(rel)
2544
+ }
2545
+ const settingsDir = path.join(sourceDir, 'settings')
2546
+ if (exists(settingsDir)) { copyTree(settingsDir, path.join(activeProfile, 'settings')); picked.push('settings/') }
2547
+ let version = ''
2548
+ try { version = String(readJson(packageFile, {})?.version ?? '') } catch { version = '' }
2549
+ const meta = mirrorManager.readMeta()
2550
+ mirrorManager.writeMeta({
2551
+ ...meta,
2552
+ version: version || meta.version || 'unknown',
2553
+ installedAt: nowMs(),
2554
+ lastBackupAt: backup?.createdAt ?? meta.lastBackupAt,
2555
+ })
2556
+ writeJsonAtomic(path.join(mirrorRoot, 'sync-state.json'), { lastSyncAt: nowMs(), sourceDir })
2557
+ return {
2558
+ ok: true,
2559
+ sourceDir,
2560
+ target: activeProfile,
2561
+ files: picked,
2562
+ version: version || meta.version || 'unknown',
2563
+ backup: backup ? { id: backup.id, bucket: backup.bucket, createdAt: backup.createdAt } : null,
2564
+ }
2565
+ },
2566
+ /** 空白母体(初始盘):备份当前 → 清空 active/profile → 版本标记 blank。 */
2567
+ blankMother: () => {
2568
+ const activeProfile = path.join(mirrorRoot, 'active', 'profile')
2569
+ let backup = null
2570
+ try { backup = mirrorManager.backup({ label: '空白母体前自动备份' }) } catch { /* 首次为空 */ }
2571
+ if (exists(activeProfile)) removeTree(activeProfile)
2572
+ ensureDir(activeProfile)
2573
+ const meta = mirrorManager.readMeta()
2574
+ mirrorManager.writeMeta({
2575
+ ...meta,
2576
+ version: 'blank',
2577
+ installedAt: nowMs(),
2578
+ lastBackupAt: backup?.createdAt ?? meta.lastBackupAt,
2579
+ })
2580
+ return {
2581
+ ok: true,
2582
+ version: 'blank',
2583
+ target: activeProfile,
2584
+ backup: backup ? { id: backup.id, bucket: backup.bucket, createdAt: backup.createdAt } : null,
2585
+ }
2586
+ },
2587
+ // v3.0.9 升级链路 3:母体变化后,元主按 overlay 重放到新镜像(配置不动)。
2588
+ syncEssences: async (input = {}) => {
2589
+ const essenceId = String(input?.essenceId ?? '').trim()
2590
+ if (!essenceId) {
2591
+ const out = await essenceManager.recomputeAll({ concurrency: Number(input?.concurrency) || 2 })
2592
+ // recomputeAll 返回的是 id 数组(recomputed/skipped/conflicted/failed),不是计数。
2593
+ const conflictedCount = Array.isArray(out?.conflicted) ? out.conflicted.length : (Number(out?.conflicted) || 0)
2594
+ const failedCount = Array.isArray(out?.failed) ? out.failed.length : (Number(out?.failed) || 0)
2595
+ // EssenceManager:批量重算出现冲突/失败时,必须变成人类可见的 ticket。
2596
+ if (conflictedCount > 0 || failedCount > 0) {
2597
+ const conflicts = Array.from({ length: conflictedCount }, () => ({ kind: 'recompute-conflict', severity: 'warning' }))
2598
+ await businessProducers.essenceConflict({
2599
+ essenceId: 'all', conflicts,
2600
+ severity: failedCount > 0 ? 'fatal' : 'warning',
2601
+ })
2602
+ }
2603
+ return { ok: true, mode: 'all', ...out }
2604
+ }
2605
+ const meta = essenceManager.getMeta(essenceId)
2606
+ if (meta.locked === true) return { ok: false, error: 'locked', essenceId }
2607
+ if (meta.archived === true) return { ok: false, error: 'archived', essenceId }
2608
+ const fresh = await essenceManager.ensureFresh(essenceId)
2609
+ return {
2610
+ ok: true, mode: 'one', essenceId,
2611
+ recomputed: fresh.recomputed === true,
2612
+ status: fresh.meta?.status ?? 'unknown',
2613
+ }
2614
+ },
2615
+ backup: (input = {}) => mirrorManager.backup({
2616
+ label: input?.label, bucket: input?.bucket === 'restore' ? 'restore' : 'backup',
2617
+ }),
2618
+ restore: (backupId) => mirrorManager.restore(backupId),
2619
+ deleteBackup: (backupId) => mirrorManager.deleteBackup(backupId),
2620
+ setBackupInterval: (hours) => mirrorManager.setBackupInterval(hours),
2621
+ checkUpdate: () => mirrorManager.checkUpdate(),
2622
+ recomputeStatus: () => upgradeCoordinator.getRecomputeStatus(),
2623
+ compatStatus: () => readCompatStatus(layout.dataDir),
2624
+ runCompatCheck,
2625
+ upgrade: async (input = {}) => {
2626
+ const emitStage = (stage) => {
2627
+ try { bus.emit('fang/mirror-upgrade-stage', { stage, at: Date.now() }) } catch { /* ignore */ }
2628
+ try { audit('mirror-upgrade-stage', { stage }) } catch { /* ignore */ }
2629
+ }
2630
+ try {
2631
+ const out = await mirrorManager.upgrade({
2632
+ autoBackup: input?.autoBackup !== false,
2633
+ autoRollback: input?.autoRollback !== false,
2634
+ onProgress: emitStage,
2635
+ })
2636
+ if (out?.ok === false) {
2637
+ await businessProducers.mirrorUpgradeFailed({
2638
+ from: out.from, to: out.to,
2639
+ reason: String(out?.error ?? out?.reason ?? 'unknown'),
2640
+ })
2641
+ } else if (out?.reason === 'compat-gate-failed') {
2642
+ const findings = Array.isArray(out?.compat?.findings) ? out.compat.findings.length : 0
2643
+ raiseDecisionTicket({
2644
+ level: 'yellow',
2645
+ title: '镜像升级后兼容门禁未通过',
2646
+ context: `镜像已切到 ${String(out?.to ?? '新版本')},但 compat 门禁未通过(findings=${findings}),子插件保持关闭。`,
2647
+ options: [{ value: 'retry', label: '重试升级' }, { value: 'ignore', label: '暂不处理' }],
2648
+ dedupeKey: 'mirror-upgrade:compat-gate-failed',
2649
+ sourceName: 'MirrorUpgrade',
2650
+ })
2651
+ } else if (out?.ok === true && out?.to && out.from !== out.to && !out.reason) {
2652
+ // 升级成功:新镜像要重启宿主才完全生效 → 红色 restart ticket(动作 confirm_restart)。
2653
+ await businessProducers.mirrorUpgradeRestartRequired({ from: out.from, to: out.to })
2654
+ }
2655
+ return out
2656
+ } catch (error) {
2657
+ await businessProducers.mirrorUpgradeFailed({
2658
+ from: 'unknown', reason: String(error?.message ?? error), level: 'red',
2659
+ })
2660
+ throw error
2661
+ }
2662
+ },
2663
+ }
2664
+ ctx.effect(() => ctx.provide(SERVICE.mirror, mirrorService))
2665
+
2666
+ // 特殊动作执行钩子:confirm_restart 不再只落库,交给 restart-guard 的 request
2667
+ // (它自己的审批/三重门禁/外部 supervisor 仍然生效;决策中心只负责触发)。
2668
+ try {
2669
+ const decisionCenterService = safeGet(SERVICE.decisionCenter)
2670
+ if (decisionCenterService && typeof decisionCenterService.registerActionHandler === 'function') {
2671
+ const unregisterAction = decisionCenterService.registerActionHandler('confirm_restart', async ({ ticket, payload } = {}) => {
2672
+ const restart = safeGet(SERVICE.restart)
2673
+ if (!restart || typeof restart.request !== 'function') return { ok: false, reason: 'restart-unavailable' }
2674
+ const input = payload && typeof payload === 'object' ? payload : {}
2675
+ return restart.request({
2676
+ reason: input.reason ?? `decision:${ticket?.id ?? 'unknown'}`,
2677
+ risk: input.risk ?? 'yellow',
2678
+ scope: input.scope ?? 'mother',
2679
+ origin: 'decision-center',
2680
+ digests: Array.isArray(input.digests) ? input.digests : undefined,
2681
+ forcedApproval: input.forcedApproval === true,
2682
+ })
2683
+ })
2684
+ ctx.effect(() => () => { try { unregisterAction?.() } catch { /* ignore */ } })
2685
+ }
2686
+ } catch { /* decision center optional */ }
2687
+ // 启动后自检一次;兼容状态默认 subPluginsEnabled=false,直到门禁通过
2688
+ const compatBootTimer = setTimeout(() => { void runCompatCheck() }, 1500)
2689
+ compatBootTimer.unref?.()
2690
+ ctx.effect(() => () => clearTimeout(compatBootTimer))
2691
+ const mirrorAutoTimer = setInterval(() => {
2692
+ try {
2693
+ const status = mirrorService.status()
2694
+ if (status.meta?.nextBackupAt !== null && Date.now() >= status.meta.nextBackupAt) {
2695
+ mirrorManager.maybeAutoBackup()
2696
+ }
2697
+ // 母体自动同步:按偏好 6/12/24h 从本机 profile 克隆到镜像(关闭时不跑)。
2698
+ const prefs = preferencesStore.get()
2699
+ const syncHours = Number(prefs?.syncIntervalHours)
2700
+ if (Number.isFinite(syncHours) && syncHours > 0) {
2701
+ let last = 0
2702
+ try { last = Number(readJson(path.join(mirrorRoot, 'sync-state.json'), {})?.lastSyncAt) || 0 } catch { last = 0 }
2703
+ if (Date.now() - last >= syncHours * 3_600_000) {
2704
+ try {
2705
+ mirrorService.cloneMotherProfile({})
2706
+ ctx.logger?.info?.('[mirror] auto-sync completed', { hours: syncHours })
2707
+ } catch (error) {
2708
+ ctx.logger?.warn?.('[mirror] auto-sync failed', { error: String(error?.message ?? error) })
2709
+ }
2710
+ }
2711
+ }
2712
+ } catch (error) {
2713
+ ctx.logger?.warn?.('[mirror] auto backup tick failed', { error: String(error?.message ?? error) })
2714
+ }
2715
+ }, 60_000)
2716
+ mirrorAutoTimer.unref?.()
2717
+ ctx.effect(() => () => clearInterval(mirrorAutoTimer))
2718
+
2719
+ // ── 启动自检 ───────────────────────────────────────────────────────────
2720
+ const bootState = { at: nowMs(), ok: null, findings: [] }
2721
+ const bootAuditTimer = setTimeout(() => {
2722
+ try {
2723
+ const auditor = safeGet(SERVICE.auditor)
2724
+ const check = auditor?.bootCheck?.() ?? auditor?.bootAudit?.()
2725
+ bootState.ok = check?.ok !== false
2726
+ bootState.findings = Array.isArray(check?.findings) ? check.findings : []
2727
+ audit(AUDIT.BOOT, { ok: bootState.ok, findings: bootState.findings })
2728
+ } catch (e) {
2729
+ bootState.ok = false
2730
+ bootState.findings = [{ id: 'boot-audit-threw', level: 'error', message: String(e?.message ?? e) }]
2731
+ }
2732
+ }, 1000)
2733
+ ctx.effect(() => () => clearTimeout(bootAuditTimer))
2734
+
2735
+ // ── 启动时把注册表里已经死掉的任务标成 stopped(不假装还活着)───────────────
2736
+ const refreshStale = () => {
2737
+ let changed = false
2738
+ for (const t of tasks.values()) {
2739
+ const live = forker()?.status?.(t.taskId)
2740
+ if (live && !live.running && (t.status === TASK_STATUS.RUNNING || t.status === TASK_STATUS.READY)) {
2741
+ t.status = TASK_STATUS.FAILED
2742
+ changed = true
2743
+ }
2744
+ }
2745
+ if (changed) persist()
2746
+ }
2747
+ const bootTimer = setTimeout(() => { try { refreshStale() } catch { /* ignore */ } }, 2000)
2748
+ ctx.effect(() => () => clearTimeout(bootTimer))
2749
+
2750
+ // ── 可选:空闲自动归档 ───────────────────────────────────────────────────
2751
+ if (cfg.autoReapIdleMinutes > 0) {
2752
+ const t = setInterval(() => {
2753
+ const idleMs = cfg.autoReapIdleMinutes * 60_000
2754
+ for (const task of [...tasks.values()]) {
2755
+ if (nowMs() - (task.lastActiveAt ?? task.createdAt) > idleMs) {
2756
+ service.reap(task.taskId).catch(() => { /* best effort */ })
2757
+ }
2758
+ }
2759
+ }, 60_000)
2760
+ ctx.effect(() => () => clearInterval(t))
2761
+ }
2762
+
2763
+ // v3.0.9:连接信息要的宿主服务(只有 Web 运行时才有;分身/无 Web 环境允许缺失)。
2764
+ // 必须用 ctx.inject 取——直接 child.get 在插件上下文里拿不到这两个服务。
2765
+ let connectionService = null
2766
+ let webAppService = null
2767
+ ctx.inject(['connection'], (child) => {
2768
+ try { connectionService = child.get('connection') ?? child.connection ?? null } catch { connectionService = null }
2769
+ })
2770
+ ctx.inject(['webApp'], (child) => {
2771
+ try { webAppService = child.get('webApp') ?? child.webApp ?? null } catch { webAppService = null }
2772
+ })
2773
+
2774
+ // ── HTTP 门户 ──────────────────────────────────────────────────────────
2775
+ if (cfg.routes?.enabled !== false) {
2776
+ ctx.inject(['webServer'], (child) => {
2777
+ const webServer = child.get('webServer') ?? child.webServer
2778
+ if (!webServer || typeof webServer.register !== 'function') return
2779
+ compatWebServer = webServer
2780
+ // v3.0.8:把当前宿主 origin 写到 dataDir/endpoint.json,供 Electron 面板发现,
2781
+ // 避免桌面宿主端口每次变化后必须手工设置 FANG_UPSTREAM。
2782
+ try {
2783
+ const origin = typeof webServer.getOrigin === 'function' ? webServer.getOrigin() : ''
2784
+ if (origin) {
2785
+ fs.mkdirSync(layout.dataDir, { recursive: true })
2786
+ fs.writeFileSync(path.join(layout.dataDir, 'endpoint.json'), JSON.stringify({ url: origin, updatedAt: new Date().toISOString() }) + '\n')
2787
+ }
2788
+ } catch { /* endpoint discovery is best-effort */ }
2789
+ const prefix = String(cfg.routes?.prefix ?? '/fang').replace(/\/+$/, '') || '/fang'
2790
+
2791
+ const json = (res, code, obj) => {
2792
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
2793
+ res.end(JSON.stringify(obj))
2794
+ }
2795
+ // v3.3.3: queue journal route gate (json is in this scope).
2796
+ const queueJournalGate = (res) => {
2797
+ if (queueJournalEnabled()) return false
2798
+ json(res, 503, { ok: false, error: 'queue-journal-disabled', detail: 'enable the fang-queue-journal sub-plugin to use composer journal/queue recovery' })
2799
+ return true
2800
+ }
2801
+ const readBody = (req) => new Promise((resolve) => {
2802
+ let raw = ''
2803
+ req.on('data', (c) => { raw += c; if (raw.length > 1_000_000) req.destroy() })
2804
+ req.on('end', () => {
2805
+ try { resolve(raw ? JSON.parse(raw) : {}) } catch { resolve({}) }
2806
+ })
2807
+ req.on('error', () => resolve({}))
2808
+ })
2809
+ const route = (method, pathname, handler, routeOptions) => webServer.register({
2810
+ kind: 'exact',
2811
+ path: pathname,
2812
+ method,
2813
+ handler: async (req, res) => {
2814
+ // v3.0.5/B1:loopback Host + Origin/CSRF + 安装 token(fail-closed)。
2815
+ // Auth must run before the method check: a token-less POST is 401, not 405.
2816
+ const decision = checkRequestAuth(req, {
2817
+ token: authToken,
2818
+ publicRoute: authToken.length === 0 || routeOptions?.public === true,
2819
+ })
2820
+ if (!decision.ok) return json(res, decision.status, authErrorBody(decision))
2821
+ if (req.method !== method) {
2822
+ try { res.setHeader?.('allow', method) } catch { /* fake res */ }
2823
+ return json(res, 405, { ok: false, error: 'method-not-allowed', code: 'method-not-allowed' })
2824
+ }
2825
+ try { return await handler(req, res) } catch (e) {
2826
+ return json(res, 500, { ok: false, error: 'handler-threw', code: 'handler-threw', detail: String(e?.message ?? e) })
2827
+ }
2828
+ },
2829
+ })
2830
+
2831
+ // The DSH webServer keys exact routes by path only, so GET+POST on the same
2832
+ // path cannot be two `register()` calls (the second throws and later routes
2833
+ // silently never register). One handler dispatches by method instead.
2834
+ const routeMethods = (pathname, handlers, routeOptions) => webServer.register({
2835
+ kind: 'exact',
2836
+ path: pathname,
2837
+ method: Object.keys(handlers).join(','),
2838
+ handler: async (req, res) => {
2839
+ const decision = checkRequestAuth(req, {
2840
+ token: authToken,
2841
+ publicRoute: authToken.length === 0 || routeOptions?.public === true,
2842
+ })
2843
+ if (!decision.ok) return json(res, decision.status, authErrorBody(decision))
2844
+ const allow = Object.keys(handlers)
2845
+ const fn = handlers[String(req.method ?? 'GET').toUpperCase()]
2846
+ if (typeof fn !== 'function') {
2847
+ try { res.setHeader?.('allow', allow.join(', ')) } catch { /* fake res */ }
2848
+ return json(res, 405, { ok: false, error: 'method-not-allowed', code: 'method-not-allowed' })
2849
+ }
2850
+ try { return await fn(req, res) } catch (e) {
2851
+ return json(res, 500, { ok: false, error: 'handler-threw', code: 'handler-threw', detail: String(e?.message ?? e) })
2852
+ }
2853
+ },
2854
+ })
2855
+
2856
+ // ── 分支路由共用:taskId 必须是注册表里的安全 id ─────────────────────
2857
+ // 既保证 404 语义一致(tasks/status 对未知任务返回 404),也阻断把
2858
+ // `../` 之类的 taskId 带进 BranchStore.file() 的路径逃逸。
2859
+ const safeTaskId = (value) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(String(value ?? ''))
2860
+ const requireTask = (value) => {
2861
+ const taskId = String(value ?? '').trim()
2862
+ if (!taskId) return { ok: false, status: 400, error: 'taskId-required' }
2863
+ if (!safeTaskId(taskId) || !tasks.has(taskId)) return { ok: false, status: 404, error: 'unknown-task' }
2864
+ return { ok: true, taskId }
2865
+ }
2866
+ const branchErrorStatus = (code) => {
2867
+ switch (String(code ?? '')) {
2868
+ case 'invalid-branch-id':
2869
+ case 'invalid-status':
2870
+ case 'invalid-task-id':
2871
+ return 400
2872
+ case 'branch-missing':
2873
+ case 'parent-missing':
2874
+ return 404
2875
+ case 'branch-exists':
2876
+ case 'branch-not-active':
2877
+ case 'cannot-seal-active':
2878
+ return 409
2879
+ default:
2880
+ return 500
2881
+ }
2882
+ }
2883
+
2884
+ // 浏览器面板 bootstrap:同源读取 token(Host/Origin 已校验;不跨站)
2885
+ route('GET', `${prefix}/api/auth-token`, (req, res) => json(res, 200, {
2886
+ ok: true, token: authToken,
2887
+ }), { public: true })
2888
+
2889
+ // 母体不可删除:即使有人直接打接口也返回 403,而不是“没有这个路由”。
2890
+ // 仅 POST(allow-list 只允许 GET/POST;DELETE 语义由该 403 表达)。
2891
+ route('POST', `${prefix}/api/mother/delete`, (req, res) => json(res, 403, { ok: false, error: 'mother-immutable' }))
2892
+
2893
+ // 完整连接地址(带进程 token):Web 端「连接信息」用它,不再依赖桌面壳。
2894
+ // 桌面壳缺失时也能给出可复制的真实登录地址;拿不到 token 时如实标 tokenized:false。
2895
+ route('GET', `${prefix}/api/connection`, (req, res) => {
2896
+ const advertised = (() => { try { return String(webAppService?.getAdvertisedUrl?.() ?? '') } catch { return '' } })()
2897
+ const origin = (() => { try { return String(webServer.getOrigin?.() ?? '') } catch { return '' } })()
2898
+ const base = advertised || origin
2899
+ let url = base
2900
+ let tokenized = false
2901
+ try {
2902
+ if (base && typeof connectionService?.authenticatedUrl === 'function') {
2903
+ const next = connectionService.authenticatedUrl(base)
2904
+ if (next) { url = String(next); tokenized = url !== base }
2905
+ }
2906
+ } catch { /* 无 connection 服务时退回裸地址,不假装成功 */ }
2907
+ return json(res, 200, {
2908
+ ok: true, url, origin, advertisedUrl: advertised, tokenized, available: Boolean(url),
2909
+ })
2910
+ })
2911
+
2912
+ route('GET', `${prefix}/api/status`, (req, res) => json(res, 200, service.status()))
2913
+ // v3.1.15:fang-master 契约端点(HTTP fallback;token 未配置时禁用)。
2914
+ if (contractCfg.enabled !== false) {
2915
+ const contractPath = String(contractCfg.pathPrefix || `${prefix}/api/contract`).replace(/\/+$/, '')
2916
+ const contractAuthorized = (req) => {
2917
+ const token = String(contractCfg.token ?? '')
2918
+ if (!token) return false
2919
+ const match = /^Bearer\s+(\S+)$/i.exec(String(req?.headers?.authorization ?? '').trim())
2920
+ if (!match) return false
2921
+ const a = Buffer.from(match[1], 'utf8')
2922
+ const b = Buffer.from(token, 'utf8')
2923
+ return a.length === b.length && crypto.timingSafeEqual(a, b)
2924
+ }
2925
+ route('POST', `${contractPath}/hello`, async (req, res) => {
2926
+ if (!contractCfg.token) return json(res, 503, { ok: false, code: 'contract-token-required' })
2927
+ if (!contractAuthorized(req)) return json(res, 401, { ok: false, code: 'contract-unauthorized' })
2928
+ return json(res, 200, contractServer.hello())
2929
+ })
2930
+ route('POST', `${contractPath}/invoke`, async (req, res) => {
2931
+ if (!contractCfg.token) return json(res, 503, { ok: false, code: 'contract-token-required' })
2932
+ if (!contractAuthorized(req)) return json(res, 401, { ok: false, code: 'contract-unauthorized' })
2933
+ const body = await readBody(req)
2934
+ if (!body || typeof body !== 'object') return json(res, 400, { ok: false, code: 'invalid-envelope' })
2935
+ const out = await contractServer.invoke(body)
2936
+ return json(res, 200, out)
2937
+ })
2938
+ route('GET', `${contractPath}/health`, (req, res) => json(res, 200, {
2939
+ ok: true, contractVersion: CONTRACT_VERSION, role: gate.role, methods: contractServer.hello().methods,
2940
+ // v3.1.35:回流计数(排障用;只读状态,不暴露可写 store)
2941
+ relay: (() => {
2942
+ try {
2943
+ const s = relayStore.status()
2944
+ return { peer: s?.peer ?? null, cursor: s?.cursor ?? 0, accepted: s?.accepted ?? 0, duplicates: s?.duplicates ?? 0, rejected: s?.rejected ?? 0, lastAt: s?.lastAt ?? null }
2945
+ } catch { return null }
2946
+ })(),
2947
+ }))
2948
+ }
2949
+ // 重启守卫 / supervisor 契约(只读)。不触发重启;写操作仍只走审批 + 外部 supervisor。
2950
+ route('GET', `${prefix}/api/restart`, (req, res) => {
2951
+ const view = restartView()
2952
+ return json(res, 200, { ok: view.available === true && !view.error, ...view })
2953
+ })
2954
+ route('GET', `${prefix}/api/restart/selfcheck`, (req, res) => {
2955
+ const guard = safeGet(SERVICE.restart)
2956
+ if (!guard || typeof guard.selfCheck !== 'function') return json(res, 503, { ok: false, error: 'restart-guard-unavailable' })
2957
+ return json(res, 200, guard.selfCheck())
2958
+ })
2959
+ route('GET', `${prefix}/api/upgrade/status`, (req, res) => {
2960
+ let mirror = null
2961
+ let essences = []
2962
+ let compat = null
2963
+ try { mirror = mirrorService.status() } catch { /* empty */ }
2964
+ try { essences = mirrorService.listEssences()?.essences ?? [] } catch { /* empty */ }
2965
+ try { compat = readCompatStatus(layout.dataDir) } catch { /* empty */ }
2966
+ let failedTasks = 0
2967
+ try { failedTasks = [...tasks.values()].filter(task => task.status === TASK_STATUS.FAILED).length } catch { /* empty */ }
2968
+ return json(res, 200, buildUpgradeStatus({ version: FANG_VERSION, mirror, essences, compat, tasks: { failed: failedTasks } }))
2969
+ })
2970
+ route('GET', `${prefix}/api/tasks`, (req, res) => json(res, 200, { ok: true, tasks: service.listTasks() }))
2971
+
2972
+ // Decision center (瓮城·决策中心): one inbox for human attention. The plugin
2973
+ // owns tickets; this route is only the authenticated read/write surface.
2974
+ route('GET', `${prefix}/api/decisions`, (req, res) => {
2975
+ const center = safeGet(SERVICE.decisionCenter)
2976
+ if (!center || typeof center.list !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
2977
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
2978
+ const out = center.list({
2979
+ includeArchived: query.get('includeArchived') === '1' || query.get('includeArchived') === 'true',
2980
+ since: Number(query.get('since')) || 0,
2981
+ })
2982
+ return json(res, 200, { ok: true, ...out })
2983
+ })
2984
+ route('GET', `${prefix}/api/decisions/quota`, (req, res) => {
2985
+ const center = safeGet(SERVICE.decisionCenter)
2986
+ if (!center || typeof center.quotaStatus !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
2987
+ return json(res, 200, center.quotaStatus())
2988
+ })
2989
+ route('GET', `${prefix}/api/decisions/item`, (req, res) => {
2990
+ const center = safeGet(SERVICE.decisionCenter)
2991
+ if (!center || typeof center.get !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
2992
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
2993
+ const ticket = center.get(String(query.get('id') ?? ''))
2994
+ return ticket ? json(res, 200, { ok: true, ticket }) : json(res, 404, { ok: false, error: 'not-found' })
2995
+ })
2996
+ route('GET', `${prefix}/api/decisions/forks`, (req, res) => {
2997
+ const center = safeGet(SERVICE.decisionCenter)
2998
+ if (!center || typeof center.forkAggregation !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
2999
+ return json(res, 200, center.forkAggregation())
3000
+ })
3001
+ route('GET', `${prefix}/api/decisions/producers`, (req, res) => {
3002
+ const center = safeGet(SERVICE.decisionCenter)
3003
+ if (!center || typeof center.producerCatalog !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3004
+ return json(res, 200, center.producerCatalog())
3005
+ })
3006
+ // 外部生产者桥(PM/PJM/Worker/HarnessMM/PluginUpdater/SuperAgent):
3007
+ // 只做带鉴权的 ingest;ticket 来源标记 external:<producer>,roleTrusted=false。
3008
+ route('POST', `${prefix}/api/decisions/ingest`, async (req, res) => {
3009
+ const center = safeGet(SERVICE.decisionCenter)
3010
+ if (!center || typeof center.ingest !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3011
+ const body = await readBody(req)
3012
+ const out = center.ingest(body && typeof body === 'object' ? body : {})
3013
+ const status = out.ok ? 200
3014
+ : out.reason === 'title-required' || out.reason === 'options-required' || out.reason === 'gray-is-terminal' ? 400
3015
+ : 409
3016
+ return json(res, status, out)
3017
+ })
3018
+ // 决策中心实时推送:SSE。bus 事件 → 一条已鉴权流;无 SSE 时客户端仍可轮询。
3019
+ const decisionStreams = new Set()
3020
+ route('GET', `${prefix}/api/decisions/events`, (req, res) => {
3021
+ const center = safeGet(SERVICE.decisionCenter)
3022
+ if (!center || typeof center.list !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3023
+ if (decisionStreams.size >= 20) return json(res, 429, { ok: false, error: 'too-many-event-streams' })
3024
+ const stream = openDecisionEventStream({ req, res, bus, center })
3025
+ decisionStreams.add(stream)
3026
+ const cleanup = () => {
3027
+ if (!decisionStreams.delete(stream)) return
3028
+ try { stream.close() } catch { /* ignore */ }
3029
+ }
3030
+ try { req.on?.('close', cleanup) } catch { /* ignore */ }
3031
+ try { res.on?.('close', cleanup) } catch { /* ignore */ }
3032
+ return undefined
3033
+ })
3034
+ try { child.effect?.(() => () => { for (const stream of decisionStreams) { try { stream.close() } catch { /* ignore */ } } decisionStreams.clear() }) } catch { /* fake webServer in tests */ }
3035
+ route('POST', `${prefix}/api/decisions/resolve`, async (req, res) => {
3036
+ const body = await readBody(req)
3037
+ const center = safeGet(SERVICE.decisionCenter)
3038
+ if (!center || typeof center.resolve !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3039
+ if (Array.isArray(body?.ids)) {
3040
+ if (typeof center.resolveMany !== 'function') return json(res, 503, { ok: false, error: 'decision-center-batch-unavailable' })
3041
+ const out = center.resolveMany(body.ids, { option: body?.option, by: body?.by ?? 'fang-api', note: body?.note })
3042
+ return json(res, 200, out)
3043
+ }
3044
+ const resolveOptions = { by: body?.by ?? 'fang-api', note: body?.note }
3045
+ // v3.1.13:只有显式传 custom 时才把字段交给服务层;undefined 字段不进
3046
+ // options(旧调用方/测试对 {by,note} 的形状有断言)。
3047
+ if (body?.custom !== undefined) resolveOptions.custom = body.custom
3048
+ const out = (body?.actionId !== undefined && typeof center.resolveAction === 'function')
3049
+ ? center.resolveAction(body?.id, body.actionId, body?.payload, resolveOptions)
3050
+ : center.resolve(body?.id, body?.option, resolveOptions)
3051
+ const status = out.ok ? 200
3052
+ : out.reason === 'not-found' ? 404
3053
+ : out.reason === 'option-required' || out.reason === 'unknown-option' ? 400
3054
+ : 409
3055
+ return json(res, status, out)
3056
+ })
3057
+ route('POST', `${prefix}/api/decisions/abandon`, async (req, res) => {
3058
+ const body = await readBody(req)
3059
+ const center = safeGet(SERVICE.decisionCenter)
3060
+ if (!center || typeof center.abandon !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3061
+ if (Array.isArray(body?.ids)) {
3062
+ if (typeof center.abandonMany !== 'function') return json(res, 503, { ok: false, error: 'decision-center-batch-unavailable' })
3063
+ const out = center.abandonMany(body.ids, { by: body?.by ?? 'fang-api', note: body?.note })
3064
+ return json(res, 200, out)
3065
+ }
3066
+ const out = center.abandon(body?.id, { by: body?.by ?? 'fang-api', note: body?.note })
3067
+ return json(res, out.ok ? 200 : (out.reason === 'not-found' ? 404 : 409), out)
3068
+ })
3069
+
3070
+ route('GET', `${prefix}/api/decisions/ai`, (req, res) => {
3071
+ const center = safeGet(SERVICE.decisionCenter)
3072
+ if (!center || typeof center.aiStatus !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3073
+ return json(res, 200, { ok: true, ...center.aiStatus() })
3074
+ })
3075
+ route('POST', `${prefix}/api/decisions/ai/test`, async (req, res) => {
3076
+ const body = await readBody(req)
3077
+ const center = safeGet(SERVICE.decisionCenter)
3078
+ if (!center || typeof center.testAi !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3079
+ const out = await center.testAi(body ?? {})
3080
+ return json(res, out.ok ? 200 : (out.status === 429 ? 429 : 400), out)
3081
+ })
3082
+ route('POST', `${prefix}/api/decisions/ai/config`, async (req, res) => {
3083
+ const body = await readBody(req)
3084
+ const center = safeGet(SERVICE.decisionCenter)
3085
+ if (!center || typeof center.configureAi !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3086
+ const out = center.configureAi(body ?? {})
3087
+ return json(res, out.ok ? 200 : 400, out)
3088
+ })
3089
+ route('POST', `${prefix}/api/decisions/ai/advise/stream`, async (req, res) => {
3090
+ const body = await readBody(req)
3091
+ const center = safeGet(SERVICE.decisionCenter)
3092
+ if (!center || typeof center.advise !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3093
+ res.writeHead(200, {
3094
+ 'content-type': 'text/event-stream; charset=utf-8',
3095
+ 'cache-control': 'no-store',
3096
+ 'x-accel-buffering': 'no',
3097
+ connection: 'keep-alive',
3098
+ })
3099
+ try { res.flushHeaders?.() } catch { /* fake res */ }
3100
+ let closed = false
3101
+ let wrote = 0
3102
+ const send = (event, data) => {
3103
+ if (closed) return false
3104
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); wrote += 1; return true } catch { return false }
3105
+ }
3106
+ try {
3107
+ send('start', { id: String(body?.id ?? '') })
3108
+ const out = typeof center.adviseStream === 'function'
3109
+ ? await center.adviseStream(body?.id, (delta, text) => { send('delta', { delta, text }) })
3110
+ : await center.advise(body?.id)
3111
+ send(out.ok ? 'done' : 'error', out.ok ? { advice: out.advice, ticket: out.ticket } : out)
3112
+ } catch (error) {
3113
+ send('error', { ok: false, error: String(error?.message ?? error) })
3114
+ } finally {
3115
+ closed = true
3116
+ try { res.end() } catch { /* ignore */ }
3117
+ }
3118
+ })
3119
+ route('POST', `${prefix}/api/decisions/ai/advise`, async (req, res) => {
3120
+ const body = await readBody(req)
3121
+ const center = safeGet(SERVICE.decisionCenter)
3122
+ if (!center || typeof center.advise !== 'function') return json(res, 503, { ok: false, error: 'decision-center-unavailable' })
3123
+ const out = await center.advise(body?.id)
3124
+ const limited = out?.reason === 'ai-rate-limited' || out?.reason === 'ai-circuit-open' || out?.reason === 'ai-daily-quota-exceeded'
3125
+ if (limited && Number.isFinite(out?.retryAfterMs) && out.retryAfterMs > 0) {
3126
+ try { res.setHeader?.('retry-after', String(Math.ceil(out.retryAfterMs / 1000))) } catch { /* fake res */ }
3127
+ }
3128
+ return json(res, out.ok ? 200 : (out.reason === 'not-found' ? 404 : limited ? 429 : 409), out)
3129
+ })
3130
+
3131
+ // Mother-side approval aggregation: local approver + every live fork adapter.
3132
+ // A failed adapter is reported as degraded, never as an empty approval list.
3133
+ route('GET', `${prefix}/api/approvals`, async (req, res) => {
3134
+ const items = []
3135
+ const failed = []
3136
+ const approver = safeGet(SERVICE.approver)
3137
+ try {
3138
+ if (approver && typeof approver.pending === 'function') {
3139
+ for (const item of approver.pending() ?? []) {
3140
+ items.push({
3141
+ source: 'mother',
3142
+ taskId: null,
3143
+ approvalId: String(item?.id ?? ''),
3144
+ title: String(item?.title ?? ''),
3145
+ risk: String(item?.risk ?? 'unknown'),
3146
+ kind: String(item?.kind ?? 'unknown'),
3147
+ state: item?.state ?? 'pending',
3148
+ createdAt: item?.createdAt ?? null,
3149
+ deadlineAt: item?.deadlineAt ?? null,
3150
+ adapterOk: true,
3151
+ })
3152
+ }
3153
+ if (typeof approver.orphaned === 'function') {
3154
+ for (const item of approver.orphaned() ?? []) {
3155
+ items.push({
3156
+ source: 'mother',
3157
+ taskId: null,
3158
+ approvalId: String(item?.id ?? ''),
3159
+ title: String(item?.title ?? ''),
3160
+ risk: String(item?.risk ?? 'unknown'),
3161
+ kind: String(item?.kind ?? 'unknown'),
3162
+ state: 'orphaned',
3163
+ createdAt: item?.createdAt ?? null,
3164
+ deadlineAt: item?.deadlineAt ?? null,
3165
+ adapterOk: true,
3166
+ })
3167
+ }
3168
+ }
3169
+ } else {
3170
+ failed.push({ taskId: null, error: 'approver-unavailable' })
3171
+ }
3172
+ } catch (error) {
3173
+ failed.push({ taskId: null, error: String(error?.message ?? error) })
3174
+ }
3175
+
3176
+ const forkResults = await Promise.all([...tasks.values()]
3177
+ .filter((task) => task?.url)
3178
+ .map(async (task) => {
3179
+ if (task.adapter && task.adapter.ok === false) {
3180
+ return { task, ok: false, error: task.adapter.error ?? 'adapter-unavailable' }
3181
+ }
3182
+ const client = await remoteFor(task)
3183
+ if (!client) return { task, ok: false, error: 'remote-unavailable' }
3184
+ const result = await client.command(ADAPTER_OP.APPROVALS, {})
3185
+ const data = result?.data?.result ?? result?.data ?? {}
3186
+ if (!result?.ok || data?.ok === false) {
3187
+ return { task, ok: false, error: data?.error ?? result?.error ?? 'adapter-failed' }
3188
+ }
3189
+ const list = Array.isArray(data?.items) ? data.items
3190
+ : Array.isArray(data?.result?.items) ? data.result.items
3191
+ : []
3192
+ return { task, ok: true, items: list }
3193
+ }))
3194
+ for (const result of forkResults) {
3195
+ if (!result.ok) {
3196
+ failed.push({ taskId: result.task.taskId, error: result.error })
3197
+ continue
3198
+ }
3199
+ for (const item of result.items) {
3200
+ items.push({
3201
+ source: 'fork',
3202
+ taskId: result.task.taskId,
3203
+ approvalId: String(item?.id ?? item?.approvalId ?? ''),
3204
+ title: String(item?.title ?? ''),
3205
+ risk: String(item?.risk ?? 'unknown'),
3206
+ kind: String(item?.kind ?? 'unknown'),
3207
+ state: item?.state ?? 'pending',
3208
+ createdAt: item?.createdAt ?? null,
3209
+ deadlineAt: item?.deadlineAt ?? null,
3210
+ adapterOk: true,
3211
+ })
3212
+ }
3213
+ }
3214
+ items.sort((a, b) => (Number(a.deadlineAt) || Number.MAX_SAFE_INTEGER) - (Number(b.deadlineAt) || Number.MAX_SAFE_INTEGER))
3215
+ return json(res, 200, { ok: true, degraded: failed.length > 0, items, failed })
3216
+ })
3217
+
3218
+ // Decide one approval by source. The response is the real adapter/approver result;
3219
+ // stale or unknown approvals return 409 instead of pretending success.
3220
+ route('POST', `${prefix}/api/approvals/decide`, async (req, res) => {
3221
+ const body = await readBody(req)
3222
+ const source = String(body?.source ?? '')
3223
+ const taskId = body?.taskId === undefined || body?.taskId === null ? null : String(body.taskId)
3224
+ const approvalId = String(body?.approvalId ?? '').trim()
3225
+ const raw = body?.outcome ?? body?.decision
3226
+ const outcome = raw === 'approve' ? 'approved' : raw === 'deny' ? 'denied' : String(raw ?? '')
3227
+ const by = String(body?.by ?? 'fang-api')
3228
+ const note = body?.note === undefined || body?.note === null ? null : String(body.note)
3229
+ if (!approvalId) return json(res, 400, { ok: false, error: 'approval-id-required' })
3230
+ if (outcome !== 'approved' && outcome !== 'denied') return json(res, 400, { ok: false, error: 'bad-outcome', allowed: ['approved', 'denied'] })
3231
+
3232
+ if (source === 'mother') {
3233
+ const approver = safeGet(SERVICE.approver)
3234
+ if (!approver || typeof approver.decide !== 'function') {
3235
+ return json(res, 503, { ok: false, source, error: 'approver-unavailable' })
3236
+ }
3237
+ try {
3238
+ const result = approver.decide(approvalId, outcome, { by, note })
3239
+ if (!result || result.ok === false) {
3240
+ const stale = result?.reason === 'not-found' || result?.reason === 'not-pending'
3241
+ return json(res, stale ? 409 : 400, { ok: false, source, approvalId, error: result?.reason ?? 'decide-failed' })
3242
+ }
3243
+ audit(AUDIT.APPROVAL_DECIDED, { source, taskId: null, approvalId, outcome, by, note })
3244
+ return json(res, 200, { ok: true, source, taskId: null, approvalId, outcome, by, result })
3245
+ } catch (error) {
3246
+ return json(res, 500, { ok: false, source, approvalId, error: String(error?.message ?? error) })
3247
+ }
3248
+ }
3249
+
3250
+ if (source === 'fork') {
3251
+ if (!taskId) return json(res, 400, { ok: false, source, error: 'task-id-required' })
3252
+ const task = tasks.get(taskId)
3253
+ if (!task) return json(res, 404, { ok: false, source, taskId, error: 'unknown-task' })
3254
+ const client = await remoteFor(task)
3255
+ if (!client) return json(res, 503, { ok: false, source, taskId, error: 'remote-unavailable' })
3256
+ const result = await client.command(ADAPTER_OP.APPROVAL_DECIDE, { id: approvalId, outcome, by, note })
3257
+ const data = result?.data?.result ?? result?.data ?? {}
3258
+ if (!result?.ok || data?.ok === false) {
3259
+ const stale = data?.code === 'not-found' || data?.code === 'conflict'
3260
+ return json(res, stale ? 409 : 502, { ok: false, source, taskId, approvalId, error: data?.error ?? result?.error ?? 'adapter-failed' })
3261
+ }
3262
+ audit(AUDIT.APPROVAL_DECIDED, { source, taskId, approvalId, outcome, by, note })
3263
+ return json(res, 200, { ok: true, source, taskId, approvalId, outcome, by, result: data })
3264
+ }
3265
+
3266
+ return json(res, 400, { ok: false, error: 'bad-source', allowed: ['mother', 'fork'] })
3267
+ })
3268
+
3269
+ route('POST', `${prefix}/api/tasks/create`, async (req, res) => {
3270
+ const body = await readBody(req)
3271
+ try {
3272
+ const out = await service.createTask({ title: body?.title, template: body?.template, collaborators: body?.collaborators, essenceId: body?.essenceId, importSession: body?.importSession, seed: body?.seed })
3273
+ return json(res, out.ok ? 200 : 409, out)
3274
+ } catch (error) {
3275
+ return json(res, 500, { ok: false, error: String(error?.message ?? error) })
3276
+ }
3277
+ })
3278
+ route('GET', `${prefix}/api/tasks/status`, async (req, res) => {
3279
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3280
+ const out = await service.getTaskStatus(query.get('taskId') ?? '')
3281
+ return json(res, out.ok === false ? 404 : 200, out)
3282
+ })
3283
+ // v3.0.5/B4:打开分身时才在内存中取带 token 的 URL(不落盘、不进列表)
3284
+ route('GET', `${prefix}/api/tasks/open-url`, (req, res) => {
3285
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3286
+ const taskId = String(query.get('taskId') ?? '').trim()
3287
+ const out = taskId ? forker()?.openUrl?.(taskId) : null
3288
+ if (!out || out.ok !== true) return json(res, 404, { ok: false, error: out?.error ?? 'not-running' })
3289
+ return json(res, 200, out)
3290
+ })
3291
+ route('POST', `${prefix}/api/tasks/archive`, async (req, res) => {
3292
+ const body = await readBody(req)
3293
+ const out = await service.archiveTask(String(body?.taskId ?? ''))
3294
+ return json(res, out.ok ? 200 : 409, out)
3295
+ })
3296
+ route('POST', `${prefix}/api/tasks/restore`, async (req, res) => {
3297
+ const body = await readBody(req)
3298
+ const out = await service.restoreTask(String(body?.taskId ?? ''))
3299
+ return json(res, out.ok ? 200 : 409, out)
3300
+ })
3301
+ // v3.0.9:强制单会话(运行中拒绝;停止/归档态把多余会话隔离到 quarantine-sessions/)
3302
+ route('POST', `${prefix}/api/tasks/sessions/enforce`, async (req, res) => {
3303
+ const body = await readBody(req)
3304
+ const out = service.enforceSingleSession(String(body?.taskId ?? ''))
3305
+ return json(res, out.ok ? 200 : 409, out)
3306
+ })
3307
+ route('POST', `${prefix}/api/tasks/reset`, async (req, res) => {
3308
+ const body = await readBody(req)
3309
+ try {
3310
+ const out = await service.resetForkConfig(String(body?.taskId ?? ''))
3311
+ return json(res, out.ok ? 200 : 409, out)
3312
+ } catch (error) {
3313
+ return json(res, 500, { ok: false, error: 'reset-failed', detail: String(error?.message ?? error) })
3314
+ }
3315
+ })
3316
+ route('POST', `${prefix}/api/tasks/archive/delete`, async (req, res) => {
3317
+ const body = await readBody(req)
3318
+ const out = await service.deleteArchivedTask(String(body?.taskId ?? ''))
3319
+ return json(res, out.ok ? 200 : 409, out)
3320
+ })
3321
+ route('POST', `${prefix}/api/tasks/promote`, async (req, res) => {
3322
+ const body = await readBody(req)
3323
+ const out = await service.promoteTask(String(body?.taskId ?? ''), String(body?.pluginId ?? ''))
3324
+ return json(res, out.ok ? 200 : 409, out)
3325
+ })
3326
+
3327
+ route('POST', `${prefix}/api/derive`, async (req, res) => {
3328
+ const body = await readBody(req)
3329
+ // v3.1.30:透传 sessionIds,让「一次只能导入一个会话」的校验在 API 层生效。
3330
+ const out = await service.derive({
3331
+ title: body?.title,
3332
+ sessionId: body?.sessionId,
3333
+ sessionIds: body?.sessionIds,
3334
+ collaborators: body?.collaborators,
3335
+ })
3336
+ return json(res, out.ok ? 200 : 409, out)
3337
+ })
3338
+ route('GET', `${prefix}/api/tasks/sessions`, async (req, res) => {
3339
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3340
+ const out = await service.listForkSessions(query.get("taskId") ?? "", { limit: query.get("limit") })
3341
+ return json(res, out.ok === false ? 404 : 200, out)
3342
+ })
3343
+ // v1 兼容路由
3344
+ route('GET', `${prefix}/api/forks`, (req, res) => json(res, 200, { ok: true, forks: service.listForks() }))
3345
+ routeMethods(`${prefix}/api/forks/supervision`, {
3346
+ GET: (req, res) => json(res, 200, service.forkSupervision()),
3347
+ POST: async (req, res) => {
3348
+ const body = await readBody(req)
3349
+ const out = service.setForkSupervision(body ?? {})
3350
+ return json(res, out.ok ? 200 : 400, out)
3351
+ },
3352
+ })
3353
+ // watchdog 启动时的分身自动恢复;只拉起已登记但没在跑的非归档任务。
3354
+ route('POST', `${prefix}/api/forks/autostart`, async (req, res) => {
3355
+ const body = await readBody(req)
3356
+ const taskIds = Array.isArray(body?.taskIds) ? body.taskIds : null
3357
+ const out = await service.autostartForks({ taskIds })
3358
+ return json(res, out.ok ? 200 : 503, out)
3359
+ })
3360
+ route('POST', `${prefix}/api/forks/create`, async (req, res) => {
3361
+ const body = await readBody(req)
3362
+ const out = await service.fork({ taskTitle: body?.taskTitle ?? body?.title })
3363
+ return json(res, out.ok ? 200 : 409, out)
3364
+ })
3365
+ route('POST', `${prefix}/api/reap`, async (req, res) => {
3366
+ const body = await readBody(req)
3367
+ const out = await service.reap(String(body?.forkId ?? body?.taskId ?? ''))
3368
+ return json(res, out.ok ? 200 : 409, out)
3369
+ })
3370
+ route('POST', `${prefix}/api/promote`, async (req, res) => {
3371
+ const body = await readBody(req)
3372
+ const out = await service.promote(String(body?.forkId ?? ''), String(body?.pluginId ?? ''))
3373
+ return json(res, out.ok ? 200 : 409, out)
3374
+ })
3375
+ // v2.9.0 母体连接
3376
+ route('GET', `${prefix}/api/mother-link`, (req, res) => json(res, 200, linkService.describe()))
3377
+ route('POST', `${prefix}/api/mother-link/probe`, async (req, res) => {
3378
+ const body = await readBody(req)
3379
+ const out = await linkService.probe(body && typeof body === 'object' ? body : {})
3380
+ try {
3381
+ ctx.get(SERVICE.auditor)?.append?.(AUDIT.LINK_PROBE, {
3382
+ target: out.target ?? null, ok: out.ok === true,
3383
+ latencyMs: out.latencyMs ?? null, error: out.error ?? null,
3384
+ })
3385
+ } catch { /* 审计缺失不影响探测结果 */ }
3386
+ return json(res, 200, out)
3387
+ })
3388
+ // v2.11.0 母体镜像管理
3389
+ route('GET', `${prefix}/api/mirror/status`, (req, res) => {
3390
+ const payload = mirrorService.status()
3391
+ const ifNoneMatch = req && req.headers ? req.headers['if-none-match'] : undefined
3392
+ const out = buildMirrorStatusResponse(payload, ifNoneMatch)
3393
+ res.writeHead(out.statusCode, out.headers)
3394
+ res.end(out.notModified ? '' : out.body)
3395
+ })
3396
+ route('POST', `${prefix}/api/mirror/backup`, async (req, res) => {
3397
+ const body = await readBody(req)
3398
+ try {
3399
+ const entry = mirrorService.backup({ label: body?.label, bucket: body?.bucket })
3400
+ return json(res, 200, { ok: true, entry })
3401
+ } catch (error) {
3402
+ return json(res, 500, { ok: false, error: String(error?.message ?? error) })
3403
+ }
3404
+ })
3405
+ route('POST', `${prefix}/api/mirror/restore`, async (req, res) => {
3406
+ const body = await readBody(req)
3407
+ try {
3408
+ const out = mirrorService.restore(body?.backupId)
3409
+ return json(res, out.ok ? 200 : 409, out)
3410
+ } catch (error) {
3411
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3412
+ }
3413
+ })
3414
+ route('POST', `${prefix}/api/mirror/backup/delete`, async (req, res) => {
3415
+ const body = await readBody(req)
3416
+ try {
3417
+ const out = mirrorService.deleteBackup(body?.backupId)
3418
+ return json(res, out.ok ? 200 : 404, out)
3419
+ } catch (error) {
3420
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3421
+ }
3422
+ })
3423
+ route('POST', `${prefix}/api/mirror/backup-interval`, async (req, res) => {
3424
+ const body = await readBody(req)
3425
+ const out = mirrorService.setBackupInterval(body?.hours)
3426
+ return json(res, out.ok ? 200 : 400, out)
3427
+ })
3428
+ // v3.0.9 升级链路 2.1:从本机母体 profile 克隆镜像基础层(先自动备份)。
3429
+ route('POST', `${prefix}/api/mirror/clone-profile`, async (req, res) => {
3430
+ const body = await readBody(req)
3431
+ try { return json(res, 200, { ok: true, ...mirrorService.cloneMotherProfile(body) }) } catch (error) {
3432
+ return json(res, 400, { ok: false, error: 'clone-failed', detail: String(error?.message ?? error) })
3433
+ }
3434
+ })
3435
+ // 空白母体(初始盘):备份当前 → active/profile 清空 → 版本 blank
3436
+ route('POST', `${prefix}/api/mirror/blank`, async (req, res) => {
3437
+ const body = await readBody(req)
3438
+ try { return json(res, 200, { ok: true, ...mirrorService.blankMother(body) }) } catch (error) {
3439
+ return json(res, 400, { ok: false, error: 'blank-failed', detail: String(error?.message ?? error) })
3440
+ }
3441
+ })
3442
+ // v3.0.8 镜像偏好:对话框交互超时 15/30/60s(宿主侧持久化)
3443
+ route('GET', `${prefix}/api/mirror/preferences`, (req, res) => json(res, 200, mirrorService.getPreferences()))
3444
+ route('POST', `${prefix}/api/mirror/preferences/set`, async (req, res) => {
3445
+ const body = await readBody(req)
3446
+ try {
3447
+ const patch = {}
3448
+ if (body?.activeIdleTimeoutMs !== undefined) patch.activeIdleTimeoutMs = body.activeIdleTimeoutMs
3449
+ if (body?.syncIntervalHours !== undefined) patch.syncIntervalHours = body.syncIntervalHours
3450
+ const preferences = mirrorService.setPreferences(patch)
3451
+ return json(res, 200, preferences)
3452
+ } catch (error) {
3453
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3454
+ }
3455
+ })
3456
+ // v3.0.3 compat 门禁:客户端升级后子插件默认关闭,门禁通过才允许启用
3457
+ route('GET', `${prefix}/api/mirror/recompute-status`, (req, res) => json(res, 200, mirrorService.recomputeStatus()))
3458
+ route('GET', `${prefix}/api/essence/list`, (req, res) => json(res, 200, mirrorService.listEssences()))
3459
+ // v3.0.6 元主 CRUD / 默认元主 / 导出
3460
+ route('POST', `${prefix}/api/essence/create`, async (req, res) => {
3461
+ const body = await readBody(req)
3462
+ try { return json(res, 200, { ok: true, essence: mirrorService.createEssence(body) }) } catch (error) {
3463
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3464
+ }
3465
+ })
3466
+ // v3.0.9 母体 → 元主:从当前母体 profile 的可写层生成一个可配置元主。
3467
+ route('POST', `${prefix}/api/essence/capture`, async (req, res) => {
3468
+ const body = await readBody(req)
3469
+ try { return json(res, 200, { ok: true, ...mirrorService.captureEssence(body) }) } catch (error) {
3470
+ return json(res, 400, { ok: false, error: 'capture-failed', detail: String(error?.message ?? error) })
3471
+ }
3472
+ })
3473
+ // v3.0.9 升级链路 3:把元主 overlay 重放到当前镜像(单个或全部)。
3474
+ route('POST', `${prefix}/api/essence/sync`, async (req, res) => {
3475
+ const body = await readBody(req)
3476
+ try { return json(res, 200, { ok: true, ...(await mirrorService.syncEssences(body)) }) } catch (error) {
3477
+ return json(res, 400, { ok: false, error: 'sync-failed', detail: String(error?.message ?? error) })
3478
+ }
3479
+ })
3480
+ // v3.0.9 原身运行态:start / stop(隔离 home 的配置态实例)
3481
+ // 注意:DSH webServer 按 path 键路由,GET+POST 必须走 routeMethods 一次注册,
3482
+ // 否则第二次 register 抛出,后面的路由会静默丢失(实测踩过)。
3483
+ routeMethods(`${prefix}/api/essence/runtime`, {
3484
+ GET: (req, res) => {
3485
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3486
+ return json(res, 200, mirrorService.essenceRuntimeStatus(query.get('essenceId')))
3487
+ },
3488
+ POST: async (req, res) => {
3489
+ const body = await readBody(req)
3490
+ const action = String(body?.action ?? 'start')
3491
+ try {
3492
+ if (action === 'stop') return json(res, 200, mirrorService.stopEssenceRuntime(body?.essenceId))
3493
+ return json(res, 200, await mirrorService.startEssenceRuntime(body?.essenceId, body))
3494
+ } catch (error) {
3495
+ return json(res, 400, { ok: false, error: 'runtime-failed', detail: String(error?.message ?? error) })
3496
+ }
3497
+ },
3498
+ })
3499
+ route('GET', `${prefix}/api/essence/detail`, (req, res) => {
3500
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3501
+ try { return json(res, 200, mirrorService.essenceDetail(query.get('essenceId'))) } catch (error) {
3502
+ return json(res, 400, { ok: false, error: 'detail-failed', detail: String(error?.message ?? error) })
3503
+ }
3504
+ })
3505
+ route('GET', `${prefix}/api/essence/open/candidates`, (req, res) => json(res, 200, mirrorService.essenceOpenCandidates()))
3506
+ route('GET', `${prefix}/api/essence/runtime/open`, (req, res) => {
3507
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3508
+ return json(res, 200, mirrorService.openEssenceRuntime(query.get('essenceId')))
3509
+ })
3510
+ route('POST', `${prefix}/api/essence/backup`, async (req, res) => {
3511
+ const body = await readBody(req)
3512
+ try { return json(res, 200, { ok: true, ...mirrorService.backupEssence(String(body?.essenceId ?? '')) }) } catch (error) {
3513
+ return json(res, 400, { ok: false, error: 'backup-failed', detail: String(error?.message ?? error) })
3514
+ }
3515
+ })
3516
+ route('POST', `${prefix}/api/essence/restore`, async (req, res) => {
3517
+ const body = await readBody(req)
3518
+ try { return json(res, 200, { ok: true, ...mirrorService.restoreEssence(String(body?.essenceId ?? '')) }) } catch (error) {
3519
+ return json(res, 400, { ok: false, error: 'restore-failed', detail: String(error?.message ?? error) })
3520
+ }
3521
+ })
3522
+ route('POST', `${prefix}/api/essence/import`, async (req, res) => {
3523
+ const body = await readBody(req)
3524
+ try { return json(res, 200, { ok: true, ...mirrorService.importEssence(body) }) } catch (error) {
3525
+ return json(res, 400, { ok: false, error: 'import-failed', detail: String(error?.message ?? error) })
3526
+ }
3527
+ })
3528
+ route('POST', `${prefix}/api/essence/rename`, async (req, res) => {
3529
+ const body = await readBody(req)
3530
+ try { return json(res, 200, { ok: true, essence: mirrorService.renameEssence(String(body?.essenceId ?? ''), body?.name) }) } catch (error) {
3531
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3532
+ }
3533
+ })
3534
+ route('POST', `${prefix}/api/essence/archive`, async (req, res) => {
3535
+ const body = await readBody(req)
3536
+ try { return json(res, 200, { ok: true, essence: mirrorService.archiveEssence(String(body?.essenceId ?? ''), body?.archived !== false) }) } catch (error) {
3537
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3538
+ }
3539
+ })
3540
+ route('POST', `${prefix}/api/essence/delete`, async (req, res) => {
3541
+ const body = await readBody(req)
3542
+ try { return json(res, 200, { ok: true, ...mirrorService.deleteEssence(String(body?.essenceId ?? '')) }) } catch (error) {
3543
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3544
+ }
3545
+ })
3546
+ route('POST', `${prefix}/api/essence/copy`, async (req, res) => {
3547
+ const body = await readBody(req)
3548
+ try { return json(res, 200, { ok: true, essence: mirrorService.copyEssence(String(body?.essenceId ?? ''), body?.name) }) } catch (error) {
3549
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3550
+ }
3551
+ })
3552
+ route('GET', `${prefix}/api/essence/download`, (req, res) => {
3553
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3554
+ try {
3555
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3556
+ const bundle = mirrorService.downloadEssence(String(query.get('essenceId') ?? ''), { full: query.get('full') === '1' })
3557
+ res.writeHead(200, {
3558
+ 'content-type': 'application/json; charset=utf-8',
3559
+ 'content-disposition': `attachment; filename="essence-${bundle.essenceId}.json"`,
3560
+ 'cache-control': 'no-store',
3561
+ })
3562
+ return res.end(JSON.stringify(bundle))
3563
+ } catch (error) {
3564
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3565
+ }
3566
+ })
3567
+ // v3.0.6 分身 rename/copy/download(copy=访问信息;download=元数据清单)
3568
+ route('POST', `${prefix}/api/tasks/rename`, async (req, res) => {
3569
+ const body = await readBody(req)
3570
+ const out = await service.renameTask(String(body?.taskId ?? ''), body?.title)
3571
+ return json(res, out.ok ? 200 : 409, out)
3572
+ })
3573
+ route('POST', `${prefix}/api/tasks/copy`, async (req, res) => {
3574
+ const body = await readBody(req)
3575
+ const out = await service.copyTaskInfo(String(body?.taskId ?? ''))
3576
+ return json(res, out.ok ? 200 : 404, out)
3577
+ })
3578
+ route('GET', `${prefix}/api/tasks/download`, async (req, res) => {
3579
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3580
+ const out = await Promise.resolve(service.downloadTaskManifest(String(query.get('taskId') ?? '')))
3581
+ return json(res, out.ok ? 200 : 404, out)
3582
+ })
3583
+ route('GET', `${prefix}/api/compat`, (req, res) => json(res, 200, mirrorService.compatStatus()))
3584
+ route('POST', `${prefix}/api/compat/check`, async (req, res) => {
3585
+ const status = await mirrorService.runCompatCheck()
3586
+ return json(res, status.ok ? 200 : 409, status)
3587
+ })
3588
+ route('POST', `${prefix}/api/mirror/upgrade`, async (req, res) => {
3589
+ const body = await readBody(req)
3590
+ const out = await mirrorService.upgrade({
3591
+ autoBackup: body?.autoBackup !== false,
3592
+ autoRollback: body?.autoRollback !== false,
3593
+ })
3594
+ return json(res, out.ok ? 200 : 409, out)
3595
+ })
3596
+ // v3.0.8 分支 HTTP 面(注册在末尾,避免影响既有路由初始化顺序)
3597
+ routeMethods(`${prefix}/api/tasks/branches`, {
3598
+ GET: (req, res) => {
3599
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3600
+ const checked = requireTask(query.get('taskId'))
3601
+ if (!checked.ok) return json(res, checked.status, { ok: false, error: checked.error, code: checked.error })
3602
+ return json(res, 200, branchStore.list(checked.taskId))
3603
+ },
3604
+ POST: async (req, res) => {
3605
+ const body = await readBody(req)
3606
+ const checked = requireTask(body?.taskId)
3607
+ if (!checked.ok) return json(res, checked.status, { ok: false, error: checked.error, code: checked.error })
3608
+ try {
3609
+ return json(res, 200, branchStore.create(checked.taskId, {
3610
+ branchId: String(body?.branchId ?? ''),
3611
+ parentBranchId: body?.parentBranchId ? String(body.parentBranchId) : undefined,
3612
+ baseTurn: Number(body?.baseTurn) || 0,
3613
+ }))
3614
+ } catch (error) {
3615
+ const code = String(error?.message ?? error)
3616
+ return json(res, branchErrorStatus(code), { ok: false, error: code, code })
3617
+ }
3618
+ },
3619
+ })
3620
+ route('POST', `${prefix}/api/tasks/branches/activate`, async (req, res) => {
3621
+ const body = await readBody(req)
3622
+ const checked = requireTask(body?.taskId)
3623
+ if (!checked.ok) return json(res, checked.status, { ok: false, error: checked.error, code: checked.error })
3624
+ try {
3625
+ const out = branchStore.activate(checked.taskId, String(body?.branchId ?? ''))
3626
+ // 方案 B:激活即切换 active 分支会话(同一分身可有多个分支会话,但同时只有 1 个 active)。
3627
+ const task = tasks.get(checked.taskId)
3628
+ if (task) {
3629
+ task.activeBranchId = out.activeBranchId
3630
+ persist()
3631
+ }
3632
+ audit('fork-branch-activated', { taskId: checked.taskId, branchId: out.activeBranchId, childSessionId: out.branch?.sessionId ?? null })
3633
+ return json(res, 200, out)
3634
+ } catch (error) {
3635
+ const code = String(error?.message ?? error)
3636
+ return json(res, branchErrorStatus(code), { ok: false, error: code, code })
3637
+ }
3638
+ })
3639
+ route('POST', `${prefix}/api/tasks/branches/status`, async (req, res) => {
3640
+ const body = await readBody(req)
3641
+ const checked = requireTask(body?.taskId)
3642
+ if (!checked.ok) return json(res, checked.status, { ok: false, error: checked.error, code: checked.error })
3643
+ try {
3644
+ return json(res, 200, branchStore.setStatus(checked.taskId, String(body?.branchId ?? ''), String(body?.status ?? '')))
3645
+ } catch (error) {
3646
+ const code = String(error?.message ?? error)
3647
+ return json(res, branchErrorStatus(code), { ok: false, error: code, code })
3648
+ }
3649
+ })
3650
+ // 方案 A:物理分支——在分身自己的宿主里 sessions.fork,子会话挂回同一分身。
3651
+ route('POST', `${prefix}/api/tasks/branches/fork`, async (req, res) => {
3652
+ const body = await readBody(req)
3653
+ const checked = requireTask(body?.taskId)
3654
+ if (!checked.ok) return json(res, checked.status, { ok: false, error: checked.error, code: checked.error })
3655
+ const task = tasks.get(checked.taskId)
3656
+ if (!task) return json(res, 404, { ok: false, error: 'unknown-task' })
3657
+ if (task.status === TASK_STATUS.ARCHIVED) return json(res, 409, { ok: false, error: 'task-archived' })
3658
+ const client = await remoteFor(task)
3659
+ if (!client || typeof client.command !== 'function') return json(res, 502, { ok: false, error: 'adapter-unavailable' })
3660
+ const current = branchStore.list(checked.taskId)
3661
+ const sourceSessionId = String(body?.sessionId ?? current.sessionId ?? '').trim()
3662
+ if (!sourceSessionId) return json(res, 409, { ok: false, error: 'no-session' })
3663
+ const branchId = String(body?.branchId ?? '').trim() || `b${Date.now().toString(36)}`
3664
+ const baseTurn = Number.isFinite(Number(body?.baseTurn)) ? Number(body.baseTurn) : 0
3665
+ try {
3666
+ const out = await client.command('fork-session', { sessionId: sourceSessionId, boundary: baseTurn || undefined })
3667
+ const result = out?.data?.result ?? out?.data ?? null
3668
+ if (!out?.ok || result?.ok === false) {
3669
+ const code = String(result?.code ?? result?.error ?? out?.error ?? 'fork-failed')
3670
+ return json(res, 502, { ok: false, error: code, detail: result })
3671
+ }
3672
+ const childSessionId = result?.childSessionId ?? result?.id ?? null
3673
+ const branch = branchStore.create(checked.taskId, { branchId, baseTurn, sessionId: childSessionId })
3674
+ audit('fork-branch-created', { taskId: checked.taskId, branchId, childSessionId })
3675
+ return json(res, 200, { ok: true, branch: branch.branch, childSessionId, sourceSessionId })
3676
+ } catch (error) {
3677
+ const code = String(error?.message ?? error)
3678
+ return json(res, branchErrorStatus(code), { ok: false, error: code, code })
3679
+ }
3680
+ })
3681
+ route('GET', `${prefix}/api/reflow/policy`, (req, res) => json(res, 200, {
3682
+ ok: true,
3683
+ states: ['sealed', 'verified', 'quarantine', 'adopted'],
3684
+ transitions: {
3685
+ sealed: ['verified'],
3686
+ verified: ['quarantine', 'adopted'],
3687
+ quarantine: ['verified'],
3688
+ adopted: [],
3689
+ },
3690
+ forbiddenFields: ['token', 'password', 'secret', 'credential', 'auth', 'cookie', 'sessionkey', 'apikey'],
3691
+ merge: false,
3692
+ }))
3693
+
3694
+ // v3.0.9 composer journal: persist unsent drafts and a read-only queue mirror
3695
+ // so a restart/crash can offer recovery. Nothing is auto-sent.
3696
+ routeMethods(`${prefix}/api/composer/journal`, {
3697
+ GET: (req, res) => {
3698
+ if (queueJournalGate(res)) return
3699
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3700
+ const key = query.get('key') || ''
3701
+ // list=1 is the recovery list: every saved session draft/queue, newest first.
3702
+ if (!key && query.get('list') === '1') {
3703
+ return json(res, 200, { ok: true, entries: composerJournal.list() })
3704
+ }
3705
+ if (!key) return json(res, 400, { ok: false, error: 'journal-key-required' })
3706
+ return json(res, 200, { ok: true, entry: composerJournal.get(key) })
3707
+ },
3708
+ POST: async (req, res) => {
3709
+ if (queueJournalGate(res)) return
3710
+ const body = await readBody(req)
3711
+ const key = String(body?.key ?? '')
3712
+ if (!key) return json(res, 400, { ok: false, error: 'journal-key-required' })
3713
+ try {
3714
+ return json(res, 200, { ok: true, entry: composerJournal.save(key, { ...body, sessionKey: body?.sessionKey || body?.sessionId || '' }) })
3715
+ } catch (error) {
3716
+ return json(res, 400, { ok: false, error: String(error?.message ?? error) })
3717
+ }
3718
+ },
3719
+ })
3720
+
3721
+ // v3.1.21:append-only 历史快照(主 journal 被覆盖时的兜底恢复源)。
3722
+ routeMethods(`${prefix}/api/composer/journal/history`, {
3723
+ GET: (req, res) => {
3724
+ if (queueJournalGate(res)) return
3725
+ const query = new URL(String(req.url ?? '/'), 'http://x').searchParams
3726
+ const limit = Math.max(1, Math.min(200, Number(query.get('limit') || 20)))
3727
+ const sessionKey = String(query.get('sessionKey') || '')
3728
+ return json(res, 200, { ok: true, items: composerJournal.history({ limit, sessionKey }) })
3729
+ },
3730
+ })
3731
+
3732
+ // Restore saved queue rows into the official Session inbox WITHOUT waking the
3733
+ // agent. The queue journal is a recovery surface; it must not send messages.
3734
+ route('POST', `${prefix}/api/composer/queue`, async (req, res) => {
3735
+ if (queueJournalGate(res)) return
3736
+ const body = await readBody(req)
3737
+ const sessionId = String(body?.sessionId ?? '').trim()
3738
+ const items = Array.isArray(body?.items) ? body.items : []
3739
+ if (!sessionId) return json(res, 400, { ok: false, error: 'session-id-required' })
3740
+ if (items.length === 0) return json(res, 200, { ok: true, queued: 0, failed: 0 })
3741
+ const agents = safeGet('agents')
3742
+ const agent = agents?.get?.(sessionId)
3743
+ if (!agent || typeof agent.send !== 'function') {
3744
+ return json(res, 409, { ok: false, error: 'session-not-live' })
3745
+ }
3746
+ let queued = 0
3747
+ let failed = 0
3748
+ for (const item of items.slice(0, 100)) {
3749
+ const text = String(item?.text ?? '').trim().slice(0, 4000)
3750
+ if (!text) { failed += 1; continue }
3751
+ try {
3752
+ agent.send({
3753
+ id: newId('msg'),
3754
+ role: 'user',
3755
+ content: [{ type: 'text', text }],
3756
+ source: { kind: 'user' },
3757
+ }, 'next-turn', false)
3758
+ queued += 1
3759
+ } catch { failed += 1 }
3760
+ }
3761
+ return json(res, 200, { ok: true, queued, failed })
3762
+ })
3763
+
3764
+ // v3.0.8 contract: an unknown /fang/api/* path must be a JSON 404 under the
3765
+ // same Host/Origin/token gate — not an unauthenticated empty 404 from the host.
3766
+ // Exact routes above win because the webServer matches exact before prefix.
3767
+ // 默认元宝(新用户免创建):dry-run 计划 + 应用。
3768
+ route('GET', `${prefix}/api/essence/default-preset`, async (req, res) => {
3769
+ try { return json(res, 200, await mirrorService.defaultPreset()) } catch (error) {
3770
+ return json(res, 500, { ok: false, error: String(error?.message ?? error) })
3771
+ }
3772
+ })
3773
+ route('POST', `${prefix}/api/essence/default`, async (req, res) => {
3774
+ const body = await readBody(req)
3775
+ try {
3776
+ // 同一路径两用:显式 essenceId = 设为默认;否则走默认元宝 ensure(apply/force/mode)。
3777
+ if (body?.essenceId) {
3778
+ return json(res, 200, { ok: true, default: mirrorService.setDefaultEssence(String(body.essenceId)) })
3779
+ }
3780
+ const out = await mirrorService.ensureDefaultEssence({ apply: body?.apply === true, force: body?.force === true, mode: body?.mode === 'preset' ? 'preset' : 'copy-local' })
3781
+ return json(res, out.ok === false ? 409 : 200, out)
3782
+ } catch (error) {
3783
+ return json(res, 500, { ok: false, error: String(error?.message ?? error) })
3784
+ }
3785
+ })
3786
+ // 新用户延迟自动创建(不阻塞启动;无已装默认插件时不创建)。
3787
+ if (cfg.defaultEssenceAuto !== false) {
3788
+ const timer = setTimeout(() => { mirrorService.ensureDefaultEssence({ apply: true, mode: 'copy-local' }).catch(() => {}) }, 8000)
3789
+ if (typeof timer.unref === 'function') timer.unref()
3790
+ }
3791
+
3792
+ webServer.register({
3793
+ kind: 'prefix',
3794
+ path: `${prefix}/api`, handler: async (req, res) => {
3795
+ const decision = checkRequestAuth(req, {
3796
+ token: authToken,
3797
+ publicRoute: authToken.length === 0,
3798
+ })
3799
+ if (!decision.ok) return json(res, decision.status, authErrorBody(decision))
3800
+ return json(res, 404, { ok: false, error: 'not-found', code: 'not-found' })
3801
+ },
3802
+ })
3803
+ })
3804
+ }
3805
+ }
3806
+
3807
+ /** Reject any plugin-provided relative path that escapes its base directory. */
3808
+ export function resolveInside(base, rel) {
3809
+ const p = String(rel ?? '')
3810
+ if (!p || p.includes('\0')) return null
3811
+ if (path.isAbsolute(p) || /^[a-zA-Z]:/.test(p) || p.startsWith('\\\\') || p.includes('..')) return null
3812
+ const resolved = path.resolve(base, p)
3813
+ const baseResolved = path.resolve(base)
3814
+ if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) return null
3815
+ return resolved
3816
+ }
3817
+
3818
+ /** Server-rendered portal (v2): task table + adapter state + four actions. */
3819
+ export function renderPanelHtml(prefix = '/fang', authToken = '') {
3820
+ const p = HTML_ESCAPE(prefix)
3821
+ const token = HTML_ESCAPE(authToken)
3822
+ return `<!doctype html>
3823
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3824
+ <meta name="fang-auth" content="${token}">
3825
+ <title>dsh-Fang v2 · 瓮城</title>
3826
+ <style>
3827
+ :root { color-scheme: light dark; }
3828
+ body { font: 14px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; padding: 24px; max-width: 1180px; }
3829
+ h1 { font-size: 20px; margin: 0 0 4px; }
3830
+ .sub { color: #8a8a8a; margin-bottom: 20px; }
3831
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 12px 0; }
3832
+ input, button { font: inherit; padding: 6px 10px; border-radius: 8px; border: 1px solid #8886; background: transparent; color: inherit; }
3833
+ input { min-width: 280px; }
3834
+ button { cursor: pointer; }
3835
+ button.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
3836
+ table { width: 100%; border-collapse: collapse; margin-top: 12px; }
3837
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #8884; vertical-align: middle; }
3838
+ th { font-weight: 600; color: #8a8a8a; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
3839
+ code { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
3840
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; border: 1px solid currentColor; }
3841
+ .ready { color: #16a34a; } .running { color: #2563eb; } .failed { color: #dc2626; }
3842
+ .archived { color: #8a8a8a; } .provisioning { color: #d97706; }
3843
+ .empty { color: #8a8a8a; padding: 24px 0; }
3844
+ #msg { min-height: 22px; color: #d97706; }
3845
+ a { color: inherit; }
3846
+ </style></head>
3847
+ <body>
3848
+ <h1>dsh-Fang v2 · 瓮城</h1>
3849
+ <div class="sub">母体只读;每个任务一个分身(用完即弃)。Adapter 就绪 = 分身器官已上线;归档 = flush + 回收。</div>
3850
+ <div class="row">
3851
+ <input id="title" placeholder="任务标题,例如:开发 dsh-foo 插件" />
3852
+ <button class="primary" id="create">派生新任务</button>
3853
+ <button id="refresh">刷新</button>
3854
+ <span id="mother"></span>
3855
+ </div>
3856
+ <div id="msg"></div>
3857
+ <table>
3858
+ <thead><tr><th>任务</th><th>状态</th><th>Adapter</th><th>端口</th><th>PID</th><th>操作</th></tr></thead>
3859
+ <tbody id="rows"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
3860
+ </table>
3861
+ <script>
3862
+ const P = ${JSON.stringify(p)};
3863
+ const $ = (id) => document.getElementById(id);
3864
+ // v3.0.5/B1:面板同源读取安装 token,所有 /api 请求必须携带 header(否则 401 unauthorized)
3865
+ const AUTH = (document.querySelector('meta[name="fang-auth"]')?.content || '');
3866
+ // UI 分析日志:所有按钮/链接点击都记录(可被自动冒烟读取)
3867
+ window.__fangUiLog = [];
3868
+ document.addEventListener('click', (ev) => {
3869
+ const el = ev.target && ev.target.closest ? ev.target.closest('button, a[href]') : null;
3870
+ if (!el) return;
3871
+ window.__fangUiLog.push({
3872
+ at: Date.now(),
3873
+ text: String(el.textContent || '').trim().slice(0, 80),
3874
+ act: el.getAttribute('data-act') || null,
3875
+ href: el.href || null,
3876
+ });
3877
+ }, true);
3878
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
3879
+ function msg(t, bad) { $('msg').textContent = t || ''; $('msg').style.color = bad ? '#dc2626' : '#16a34a'; }
3880
+ async function api(path, opts) {
3881
+ const res = await fetch(P + path, Object.assign({
3882
+ headers: Object.assign({ 'content-type': 'application/json' }, AUTH ? { 'x-fang-auth': AUTH } : {}),
3883
+ }, opts));
3884
+ const body = await res.json().catch(() => ({}));
3885
+ return { status: res.status, body };
3886
+ }
3887
+ async function refresh() {
3888
+ const { body } = await api('/api/status');
3889
+ const s = body || {};
3890
+ $('mother').innerHTML = s.version
3891
+ ? '<span class="badge">母体 · ' + esc(s.profileTag) + ' · ' + esc(s.tasks?.ready) + ' ready / ' + esc(s.tasks?.total) + ' tasks</span>'
3892
+ : '';
3893
+ const list = s.tasks?.list ?? [];
3894
+ if (!list.length) { $('rows').innerHTML = '<tr><td colspan="6" class="empty">还没有任务。输入标题并点「派生新任务」。</td></tr>'; return; }
3895
+ $('rows').innerHTML = list.map(t => '<tr>' +
3896
+ '<td>' + esc(t.title) + '<br><code>' + esc(t.taskId) + '</code></td>' +
3897
+ '<td><span class="badge ' + esc(t.status) + '">' + esc(t.status) + '</span></td>' +
3898
+ '<td><span class="badge ' + (t.adapter?.ok ? 'ready' : 'failed') + '">' + (t.adapter?.ok ? 'ready' : esc(t.adapter?.error || 'down')) + '</span></td>' +
3899
+ '<td>' + (t.port ? esc(t.port) : '—') + '</td>' +
3900
+ '<td>' + (t.pid ? esc(t.pid) : '—') + '</td>' +
3901
+ '<td>' +
3902
+ (t.url ? '<a class="badge" href="' + esc(t.url) + '" target="_blank" rel="noopener">打开</a> ' : '') +
3903
+ '<button data-act="archive" data-id="' + esc(t.taskId) + '">归档</button> ' +
3904
+ '<button data-act="promote" data-id="' + esc(t.taskId) + '">提升插件…</button>' +
3905
+ '</td></tr>').join('');
3906
+ }
3907
+ $('create').onclick = async () => {
3908
+ const title = $('title').value.trim();
3909
+ msg('派生中…(含 Adapter 握手,约 5–20 秒)');
3910
+ const { body } = await api('/api/tasks/create', { method: 'POST', body: JSON.stringify({ title }) });
3911
+ if (body?.ok) { msg('已派生:' + body.taskId + '(adapter ' + (body.adapter?.ok ? 'ready' : '未就绪') + ')'); if (body.url) window.open(body.url, '_blank', 'noopener'); $('title').value = ''; }
3912
+ else msg('派生失败:' + (body?.error || 'unknown') + (body?.detail ? ' — ' + body.detail : ''), true);
3913
+ refresh();
3914
+ };
3915
+ $('refresh').onclick = refresh;
3916
+ $('rows').addEventListener('click', async (ev) => {
3917
+ const btn = ev.target.closest('button[data-act]');
3918
+ if (!btn) return;
3919
+ const id = btn.getAttribute('data-id');
3920
+ if (btn.dataset.act === 'archive') {
3921
+ if (!confirm('归档该任务?会先 flush 分身缓冲并落审计,再删除 $DSH_HOME。')) return;
3922
+ const { body } = await api('/api/tasks/archive', { method: 'POST', body: JSON.stringify({ taskId: id }) });
3923
+ msg(body?.ok ? '已归档 ' + id : '归档失败:' + (body?.error || 'unknown'), !body?.ok);
3924
+ } else {
3925
+ const pluginId = prompt('要提升的插件目录名(位于该任务缓冲区内):');
3926
+ if (!pluginId) return;
3927
+ const { body } = await api('/api/tasks/promote', { method: 'POST', body: JSON.stringify({ taskId: id, pluginId }) });
3928
+ msg(body?.ok ? '提升已批准:' + (body.note || body.target) : '提升被拒:' + (body?.error || 'unknown'), !body?.ok);
3929
+ }
3930
+ refresh();
3931
+ });
3932
+ refresh();
3933
+ setInterval(refresh, 5000);
3934
+ </script>
3935
+ </body></html>`
3936
+ }
3937
+
3938
+ export const _internals = { resolveCliPath, isShellLauncher, renderPanelHtml, loadRegistry, resolveInside, normalizeSeed }