@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,1610 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang 2.0 — fang-forker: the process layer for disposable fork dsh-Web instances.
4
+ //
5
+ // Hard boundary (CONTRACTS-V2.md §5.5 / §1):
6
+ // * mother-only: apply() starts with roleGate('forker'); in a fork nothing is registered.
7
+ // * The mother is immutable. `provision` only READS profileTemplate. With cleanTemplate
8
+ // (v2 default) the fork profile is cloned from a *forker-owned clean template* (written
9
+ // under the fork home), so a dirty mother profile can never leak into a fork; from the
10
+ // mother it takes one thing only — the node_modules source for the junction.
11
+ // Removal always goes through fsx.removeTree() (junction-safe: never enters the mother).
12
+ // * A fork shares nothing: its own DSH_HOME (env.DSH_HOME=homeDir), its own buffer dir,
13
+ // and in v2 a *deterministic* port from the pool (18000–18100) instead of --port 0.
14
+ // `allocatePorts=false` keeps the v1 `--port 0` behaviour.
15
+ // * This plugin owns process lifecycle only. Risk scoring / approval / routing / adapter
16
+ // handshakes do not live here (scorer / approver / router / adapter own those).
17
+ //
18
+ // Port pool (v2):
19
+ // * ports are probed with net.createServer().listen({port,host:'127.0.0.1'}) — a port is
20
+ // usable only if the bind test succeeds; a busy port is skipped (next candidate).
21
+ // * the occupancy table is persisted to <dataRoot>/ports.json as {port:{forkId,pid,at}}.
22
+ // On first port use, entries whose recorded child pid is dead (or that never got a pid)
23
+ // are reclaimed, so a crashed mother can never leak the deterministic pool forever.
24
+ // * spawn validates the child's printed URL port against the port it was told to bind:
25
+ // a mismatch is a hard failure (child stopped, {ok:false,error:'port-mismatch'}).
26
+ // * stop/reap/dispose release the port; pool exhaustion is {ok:false,error:'port-exhausted'}.
27
+ //
28
+ // Windows launch note (measured): the default launcher is a `.cmd`
29
+ // (<DSH_INSTALL>\resources\runtime\cli\bin\dsh.cmd). child_process.spawn('.cmd') throws
30
+ // EINVAL/ENOENT without a shell, so `.cmd`/`.bat` must be launched with
31
+ // `shell: true`; `.mjs`/`.js` entries are launched through node; `.exe` directly.
32
+ import { spawn as spawnProcess, spawnSync } from 'node:child_process'
33
+ import { randomBytes } from 'node:crypto'
34
+ import fs from 'node:fs'
35
+ import { createRequire } from 'node:module'
36
+ import net from 'node:net'
37
+ import path from 'node:path'
38
+ import {
39
+ AUDIT,
40
+ EXIT,
41
+ FORK_STATUS,
42
+ SERVICE,
43
+ bus,
44
+ copyTree,
45
+ ensureDir,
46
+ ensureModuleMirror,
47
+ exists,
48
+ iso,
49
+ listFiles,
50
+ mirrorModuleTree,
51
+ newId,
52
+ nowMs,
53
+ parseWebUrl,
54
+ readJson,
55
+ removeTree,
56
+ resolveFangLayout,
57
+ roleGate,
58
+ writeFileAtomic,
59
+ writeJsonAtomic,
60
+ z,
61
+ } from '../kernel/index.mjs'
62
+ import { computeBackoff, planRestart, reconcileOrphans, writeForkMeta, probeHttpAlive, SUPERVISION_DEFAULTS } from '../lib/fork-supervisor.mjs'
63
+ import { applyForkConfigLock, setProfileConfigReadonly } from '../lib/fork-config-lock.mjs'
64
+ import { runMainGate, formatGateFailure } from '../lib/main-gate.mjs'
65
+ import { DEFAULT_FORK_SEED, applyForkSeedToProfile } from '../lib/fork-seed.mjs'
66
+
67
+ export const name = 'fang-forker'
68
+ export const inject = []
69
+
70
+ /** In-process event topics (CONTRACTS.md §6). */
71
+ const EVENTS = {
72
+ started: 'fang/fork-started',
73
+ crashed: 'fang/fork-crashed',
74
+ reaped: 'fang/fork-reaped',
75
+ /** v2.7.3:自动重启失败 / 放弃重启(terminal=true 表示不会再有自动重试)。 */
76
+ restartFailed: 'fang/fork-restart-failed',
77
+ }
78
+
79
+ /** v2 deterministic port pool (CONTRACTS-V2.md §5.5). */
80
+ const DEFAULT_PORT_POOL = { start: 18000, end: 18100 }
81
+ /** Loopback interface a fork's `dsh web` binds; the pool is probed on the same host. */
82
+ const PORT_HOST = '127.0.0.1'
83
+ /** A bind probe that does not answer by then counts as "not usable" (fail-closed, try next). */
84
+ const PORT_PROBE_TIMEOUT_MS = 5000
85
+ /** Bundle list of the clean fork profile: base + web app + this bundle (Cordis patch). */
86
+ const CLEAN_TEMPLATE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
87
+
88
+ // v3.1.0: DSH's first-run preview notice persists ui-settings-general.welcomeNoticeVersion.
89
+ // A clean profile that omits the row leaves the config form unavailable, so 「继续」 always
90
+ // reports "暂时无法保存确认状态". Seed the acknowledgement in every generated clean profile;
91
+ // the fork lock then appends its own disable rows.
92
+ const WELCOME_NOTICE_PATCH = [
93
+ '- id: ui-settings-general',
94
+ ' name: "@deepseek-ai/dsh-client-ui-settings-general"',
95
+ ' config:',
96
+ ' welcomeNoticeVersion: 2026-09-28.1',
97
+ '',
98
+ ].join('\n')
99
+
100
+ const DEFAULTS = {
101
+ cliPath: '',
102
+ profileTemplate: '',
103
+ startTimeoutMs: 60000,
104
+ maxForks: 8,
105
+ maxLogLines: 400,
106
+ dataRoot: '',
107
+ portPool: { ...DEFAULT_PORT_POOL },
108
+ allocatePorts: true,
109
+ cleanTemplate: true,
110
+ bundleName: '@twinsearth/fang-dsh-plugin',
111
+ forkProfileTag: 'web-base',
112
+ }
113
+
114
+ /** Files cloned from a (clean or mother) template profile: package.json + cordis*.yml only. */
115
+ const CLONED_RE = /^(package\.json|cordis.*\.ya?ml)$/i
116
+ const PROFILE_UNSAFE_RE = /[^a-zA-Z0-9_.-]/g
117
+ /** stdout/stderr scan window kept for `parseWebUrl` (the banner may be split over chunks). */
118
+ const SCAN_TEXT_LIMIT = 64 * 1024
119
+
120
+ export { parseWebUrl }
121
+
122
+ export const Config = z
123
+ .object({
124
+ cliPath: z.string().default('').description('dsh CLI 入口:.exe / .cmd / .mjs(空 = 由 spawn 入参给)'),
125
+ profileTemplate: z
126
+ .string()
127
+ .default('')
128
+ .description('母版 profile 目录:clone 源 / node_modules junction 源(cleanTemplate 时只用后者)'),
129
+ startTimeoutMs: z.natural().default(60000).description('等待 web URL 的启动超时(毫秒)'),
130
+ maxForks: z.natural().default(8).description('允许同时存活的分身上限'),
131
+ maxLogLines: z.natural().default(400).description('每个分身 stdout 滚动缓冲行数'),
132
+ dataRoot: z.string().default('').description('注册表/端口表落盘根目录(空 = 仅进程内存)'),
133
+ portPool: z
134
+ .object({
135
+ start: z.number().default(DEFAULT_PORT_POOL.start).description('端口池起始(含)'),
136
+ end: z.number().default(DEFAULT_PORT_POOL.end).description('端口池结束(含)'),
137
+ })
138
+ .default({})
139
+ .description('确定性端口池 18000–18100'),
140
+ allocatePorts: z
141
+ .boolean()
142
+ .default(true)
143
+ .description('true:预分配确定性端口(--port <port>);false:退回 v1 的 --port 0'),
144
+ cleanTemplate: z
145
+ .boolean()
146
+ .default(true)
147
+ .description('true:用内置干净模板(不继承母体脏 profile);false:沿用 profileTemplate'),
148
+ bundleName: z.string().default('@twinsearth/fang-dsh-plugin').description('干净模板 profile.bundles 的第三个 bundle'),
149
+ forkProfileTag: z.string().default('web-base').description('干净模板 profile 标签(模板目录/缺省 profile 名)'),
150
+ // v3.1.12:新机器/新用户的干净分身默认不带第三方插件;这里按“安装不激活 /
151
+ // 安装并激活”两组清单给每个新分身装默认插件(开关可关;失败不阻塞分身启动)。
152
+ forkSeed: z
153
+ .object({
154
+ enabled: z.boolean().default(true).description('派生新分身时安装默认第三方插件种子'),
155
+ install: z.array(z.string()).default([...DEFAULT_FORK_SEED.install]).description('只安装、不加入 profile bundles 的插件包名'),
156
+ activate: z.array(z.string()).default([...DEFAULT_FORK_SEED.activate]).description('安装并加入 profile bundles 的插件包名'),
157
+ })
158
+ .default({})
159
+ .description('派生分身的默认第三方插件种子'),
160
+ supervise: z.boolean().default(false)
161
+ .description('restart a crashed (already-ready) fork with exponential backoff; the patch layer enables it'),
162
+ maxRestarts: z.natural().default(SUPERVISION_DEFAULTS.maxRestarts)
163
+ .description('max automatic restarts per fork'),
164
+ backoffBaseMs: z.natural().default(SUPERVISION_DEFAULTS.baseMs)
165
+ .description('backoff base in milliseconds'),
166
+ backoffMaxMs: z.natural().default(SUPERVISION_DEFAULTS.maxMs)
167
+ .description('backoff cap in milliseconds'),
168
+ backoffJitter: z.number().min(0).max(0.9).default(SUPERVISION_DEFAULTS.jitter)
169
+ .description('backoff jitter ratio'),
170
+ healthPath: z.string().default(SUPERVISION_DEFAULTS.healthPath)
171
+ .description('HTTP liveness path (any response counts)')
172
+ })
173
+ .description('fang-forker 配置')
174
+
175
+ const msgOf = (e) => String(e && e.message ? e.message : e)
176
+
177
+ function safeProfile(value) {
178
+ const p = String(value ?? '').trim().replace(PROFILE_UNSAFE_RE, '_')
179
+ return p && p !== '.' && p !== '..' ? p : 'default'
180
+ }
181
+
182
+ /**
183
+ * Pure: is this cliPath a Windows shell launcher (.cmd/.bat)?
184
+ * Measured on Windows: spawn('...dsh.cmd', args) throws EINVAL/ENOENT unless shell:true.
185
+ */
186
+ export function isShellLauncher(cliPath) {
187
+ return /\.(cmd|bat)$/i.test(String(cliPath ?? '').trim())
188
+ }
189
+
190
+ /**
191
+ * Pure: argv appended to the CLI (CONTRACTS.md §5.1).
192
+ * v2: with allocatePorts the forker passes the pre-allocated `--port <n>`; the v1 fallback
193
+ * is still `--port 0` (OS picks a free port).
194
+ * v3.1.7: `patchPaths` are launcher layers (`--patch <path>`) handed to the child before
195
+ * its app args; the headless directory-picker overlay travels this way.
196
+ * @returns {string[]}
197
+ */
198
+ export function buildSpawnArgs({ profile = 'default', port = 0, noOpen = true, patchPaths = [] } = {}) {
199
+ const n = Math.floor(Number(port))
200
+ const p = Number.isInteger(n) && n >= 0 && n <= 65535 ? n : 0
201
+ const args = ['--profile', safeProfile(profile)]
202
+ for (const entry of Array.isArray(patchPaths) ? patchPaths : []) {
203
+ const patchPath = String(entry ?? '').trim()
204
+ if (patchPath) args.push('--patch', patchPath)
205
+ }
206
+ args.push('--port', String(p))
207
+ if (noOpen) args.push('--no-open')
208
+ return args
209
+ }
210
+
211
+ /**
212
+ * Pure: turn a cliPath into the exact `{command, args, shell}` triple to spawn.
213
+ * .cmd / .bat → shell launcher (shell:true)
214
+ * .mjs/.cjs/.js→ node + script prefix
215
+ * anything else (normally .exe) → direct spawn
216
+ * Never touches the filesystem, so it is safe to unit-test without launching anything.
217
+ * @returns {{command:string, args:string[], shell:boolean, windowsHide:boolean, nodeScript:boolean}}
218
+ */
219
+ export function buildLaunchSpec({ cliPath = '', profile = 'default', port = 0, patchPaths = [] } = {}) {
220
+ const entry = String(cliPath ?? '').trim()
221
+ const args = buildSpawnArgs({ profile, port, patchPaths })
222
+ if (isShellLauncher(entry)) {
223
+ // `shell: true` concatenates argv without escaping (DEP0190); a profile home or overlay
224
+ // path containing whitespace would otherwise split into two child arguments.
225
+ const quoted = args.map((arg) => (/[\s"]/u.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
226
+ return { command: entry, args: quoted, shell: true, windowsHide: true, nodeScript: false }
227
+ }
228
+ if (/\.(mjs|cjs|js)$/i.test(entry)) {
229
+ return { command: process.execPath, args: [entry, ...args], shell: false, windowsHide: true, nodeScript: true }
230
+ }
231
+ return { command: entry, args, shell: false, windowsHide: true, nodeScript: false }
232
+ }
233
+
234
+ /**
235
+ * v3.0.5/B5:派生进程环境变量白名单(不再全量继承母体 env —— 防止云密钥/适配器 token 泄漏给最低信任层)。
236
+ * 只放行启动 DSH 必需的运行时/代理变量;DSH_*、API key、token 等一律不继承(由 overrides 显式注入)。
237
+ * @param {Record<string, string|undefined>} [base]
238
+ * @param {Record<string, string|undefined>} [overrides]
239
+ * @returns {Record<string, string>}
240
+ */
241
+ export function buildForkChildEnv(base = process.env, overrides = {}) {
242
+ const KEYS = [
243
+ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'SystemDrive', 'windir', 'ComSpec',
244
+ 'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
245
+ 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'OS', 'NUMBER_OF_PROCESSORS',
246
+ 'PROCESSOR_ARCHITECTURE', 'USERNAME', 'USERDOMAIN', 'LANG', 'LC_ALL', 'TERM', 'SHELL',
247
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
248
+ ]
249
+ /** @type {Record<string,string>} */
250
+ const env = {}
251
+ for (const key of KEYS) {
252
+ if (typeof base?.[key] === 'string') env[key] = base[key]
253
+ }
254
+ for (const [key, value] of Object.entries(overrides ?? {})) {
255
+ if (typeof value === 'string') env[key] = value
256
+ else delete env[key]
257
+ }
258
+ return env
259
+ }
260
+
261
+ /**
262
+ * The launch overlay that pins the directory picker to the browser dialog in every
263
+ * fork/ESSENCE-RUNTIME web host.
264
+ *
265
+ * `dsh-host-directory-picker-auto` resolves `native` on win32/darwin whenever the
266
+ * bind is loopback and the launch is not SSH — it has no way to know the host was
267
+ * started headless through `dsh.cmd` (ELECTRON_RUN_AS_NODE, no Electron shell), so
268
+ * the native chooser has no window to open and "选择工作区" fails in the fork Web UI
269
+ * (v3.1.7 report). The documented composition switch is an overlay that disables
270
+ * the auto row and mounts the browse backend + its client surface directly; both
271
+ * package names exist as installation dependencies of the same DSH release.
272
+ */
273
+ export const HEADLESS_PICKER_OVERLAY = [
274
+ '# dsh-Fang v3.1.7: headless web host (dsh.cmd --no-open) has no native chooser window.',
275
+ '# Pin the directory-picker interaction to the browser dialog.',
276
+ '- id: directory-picker',
277
+ ' disabled: true',
278
+ '- insert:',
279
+ ' - id: directory-picker-browse',
280
+ " name: '@deepseek-ai/dsh-host-directory-picker-browse'",
281
+ ' - id: ui-directory-picker-browse',
282
+ " name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'",
283
+ '',
284
+ ].join('\n')
285
+
286
+ /** File name of the headless launch overlay inside the fork/essence-runtime profile. */
287
+ export const HEADLESS_PICKER_OVERLAY_FILE = 'fang-headless.patch.yml'
288
+
289
+ /** Whether this DSH installation carries the browse-picker pair the overlay mounts. */
290
+ export function headlessPickerSupported({ cliPath = '' } = {}) {
291
+ const names = [
292
+ '@deepseek-ai/dsh-host-directory-picker-browse',
293
+ '@deepseek-ai/dsh-client-ui-directory-picker-browse',
294
+ ]
295
+ let require = null
296
+ try { require = createRequire(import.meta.url) } catch { require = null }
297
+ const anchors = []
298
+ // Desktop install: <root>/resources/app.asar/dsh/node_modules (asar-aware fs in the host).
299
+ try { anchors.push(path.join(path.dirname(process.execPath), 'resources', 'app.asar', 'dsh', 'node_modules')) } catch { /* ignore */ }
300
+ // Launcher-derived install root: .../resources/runtime/cli/bin/dsh.cmd → .../resources/app.asar/dsh/node_modules
301
+ const cli = String(cliPath ?? '').trim()
302
+ if (cli) {
303
+ const resources = path.resolve(path.dirname(cli), '..', '..', '..', '..', 'resources')
304
+ anchors.push(path.join(resources, 'app.asar', 'dsh', 'node_modules'))
305
+ }
306
+ for (const name of names) {
307
+ let found = false
308
+ if (require) {
309
+ try { require.resolve(name); found = true } catch { /* not resolvable here */ }
310
+ }
311
+ if (!found) {
312
+ for (const anchor of anchors) {
313
+ try {
314
+ if (fs.existsSync(path.join(anchor, ...name.split('/'), 'package.json'))) { found = true; break }
315
+ } catch { /* ignore */ }
316
+ }
317
+ }
318
+ if (!found) return false
319
+ }
320
+ return true
321
+ }
322
+
323
+ /**
324
+ * Write (or refresh) the headless-picker launch overlay in `profileDir`.
325
+ * `DSH_FANG_HEADLESS_PICKER_PATCH=0` opts out; an installation without the browse
326
+ * pair keeps the DSH default (native) so an unknown-version host is not patched blind.
327
+ * @param {string} profileDir The fork/essence-runtime profile directory.
328
+ * @param {{cliPath?: string, supported?: boolean}} [options] The launcher the fork will
329
+ * be started with; `supported` overrides the installation probe (tests).
330
+ * @returns {string|null} The overlay path to pass as `--patch`, or null.
331
+ */
332
+ export function writeHeadlessPickerOverlay(profileDir, { cliPath = '', supported } = {}) {
333
+ if (String(process.env.DSH_FANG_HEADLESS_PICKER_PATCH ?? '1') === '0') return null
334
+ if (!profileDir || !exists(profileDir)) return null
335
+ const available = typeof supported === 'boolean' ? supported : headlessPickerSupported({ cliPath })
336
+ if (!available) return null
337
+ const file = path.join(profileDir, HEADLESS_PICKER_OVERLAY_FILE)
338
+ try {
339
+ writeFileAtomic(file, HEADLESS_PICKER_OVERLAY)
340
+ return file
341
+ } catch {
342
+ return null
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Pure: why did a fork exit? (CONTRACTS.md §5.1)
348
+ * 0 = normal, 75 = the guard allowed a restart (supervisor contract), else crash.
349
+ * @returns {{kind:'normal'|'restart-request'|'crash', code:number|null, signal:string|null, restartRequested:boolean}}
350
+ */
351
+ export function classifyExit(code, signal = null) {
352
+ const n = code === null || code === undefined || Number.isNaN(Number(code)) ? null : Number(code)
353
+ if (n === EXIT.OK) return { kind: 'normal', code: n, signal, restartRequested: false }
354
+ if (n === EXIT.RESTART) return { kind: 'restart-request', code: n, signal, restartRequested: true }
355
+ return { kind: 'crash', code: n, signal, restartRequested: false }
356
+ }
357
+
358
+ /**
359
+ * Pure: normalise a configured port pool. Returns null for an unusable range, so the
360
+ * caller can fall back to the contract default instead of silently allocating nothing.
361
+ * @returns {{start:number, end:number}|null}
362
+ */
363
+ export function resolvePortPool(pool) {
364
+ const start = Math.floor(Number(pool?.start))
365
+ const end = Math.floor(Number(pool?.end))
366
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null
367
+ if (start < 1 || end > 65535 || start > end) return null
368
+ return { start, end }
369
+ }
370
+
371
+ /**
372
+ * Pure: the clean fork profile's package.json (CONTRACTS-V2.md §5.5).
373
+ * `dsh.profile.bundles` is the loader contract: base plugins, the web app, then this
374
+ * bundle — nothing inherited from the mother profile.
375
+ * @returns {{name:string, version:string, private:boolean, dsh:{profile:{bundles:string[]}}}}
376
+ */
377
+ export function buildTemplatePackageJson({ bundleName = DEFAULTS.bundleName, forkProfileTag = DEFAULTS.forkProfileTag } = {}) {
378
+ const tag = safeProfile(forkProfileTag)
379
+ const bundles = [...CLEAN_TEMPLATE_BUNDLES, String(bundleName ?? '').trim() || DEFAULTS.bundleName]
380
+ return {
381
+ name: `dsh-fang-fork-${tag}`,
382
+ version: '1.0.0',
383
+ private: true,
384
+ dsh: { profile: { bundles } },
385
+ }
386
+ }
387
+
388
+ /** Rolling stdout tail: line buffer capped at maxLines + a bounded text window for parseWebUrl. */
389
+ function createTextTail(maxLines) {
390
+ const limit = Math.max(0, Math.floor(Number(maxLines) || 0)) || DEFAULTS.maxLogLines
391
+ const lines = []
392
+ let partial = ''
393
+ let text = ''
394
+ const trim = () => {
395
+ if (lines.length > limit) lines.splice(0, lines.length - limit)
396
+ }
397
+ return {
398
+ lines,
399
+ get text() {
400
+ return text
401
+ },
402
+ push(chunk) {
403
+ const s = String(chunk ?? '')
404
+ text = (text + s).slice(-SCAN_TEXT_LIMIT)
405
+ partial += s
406
+ let idx
407
+ while ((idx = partial.indexOf('\n')) >= 0) {
408
+ lines.push(partial.slice(0, idx).replace(/\r$/, ''))
409
+ partial = partial.slice(idx + 1)
410
+ }
411
+ trim()
412
+ return s
413
+ },
414
+ feed(chunk) {
415
+ return this.push(chunk)
416
+ },
417
+ tail(n = 40) {
418
+ const count = Math.max(0, Math.floor(Number(n) || 0))
419
+ if (count === 0) return ''
420
+ const all = partial ? [...lines, partial.replace(/\r$/, '')] : lines
421
+ return all.slice(-count).join('\n')
422
+ },
423
+ }
424
+ }
425
+
426
+ /** Is this pid still alive? EPERM means "exists but not ours" — still alive. */
427
+ function isPidAlive(pid) {
428
+ const n = Number(pid)
429
+ if (!Number.isInteger(n) || n <= 0) return false
430
+ try {
431
+ process.kill(n, 0)
432
+ return true
433
+ } catch (e) {
434
+ return e && e.code === 'EPERM'
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Bind-test one port on loopback: resolve true only when net can actually listen on it.
440
+ * Asynchronous by contract: never blocks the host event loop while probing a busy pool.
441
+ * @returns {Promise<boolean>}
442
+ */
443
+ function probePortFree(port, { host = PORT_HOST, timeoutMs = PORT_PROBE_TIMEOUT_MS } = {}) {
444
+ return new Promise((resolve) => {
445
+ const server = net.createServer()
446
+ let settled = false
447
+ let timer = null
448
+ const done = (free) => {
449
+ if (settled) return
450
+ settled = true
451
+ if (timer) clearTimeout(timer)
452
+ try {
453
+ server.close(() => {})
454
+ } catch {
455
+ /* already closed */
456
+ }
457
+ resolve(free)
458
+ }
459
+ timer = setTimeout(() => done(false), Math.max(1, Math.floor(timeoutMs) || PORT_PROBE_TIMEOUT_MS))
460
+ timer.unref?.()
461
+ server.once('error', () => done(false))
462
+ try {
463
+ server.listen({ port, host, exclusive: true }, () => done(true))
464
+ } catch {
465
+ done(false)
466
+ }
467
+ })
468
+ }
469
+
470
+ export function apply(ctx, config) {
471
+ // v2 deployment matrix: forker is a mother-only organ. Must be the FIRST statement —
472
+ // in a fork role nothing (service / timer / dir) may be created.
473
+ const gate = roleGate('forker', { cfgRole: config?.role, logger: ctx?.logger })
474
+ // v2 deployment matrix: this plugin is not deployed in this role — register nothing.
475
+ if (!gate.active) return
476
+
477
+ const rawConfig = { ...DEFAULTS, ...(config ?? {}) }
478
+ const portPool = resolvePortPool(rawConfig.portPool) ?? { ...DEFAULT_PORT_POOL }
479
+ const allocatePorts = rawConfig.allocatePorts !== false
480
+ const cleanTemplate = rawConfig.cleanTemplate !== false
481
+ const bundleName = String(rawConfig.bundleName ?? DEFAULTS.bundleName).trim() || DEFAULTS.bundleName
482
+ const forkProfileTag = safeProfile(rawConfig.forkProfileTag)
483
+ const cfg = { ...rawConfig, portPool, allocatePorts, cleanTemplate, bundleName, forkProfileTag }
484
+ // 运行时 supervise 开关:patch/config 的初值 + 可在不重启宿主的情况下切换(UI/API)。
485
+ let runtimeSupervise = cfg.supervise === true
486
+
487
+ const logger = ctx?.logger ?? { debug() {}, info() {}, warn() {}, error() {} }
488
+ const logWarn = (line) => {
489
+ try {
490
+ logger.warn?.(line)
491
+ } catch {
492
+ /* logger must never break the process layer */
493
+ }
494
+ }
495
+
496
+ /** Cross-plugin reads are always tolerant (auditor/approver may be disabled rows). */
497
+ const safeGet = (key) => {
498
+ try {
499
+ return ctx?.get?.(key)
500
+ } catch {
501
+ return undefined
502
+ }
503
+ }
504
+ const audit = (kind, data) => {
505
+ try {
506
+ const auditor = safeGet(SERVICE.auditor)
507
+ if (auditor && typeof auditor.append === 'function') return auditor.append(kind, data)
508
+ } catch {
509
+ /* auditing must never break fork lifecycle */
510
+ }
511
+ return null
512
+ }
513
+ const emit = (topic, payload) => {
514
+ try {
515
+ return bus.emit(topic, payload)
516
+ } catch {
517
+ return 0
518
+ }
519
+ }
520
+
521
+ /** 广播 Adapter token 刷新/生成;handler 异常隔离,绝不影响 spawn 流程。 */
522
+ const emitTokenRefresh = (forkId, token) => {
523
+ for (const handler of [...tokenRefreshHandlers]) {
524
+ try {
525
+ handler(forkId, token)
526
+ } catch {
527
+ /* 订阅者异常不得打断 spawn */
528
+ }
529
+ }
530
+ }
531
+
532
+ /** forkId -> fork record. Only the live child handle and log tail are in-memory-only. */
533
+ const records = new Map()
534
+ /** forkId -> disposer for an in-flight spawn promise (so plugin dispose cannot hang one). */
535
+ const pendingSpawns = new Set()
536
+ /** (forkId, token) 订阅者:每次 spawn 重新生成 Adapter token 时广播(首次派生与重启都会触发)。 */
537
+ const tokenRefreshHandlers = new Set()
538
+ /** port -> forkId reserved/assigned by THIS plugin instance (never release another instance's). */
539
+ const ownedPorts = new Map()
540
+ /** port -> {forkId,pid,at} persisted view of the pool (loaded lazily). */
541
+ const portTable = new Map()
542
+
543
+ let forksFile = ''
544
+ let portsFile = ''
545
+ let layout = null
546
+ if (cfg.dataRoot) {
547
+ try {
548
+ layout = resolveFangLayout({ dataRoot: cfg.dataRoot })
549
+ forksFile = layout.forksFile
550
+ portsFile = path.join(layout.dataDir, 'ports.json')
551
+ } catch (e) {
552
+ logWarn(`[fang-forker] layout resolve failed: ${msgOf(e)}`)
553
+ forksFile = ''
554
+ portsFile = ''
555
+ }
556
+ }
557
+ let registryLoaded = false
558
+ let portsLoaded = false
559
+ let disposed = false
560
+ const starting = () => [...records.values()].filter((r) => r.running || r.child || r.starting).length
561
+
562
+ /** v3.0.5/B4:持久化/列表用的 URL 一律去掉 ?token=(token 只在内存)。 */
563
+ const stripUrlToken = (url) => {
564
+ const raw = String(url ?? '')
565
+ if (!raw) return raw || null
566
+ try {
567
+ const parsed = new URL(raw)
568
+ if (!parsed.searchParams.has('token')) return raw
569
+ parsed.searchParams.delete('token')
570
+ return parsed.toString()
571
+ } catch {
572
+ return raw.replace(/([?&])token=[^&#]*&?/i, (_, sep) => (sep === '?' ? '?' : '')).replace(/[?&]$/, '')
573
+ }
574
+ }
575
+
576
+ const publicFork = (rec) => ({
577
+ forkId: rec.forkId,
578
+ taskTitle: rec.taskTitle ?? '',
579
+ status: rec.status ?? FORK_STATUS.STOPPED,
580
+ pid: rec.pid ?? null,
581
+ port: rec.port ?? null,
582
+ url: stripUrlToken(rec.url),
583
+ homeDir: rec.homeDir ?? '',
584
+ profile: rec.profile ?? '',
585
+ profileDir: rec.profileDir ?? '',
586
+ template: rec.template ?? '',
587
+ bufferDir: rec.bufferDir ?? '',
588
+ running: !!rec.running,
589
+ createdAt: rec.createdAt ?? null,
590
+ startedAt: rec.startedAt ?? null,
591
+ exitedAt: rec.exitedAt ?? null,
592
+ exit: rec.exit ?? null,
593
+ lastError: rec.lastError ?? null,
594
+ })
595
+
596
+ /** Load the persisted registry lazily, so `apply()` performs no I/O. */
597
+ const ensureRegistry = () => {
598
+ if (registryLoaded) return
599
+ registryLoaded = true
600
+ if (!forksFile) return
601
+ const saved = readJson(forksFile, null)
602
+ const rows = Array.isArray(saved) ? saved : Array.isArray(saved?.forks) ? saved.forks : []
603
+ for (const row of rows) {
604
+ const forkId = row && String(row.forkId ?? '').trim()
605
+ if (!forkId || records.has(forkId)) continue
606
+ records.set(forkId, { ...row, forkId, running: false, child: null, starting: false, restored: true, log: null })
607
+ }
608
+ }
609
+
610
+ const persist = () => {
611
+ if (!forksFile) return false
612
+ try {
613
+ writeJsonAtomic(forksFile, { at: iso(), forks: [...records.values()].map(publicFork) })
614
+ return true
615
+ } catch (e) {
616
+ logWarn(`[fang-forker] registry write failed: ${msgOf(e)}`)
617
+ return false
618
+ }
619
+ }
620
+
621
+ // ───────────────────────────── port pool ─────────────────────────────
622
+ const portsSnapshot = () => {
623
+ const out = {}
624
+ for (const port of [...portTable.keys()].sort((a, b) => a - b)) out[String(port)] = { ...portTable.get(port) }
625
+ return out
626
+ }
627
+
628
+ const persistPorts = () => {
629
+ if (!portsFile) return false
630
+ try {
631
+ writeJsonAtomic(portsFile, portsSnapshot())
632
+ return true
633
+ } catch (e) {
634
+ logWarn(`[fang-forker] ports write failed: ${msgOf(e)}`)
635
+ return false
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Lazy load + startup reclaim. An entry is kept only when its recorded child pid is alive:
641
+ * a pid-less reservation belongs to a mother that died mid-spawn and must be reclaimed too.
642
+ */
643
+ const ensurePorts = () => {
644
+ if (portsLoaded) return
645
+ portsLoaded = true
646
+ if (!portsFile) return
647
+ const saved = readJson(portsFile, null)
648
+ const rows =
649
+ saved && typeof saved === 'object'
650
+ ? saved.ports && typeof saved.ports === 'object'
651
+ ? saved.ports
652
+ : saved
653
+ : {}
654
+ let changed = false
655
+ for (const [key, meta] of Object.entries(rows)) {
656
+ const port = Number(key)
657
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
658
+ changed = true
659
+ continue
660
+ }
661
+ const pid = meta ? Number(meta.pid) : null
662
+ if (!meta || !isPidAlive(pid)) {
663
+ changed = true
664
+ continue
665
+ }
666
+ portTable.set(port, { forkId: String(meta.forkId ?? ''), pid: Math.floor(pid), at: String(meta.at ?? '') })
667
+ }
668
+ if (changed) persistPorts()
669
+ }
670
+
671
+ /** Reserve a port for this instance (persisted with pid=null until the child exists). */
672
+ const reservePort = (port, forkId) => {
673
+ ensurePorts()
674
+ const n = Math.floor(Number(port))
675
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return { ok: false, port: null, error: 'invalid-port' }
676
+ const existing = portTable.get(n)
677
+ if (existing && !ownedPorts.has(n) && existing.forkId !== String(forkId ?? '')) {
678
+ return { ok: false, port: n, error: 'port-in-use' }
679
+ }
680
+ portTable.set(n, { forkId: String(forkId ?? ''), pid: null, at: iso() })
681
+ ownedPorts.set(n, String(forkId ?? ''))
682
+ persistPorts()
683
+ return { ok: true, port: n }
684
+ }
685
+
686
+ /** Attach the real child pid to a reservation so a later startup can prove liveness. */
687
+ const bindPortToPid = (port, forkId, pid) => {
688
+ const n = Math.floor(Number(port))
689
+ if (ownedPorts.get(n) !== forkId) return false
690
+ portTable.set(n, { forkId, pid: Number(pid) || null, at: iso() })
691
+ persistPorts()
692
+ return true
693
+ }
694
+
695
+ /** Release a port owned by this instance (never another instance's / another fork's). */
696
+ const releaseOwnedPort = (port, forkId) => {
697
+ ensurePorts()
698
+ const n = Math.floor(Number(port))
699
+ if (!Number.isInteger(n) || n < 1) return { ok: true, port: null, released: false }
700
+ const owner = ownedPorts.get(n)
701
+ if (owner === undefined || (forkId && owner !== forkId)) {
702
+ return { ok: true, port: n, released: false, reason: 'not-owned' }
703
+ }
704
+ ownedPorts.delete(n)
705
+ const entry = portTable.get(n)
706
+ if (!entry) return { ok: true, port: n, released: false }
707
+ if (forkId && entry.forkId && entry.forkId !== forkId) {
708
+ return { ok: true, port: n, released: false, reason: 'not-owner' }
709
+ }
710
+ portTable.delete(n)
711
+ persistPorts()
712
+ return { ok: true, port: n, released: true }
713
+ }
714
+
715
+ /** Public release: removes any persisted entry for the port (operator/test escape hatch). */
716
+ const releasePort = (port) => {
717
+ ensurePorts()
718
+ const n = Math.floor(Number(port))
719
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
720
+ return { ok: false, port: null, released: false, error: 'invalid-port' }
721
+ }
722
+ ownedPorts.delete(n)
723
+ const held = portTable.delete(n)
724
+ if (held) persistPorts()
725
+ return { ok: true, port: n, released: held }
726
+ }
727
+
728
+ /**
729
+ * Allocate one deterministic port from the pool. A candidate is usable only when the
730
+ * loopback bind test succeeds; occupied candidates are skipped. Pool exhausted → error.
731
+ * @returns {Promise<{ok:boolean, port:number|null, reason:string, error?:string, pool?:object}>}
732
+ */
733
+ const allocatePort = async (opts = {}) => {
734
+ ensurePorts()
735
+ const forkId = String(opts.forkId ?? '').trim()
736
+ const { start, end } = portPool
737
+ for (let port = start; port <= end; port++) {
738
+ if (portTable.has(port)) continue
739
+ let free = false
740
+ try {
741
+ free = await probePortFree(port)
742
+ } catch {
743
+ free = false
744
+ }
745
+ if (!free) continue
746
+ portTable.set(port, { forkId, pid: null, at: iso() })
747
+ ownedPorts.set(port, forkId)
748
+ persistPorts()
749
+ return { ok: true, port, forkId, reason: '' }
750
+ }
751
+ return {
752
+ ok: false,
753
+ port: null,
754
+ forkId,
755
+ reason: 'port-exhausted',
756
+ error: 'port-exhausted',
757
+ pool: { ...portPool },
758
+ }
759
+ }
760
+
761
+ /** Windows: taskkill /PID <pid> /T /F kills the whole tree (electron children too). */
762
+ const killTree = (rec) => {
763
+ const pid = rec?.pid ?? rec?.child?.pid ?? null
764
+ if (process.platform === 'win32' && pid) {
765
+ try {
766
+ const res = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 10000 })
767
+ if (res && res.status === 0) return true
768
+ } catch {
769
+ /* fall through to child.kill */
770
+ }
771
+ }
772
+ try {
773
+ if (rec?.child && typeof rec.child.kill === 'function') {
774
+ rec.child.kill('SIGTERM')
775
+ return true
776
+ }
777
+ } catch {
778
+ /* already gone */
779
+ }
780
+ return false
781
+ }
782
+
783
+ const isSafeToRemove = (homeDir) => {
784
+ const resolved = path.resolve(homeDir)
785
+ const root = path.parse(resolved).root
786
+ return resolved !== root && resolved.length > root.length + 1
787
+ }
788
+
789
+ // ───────────────────────────── clean template ─────────────────────────────
790
+ /**
791
+ * Write a clean fork profile template (CONTRACTS-V2.md §5.5):
792
+ * package.json { name, private, dsh.profile.bundles:[dsh-base,dsh-web-app,bundleName] }
793
+ * cordis.yml '[]'
794
+ * cordis.patch.yml '[]'
795
+ * node_modules junction → nodeModulesSource (absent source → warning, still ok)
796
+ * Idempotent: same content is rewritten, an existing junction is left untouched.
797
+ * @returns {{ok:boolean, dir:string, files:string[], warnings:string[], error?:string}}
798
+ */
799
+ const ensureTemplate = (opts = {}) => {
800
+ const rawDir = String(opts.templateDir ?? '').trim()
801
+ if (!rawDir) return { ok: false, dir: '', files: [], warnings: [], error: 'no-template-dir' }
802
+ const templateDir = path.resolve(rawDir)
803
+ const files = []
804
+ const warnings = []
805
+ try {
806
+ ensureDir(templateDir)
807
+ writeJsonAtomic(
808
+ path.join(templateDir, 'package.json'),
809
+ buildTemplatePackageJson({ bundleName: cfg.bundleName, forkProfileTag: cfg.forkProfileTag }),
810
+ )
811
+ files.push('package.json')
812
+ for (const name of ['cordis.yml', 'cordis.patch.yml']) {
813
+ writeFileAtomic(path.join(templateDir, name), name === 'cordis.patch.yml' ? WELCOME_NOTICE_PATCH : '[]\n')
814
+ files.push(name)
815
+ }
816
+ const srcRaw = String(opts.nodeModulesSource ?? '').trim()
817
+ const source = srcRaw ? path.resolve(srcRaw) : ''
818
+ const link = path.join(templateDir, 'node_modules')
819
+ if (source && exists(source)) {
820
+ try {
821
+ // v3.1.7: per-entry mirror instead of one whole-tree junction — see fsx.mirrorModuleTree.
822
+ const mirrored = mirrorModuleTree(source, link)
823
+ if (mirrored.ok !== true) warnings.push(`node-modules-mirror-failed: ${mirrored.error ?? 'unknown'}`)
824
+ else files.push('node_modules')
825
+ } catch (e) {
826
+ warnings.push(`node-modules-mirror-failed: ${msgOf(e)}`)
827
+ }
828
+ } else {
829
+ warnings.push('node-modules-source-missing')
830
+ }
831
+ return { ok: true, dir: templateDir, files, warnings }
832
+ } catch (e) {
833
+ return { ok: false, dir: templateDir, files, warnings, error: 'template-write-failed', message: msgOf(e) }
834
+ }
835
+ }
836
+
837
+ /** Clone exactly package.json + cordis*.yml from a template into the fork profile dir. */
838
+ const cloneProfileConfig = (fromDir, toDir) => {
839
+ const wanted = listFiles(fromDir).filter((rel) => !rel.includes('/') && CLONED_RE.test(rel))
840
+ copyTree(fromDir, toDir, { filter: (entryName) => CLONED_RE.test(entryName) })
841
+ return wanted.filter((rel) => exists(path.join(toDir, rel)))
842
+ }
843
+
844
+ // ───────────────────────────── provision ─────────────────────────────
845
+ /**
846
+ * Build homeDir/profiles/<profile>.
847
+ * cleanTemplate=true: the clone source is a forker-owned clean template
848
+ * (<homeDir>/templates/<forkProfileTag>); only package.json + cordis*.yml are cloned and
849
+ * the mother contributes the node_modules junction source only — a dirty mother profile
850
+ * can never leak in. cleanTemplate=false: v1 behaviour (clone from profileTemplate).
851
+ * @returns {{ok:boolean, forkId?:string, homeDir?:string, profileDir?:string, bufferDir?:string,
852
+ * profile?:string, template?:string, cloned?:string[], node_modules?:string|null,
853
+ * warnings?:string[], error?:string}}
854
+ */
855
+ const provision = (opts = {}) => {
856
+ ensureRegistry()
857
+ const homeRaw = String(opts.homeDir ?? '').trim()
858
+ if (!homeRaw) return { ok: false, error: 'no-home-dir' }
859
+ const templateRaw = String(opts.profileTemplate ?? cfg.profileTemplate ?? '').trim()
860
+ if (!templateRaw) return { ok: false, error: 'no-profile-template' }
861
+ const sourceTemplateDir = path.resolve(templateRaw)
862
+ if (!exists(sourceTemplateDir)) return { ok: false, error: 'profile-template-missing', profileTemplate: sourceTemplateDir }
863
+
864
+ const forkId = String(opts.forkId ?? '').trim() || newId('fork')
865
+ const existing = records.get(forkId)
866
+ if (existing && (existing.running || existing.starting)) return { ok: false, error: 'already-running', forkId }
867
+
868
+ const homeDir = path.resolve(homeRaw)
869
+ const profile = safeProfile(opts.profile ?? (cleanTemplate ? forkProfileTag : path.basename(sourceTemplateDir)))
870
+ const profileDir = path.join(homeDir, 'profiles', profile)
871
+ // v3.3.0 主城门禁(新建 profile):分身/元主运行态首次落盘前必须过路径/磁盘/门禁。
872
+ // 已存在的 profile 走 restart 路径,不重复新建检查。
873
+ if (!exists(profileDir)) {
874
+ const gate = runMainGate({
875
+ target: 'profile',
876
+ homeDir,
877
+ profile,
878
+ profileDir,
879
+ create: true,
880
+ force: false,
881
+ })
882
+ if (!gate.ok) {
883
+ return { ok: false, error: 'main-city-gate-blocked', detail: formatGateFailure(gate), gate }
884
+ }
885
+ }
886
+ ensureDir(homeDir)
887
+ ensureDir(profileDir)
888
+
889
+ const warnings = []
890
+ // 显式 profileTemplate(例如原身 merged/profile)优先:此时不再用 clean template。
891
+ const useCleanTemplate = typeof opts.cleanTemplate === 'boolean' ? opts.cleanTemplate : cleanTemplate
892
+ const nodeModulesSource = String(opts.nodeModulesSource ?? '').trim() || path.join(sourceTemplateDir, 'node_modules')
893
+ let template = sourceTemplateDir
894
+ let cloned = []
895
+
896
+ if (useCleanTemplate) {
897
+ // Forker-owned clean template: never written into the mother profile directory.
898
+ const cleanDir = path.join(homeDir, 'templates', forkProfileTag)
899
+ const ensured = ensureTemplate({
900
+ templateDir: cleanDir,
901
+ nodeModulesSource: exists(nodeModulesSource) ? nodeModulesSource : '',
902
+ })
903
+ if (!ensured.ok) {
904
+ return {
905
+ ok: false,
906
+ error: ensured.error ?? 'clean-template-failed',
907
+ template: ensured.dir,
908
+ warnings: ensured.warnings,
909
+ }
910
+ }
911
+ template = ensured.dir
912
+ warnings.push(...ensured.warnings)
913
+ cloned = cloneProfileConfig(template, profileDir)
914
+ } else {
915
+ cloned = cloneProfileConfig(sourceTemplateDir, profileDir)
916
+ }
917
+ // 生产盘锁定:关闭插件安装 / 设置 UI(派生时写入 profile patch)。
918
+ // essence-runtime 是元宝的“配置态实例”,必须保留插件管理能力,不能套生产盘锁。
919
+ const configInstance = path.basename(String(profileDir)) === 'essence-runtime'
920
+ const configLock = configInstance ? { ok: true, skipped: true, runtime: true } : applyForkConfigLock(profileDir)
921
+ if (configLock.ok !== true) warnings.push('fork-config-lock-failed')
922
+ // 配置文件只读:防误写(DSH_FANG_FORK_PROFILE_RO=0 可关)。
923
+ const profileReadonly = setProfileConfigReadonly(profileDir, true)
924
+
925
+ // node_modules mirror in the fork profile (always to the mother's module tree).
926
+ // v3.1.7: one junction per top-level entry instead of a whole-tree junction. DSH's
927
+ // profile-scope resolver only supplies installation packages (`@deepseek-ai/*`) to an
928
+ // importer inside the profile tree or below a linked root; a whole-tree junction keeps
929
+ // the importer outside every scope, so lazy host imports fall back to plugin shims
930
+ // (w2m's shim emits `required: false` in a property schema → the provider rejects
931
+ // every request: "Invalid schema for function 'w2m_report'", v3.1.7 report).
932
+ let nodeModules = null
933
+ if (exists(nodeModulesSource)) {
934
+ try {
935
+ const mirrored = mirrorModuleTree(nodeModulesSource, path.join(profileDir, 'node_modules'))
936
+ if (mirrored.ok === true) nodeModules = nodeModulesSource
937
+ else warnings.push(`node-modules-mirror-failed: ${mirrored.error ?? 'unknown'}`)
938
+ } catch (e) {
939
+ warnings.push(`node-modules-mirror-failed: ${msgOf(e)}`)
940
+ }
941
+ } else if (!cleanTemplate) {
942
+ // Degraded but still usable: the fork simply has no node_modules link to the mother.
943
+ warnings.push('node-modules-missing')
944
+ }
945
+
946
+ const bufferDir = path.join(homeDir, 'fang', profile, 'buffer')
947
+ ensureDir(bufferDir)
948
+
949
+ const rec = existing ?? { forkId, createdAt: nowMs() }
950
+ Object.assign(rec, {
951
+ forkId,
952
+ taskTitle: String(opts.taskTitle ?? rec.taskTitle ?? ''),
953
+ homeDir,
954
+ profile,
955
+ profileDir,
956
+ bufferDir,
957
+ template,
958
+ nodeModules,
959
+ pid: rec.pid ?? null,
960
+ port: rec.port ?? null,
961
+ url: rec.url ?? null,
962
+ child: rec.child ?? null,
963
+ running: false,
964
+ starting: false,
965
+ status: FORK_STATUS.PROVISIONING,
966
+ exit: null,
967
+ lastError: null,
968
+ })
969
+ records.set(forkId, rec)
970
+ persist()
971
+ audit(AUDIT.FORK_CREATED, { forkId, homeDir, profile, taskTitle: rec.taskTitle, cloned, warnings, template })
972
+ return { ok: true, forkId, homeDir, profileDir, bufferDir, profile, template, cloned, node_modules: nodeModules, warnings, configLock, profileReadonly }
973
+ }
974
+
975
+ // ───────────────────────────── spawn ─────────────────────────────
976
+ /**
977
+ * Launch the fork and resolve once stdout printed the tokenized web URL.
978
+ * v2: with allocatePorts the child is told `--port <pool port>` and the printed URL port
979
+ * must match it; a mismatch stops the child ({ok:false,error:'port-mismatch'}).
980
+ * @returns {Promise<{ok:boolean, forkId?:string, pid?:number|null, port?:number, url?:string,
981
+ * token?:string, error?:string, expectedPort?:number}>}
982
+ */
983
+ const writeForkMetaOf = (rec) => {
984
+ if (!rec?.homeDir) return false
985
+ return writeForkMeta(rec.homeDir, {
986
+ forkId: rec.forkId, status: rec.status, pid: rec.pid ?? null, port: rec.port ?? null,
987
+ url: rec.url ?? null, profile: rec.profile ?? null,
988
+ crashCount: rec.crashCount ?? 0, restartCount: rec.restartCount ?? 0,
989
+ lastError: rec.lastError ?? null, startedAt: rec.startedAt ?? null,
990
+ exitedAt: rec.exitedAt ?? null, restartScheduledAt: rec.restartScheduledAt ?? null,
991
+ })
992
+ }
993
+
994
+ const spawn = async (opts = {}) => {
995
+ ensureRegistry()
996
+ ensurePorts()
997
+ const forkId = String(opts.forkId ?? '').trim() || newId('fork')
998
+ const homeDir = String(opts.homeDir ?? '').trim()
999
+ const profile = safeProfile(opts.profile ?? 'default')
1000
+ const cliPath = String(opts.cliPath ?? cfg.cliPath ?? '').trim()
1001
+ const timeoutMs = Math.max(1, Math.floor(Number(opts.timeoutMs ?? cfg.startTimeoutMs) || DEFAULTS.startTimeoutMs))
1002
+ const maxLogLines = Math.max(1, Math.floor(Number(cfg.maxLogLines) || DEFAULTS.maxLogLines))
1003
+
1004
+ if (!homeDir) return { forkId, ok: false, error: 'no-home-dir' }
1005
+ if (!cliPath) return { forkId, ok: false, error: 'no-cli-path' }
1006
+ const previous = records.get(forkId)
1007
+ if (previous && (previous.running || previous.child || previous.starting)) {
1008
+ return { forkId, ok: false, error: 'already-running', pid: previous.pid ?? null }
1009
+ }
1010
+ const maxForks = Math.max(1, Math.floor(Number(cfg.maxForks) || DEFAULTS.maxForks))
1011
+ if (starting() >= maxForks) return { forkId, ok: false, error: 'max-forks', limit: maxForks }
1012
+
1013
+ const collector = createTextTail(maxLogLines)
1014
+ const rec = previous ?? { forkId, createdAt: nowMs(), taskTitle: '' }
1015
+ Object.assign(rec, {
1016
+ forkId,
1017
+ homeDir,
1018
+ profile,
1019
+ profileDir: rec.profileDir ?? path.join(path.resolve(homeDir), 'profiles', profile),
1020
+ pid: null,
1021
+ port: null,
1022
+ url: null,
1023
+ child: null,
1024
+ running: false,
1025
+ starting: true,
1026
+ status: FORK_STATUS.PROVISIONING,
1027
+ exit: null,
1028
+ lastError: null,
1029
+ intentionalStop: false,
1030
+ startedAt: nowMs(),
1031
+ ready: false,
1032
+ cliPath,
1033
+ crashCount: rec.crashCount ?? 0,
1034
+ restartCount: rec.restartCount ?? 0,
1035
+ restartScheduledAt: null,
1036
+ /**
1037
+ * v3 Adapter Bearer token:**每次 spawn 重新生成**,只存在于母体内存 + 分身环境变量。
1038
+ * 不写 fang-meta.json、不进 forks.json(publicFork 不含)、不写 URL —— 母体重启后
1039
+ * 旧 token 自然失效,孤儿分身不会被接管(PROTOCOL-V3 §3)。
1040
+ */
1041
+ adapterToken: randomBytes(24).toString('hex'),
1042
+ log: collector,
1043
+ })
1044
+ records.set(forkId, rec)
1045
+ // v3.0.9 生产盘:每次打开/重启都从来源模板重放配置(原身 merged 或 clean template),
1046
+ // 分身自己的改动不保存;sessions/storages/buffer 与 node_modules junction 保持不动。
1047
+ const templateDirForRec = String(rec.template ?? '').trim()
1048
+ if (templateDirForRec && exists(templateDirForRec) && exists(rec.profileDir)) {
1049
+ try {
1050
+ // 重放前先解锁,否则复制会因只读失败。
1051
+ setProfileConfigReadonly(rec.profileDir, false)
1052
+ cloneProfileConfig(templateDirForRec, rec.profileDir)
1053
+ const settingsSrc = path.join(templateDirForRec, 'settings')
1054
+ if (exists(settingsSrc)) {
1055
+ const settingsDst = path.join(rec.profileDir, 'settings')
1056
+ if (exists(settingsDst)) removeTree(settingsDst)
1057
+ copyTree(settingsSrc, settingsDst)
1058
+ }
1059
+ rec.configSync = { at: nowMs(), from: templateDirForRec }
1060
+ } catch (e) {
1061
+ rec.configSync = { at: nowMs(), ok: false, error: msgOf(e) }
1062
+ }
1063
+ }
1064
+ try { rec.configLock = applyForkConfigLock(rec.profileDir) } catch { rec.configLock = { ok: false } }
1065
+ try { rec.profileReadonly = setProfileConfigReadonly(rec.profileDir, true) } catch { rec.profileReadonly = { ok: false } }
1066
+ // v3.1.7: repair a whole-tree `node_modules` junction left by any previous release into
1067
+ // the per-entry mirror. This is what makes the fork host resolve the installation's
1068
+ // `@deepseek-ai/*` packages for profile plugins again; without it every agent request in
1069
+ // the fork is rejected by the provider (w2m offline shim → invalid tool schema).
1070
+ try {
1071
+ const repaired = ensureModuleMirror(path.join(rec.profileDir, 'node_modules'))
1072
+ rec.nodeModulesMirror = {
1073
+ changed: repaired.changed === true,
1074
+ ok: repaired.ok !== false,
1075
+ source: repaired.source ?? null,
1076
+ links: Array.isArray(repaired.links) ? repaired.links.length : 0,
1077
+ error: repaired.error ?? null,
1078
+ }
1079
+ } catch (e) {
1080
+ rec.nodeModulesMirror = { ok: false, changed: false, error: msgOf(e) }
1081
+ }
1082
+ // v3.1.12:默认第三方插件种子。install=只安装;activate=安装并激活(写入 bundles)。
1083
+ // 种子安装在独立 seed home(dsh 自带 pnpm),模块以 hardlink 合并进分身 node_modules;
1084
+ // 同一指纹只装一次。失败不阻塞分身启动,但结果保留在 rec.forkSeed 供 UI 诊断。
1085
+ rec.forkSeed = null
1086
+ if (cfg.forkSeed?.enabled !== false && !String(profile).startsWith('essence-') && process.env.DSH_FANG_FORK_SEED !== '0') {
1087
+ const seedRoot = path.join(path.dirname(path.resolve(homeDir)), '.plugin-seed')
1088
+ try { setProfileConfigReadonly(rec.profileDir, false) } catch { /* ignore */ }
1089
+ try {
1090
+ rec.forkSeed = await applyForkSeedToProfile({
1091
+ seed: cfg.forkSeed ?? DEFAULT_FORK_SEED,
1092
+ profileDir: rec.profileDir,
1093
+ seedRoot,
1094
+ cliPath,
1095
+ logger: ctx?.logger,
1096
+ })
1097
+ } catch (e) {
1098
+ rec.forkSeed = { ok: false, error: 'fork-seed-threw', detail: msgOf(e) }
1099
+ } finally {
1100
+ try { setProfileConfigReadonly(rec.profileDir, true) } catch { /* ignore */ }
1101
+ }
1102
+ if (rec.forkSeed?.ok === false) {
1103
+ audit(AUDIT.FORK_CREATED, { forkId, forkSeedError: rec.forkSeed.error, forkSeedDetail: String(rec.forkSeed.detail ?? '').slice(0, 600) })
1104
+ }
1105
+ }
1106
+ writeForkMetaOf(rec)
1107
+ // 广播新 token:首次派生 = 生成,重启 = 刷新;订阅者拿到的是「当前 token」,
1108
+ // 不是「进程就绪」信号(进程就绪以 fang/fork-started 为准)。
1109
+ emitTokenRefresh(forkId, rec.adapterToken)
1110
+
1111
+ // Port: explicit spawn option > deterministic pool allocation > v1 --port 0.
1112
+ const requested = Math.floor(Number(opts.port))
1113
+ let expectedPort = 0
1114
+ if (Number.isInteger(requested) && requested > 0 && requested <= 65535) {
1115
+ const reserved = reservePort(requested, forkId)
1116
+ if (!reserved.ok) {
1117
+ rec.starting = false
1118
+ rec.status = FORK_STATUS.STOPPED
1119
+ rec.lastError = reserved.error ?? 'port-in-use'
1120
+ persist()
1121
+ return { forkId, ok: false, error: rec.lastError }
1122
+ }
1123
+ expectedPort = reserved.port
1124
+ } else if (allocatePorts) {
1125
+ const alloc = await allocatePort({ forkId })
1126
+ if (!alloc.ok) {
1127
+ rec.starting = false
1128
+ rec.status = FORK_STATUS.STOPPED
1129
+ rec.lastError = alloc.error ?? 'port-exhausted'
1130
+ persist()
1131
+ return { forkId, ok: false, error: rec.lastError, reason: alloc.reason, pool: { ...portPool } }
1132
+ }
1133
+ expectedPort = alloc.port
1134
+ }
1135
+ if (expectedPort > 0) rec.port = expectedPort
1136
+ if (disposed) {
1137
+ rec.starting = false
1138
+ if (expectedPort > 0) releaseOwnedPort(expectedPort, forkId)
1139
+ return { forkId, ok: false, error: 'disposed' }
1140
+ }
1141
+
1142
+ return new Promise((resolve) => {
1143
+ let done = false
1144
+ let timer = null
1145
+ let cleanup = null
1146
+
1147
+ const releaseReserved = () => {
1148
+ if (expectedPort > 0) releaseOwnedPort(expectedPort, forkId)
1149
+ }
1150
+
1151
+ const finish = (result) => {
1152
+ if (done) return false
1153
+ done = true
1154
+ rec.starting = false
1155
+ if (timer) {
1156
+ clearTimeout(timer)
1157
+ timer = null
1158
+ }
1159
+ if (cleanup) pendingSpawns.delete(cleanup)
1160
+ resolve({ forkId, ...result })
1161
+ return true
1162
+ }
1163
+
1164
+ cleanup = () => {
1165
+ if (done) return
1166
+ rec.intentionalStop = true
1167
+ killTree(rec)
1168
+ rec.running = false
1169
+ rec.child = null
1170
+ rec.starting = false
1171
+ releaseReserved()
1172
+ finish({ ok: false, error: 'disposed' })
1173
+ }
1174
+ pendingSpawns.add(cleanup)
1175
+
1176
+ // v3.1.7: the headless web host (`dsh.cmd --no-open`, ELECTRON_RUN_AS_NODE) has no
1177
+ // window for the native directory chooser; pin the browse interaction through a
1178
+ // launcher overlay so "select workspace" works inside every fork Web UI.
1179
+ const patchPaths = []
1180
+ const headlessOverlay = writeHeadlessPickerOverlay(rec.profileDir, { cliPath })
1181
+ if (headlessOverlay) patchPaths.push(headlessOverlay)
1182
+ const spec = buildLaunchSpec({ cliPath, profile, port: expectedPort, patchPaths })
1183
+ const env = buildForkChildEnv(process.env, {
1184
+ DSH_HOME: homeDir,
1185
+ // Fork identity is injected by the forker (a fork must never be able to call itself mother).
1186
+ DSH_FANG_ROLE: 'fork',
1187
+ DSH_FANG_TASK_ID: forkId,
1188
+ ...(opts.env ?? {}),
1189
+ // v3:Adapter Bearer token(PROTOCOL-V3 §3)。放在 opts.env 之后,母体生成的 token 优先。
1190
+ DSH_ADAPTER_TOKEN: rec.adapterToken,
1191
+ })
1192
+ let child = null
1193
+ try {
1194
+ child = spawnProcess(spec.command, spec.args, {
1195
+ env,
1196
+ ...(exists(homeDir) ? { cwd: homeDir } : {}),
1197
+ windowsHide: spec.windowsHide,
1198
+ shell: spec.shell,
1199
+ stdio: ['ignore', 'pipe', 'pipe'],
1200
+ })
1201
+ } catch (e) {
1202
+ rec.status = FORK_STATUS.CRASHED
1203
+ rec.lastError = 'spawn-error'
1204
+ rec.exit = { kind: 'crash', code: null, message: msgOf(e) }
1205
+ releaseReserved()
1206
+ persist()
1207
+ return finish({ ok: false, error: 'spawn-error', message: msgOf(e), spec })
1208
+ }
1209
+
1210
+ rec.child = child
1211
+ rec.pid = child.pid ?? null
1212
+ if (expectedPort > 0 && rec.pid) bindPortToPid(expectedPort, forkId, rec.pid)
1213
+ // Note: on a failed launch (ENOENT/EINVAL) `child.pid` is undefined and the
1214
+ // `error` event arrives on the next tick — never resolve early here, or that
1215
+ // event would have no listener and surface as an uncaughtException.
1216
+
1217
+ const onData = (chunk) => {
1218
+ collector.push(chunk)
1219
+ if (rec.url) return
1220
+ const found = parseWebUrl(collector.text)
1221
+ if (!found) return
1222
+ // v2: the deterministic port is only trusted when the child really bound it.
1223
+ if (expectedPort > 0 && found.port !== expectedPort) {
1224
+ const pid = rec.pid
1225
+ rec.lastError = 'port-mismatch'
1226
+ const stopped = stopRecord(rec)
1227
+ persist()
1228
+ finish({
1229
+ ok: false,
1230
+ error: 'port-mismatch',
1231
+ expectedPort,
1232
+ pid,
1233
+ port: found.port,
1234
+ url: found.url,
1235
+ killed: stopped.killed,
1236
+ })
1237
+ return
1238
+ }
1239
+ rec.url = found.url
1240
+ rec.port = found.port
1241
+ rec.token = found.token ?? null
1242
+ rec.status = FORK_STATUS.RUNNING
1243
+ rec.running = true
1244
+ rec.starting = false
1245
+ rec.ready = true
1246
+ rec.startedAt = nowMs()
1247
+ writeForkMetaOf(rec)
1248
+ if (expectedPort > 0 && rec.pid) bindPortToPid(expectedPort, forkId, rec.pid)
1249
+ persist()
1250
+ emit(EVENTS.started, { forkId, pid: rec.pid, port: rec.port })
1251
+ audit(AUDIT.FORK_STARTED, { forkId, pid: rec.pid, port: rec.port, url: rec.url, profile })
1252
+ finish({
1253
+ ok: true,
1254
+ pid: rec.pid,
1255
+ port: rec.port,
1256
+ url: rec.url,
1257
+ token: rec.token,
1258
+ ...(expectedPort > 0 ? { allocatedPort: expectedPort } : {}),
1259
+ ...(rec.forkSeed ? { forkSeed: rec.forkSeed } : {}),
1260
+ })
1261
+ }
1262
+ // The tokenized banner is normally on stdout; scanning stderr too costs nothing.
1263
+ try {
1264
+ child.stdout?.on('data', onData)
1265
+ child.stderr?.on('data', onData)
1266
+ } catch {
1267
+ /* pipes unavailable: the timeout path below still settles the promise */
1268
+ }
1269
+
1270
+ child.once('error', (e) => {
1271
+ rec.running = false
1272
+ rec.child = null
1273
+ rec.starting = false
1274
+ rec.status = FORK_STATUS.CRASHED
1275
+ rec.lastError = msgOf(e)
1276
+ rec.exit = { kind: 'crash', code: null, message: msgOf(e) }
1277
+ releaseReserved()
1278
+ persist()
1279
+ finish({ ok: false, error: 'spawn-error', message: msgOf(e), log: collector.tail(20) })
1280
+ })
1281
+
1282
+ child.once('exit', (code, signal) => {
1283
+ const exitInfo = classifyExit(code, signal)
1284
+ rec.exit = exitInfo
1285
+ rec.exitedAt = nowMs()
1286
+ rec.running = false
1287
+ rec.child = null
1288
+ rec.starting = false
1289
+ if (rec.intentionalStop) rec.status = FORK_STATUS.STOPPED
1290
+ else {
1291
+ rec.status = FORK_STATUS.CRASHED
1292
+ emit(EVENTS.crashed, { forkId, code: exitInfo.code, kind: exitInfo.kind })
1293
+ audit(AUDIT.FORK_CRASHED, { forkId, code: exitInfo.code, kind: exitInfo.kind, signal })
1294
+ }
1295
+ // A dead child never keeps a deterministic port: release it on every exit path.
1296
+ releaseReserved()
1297
+ if (!rec.intentionalStop) rec.crashCount = (rec.crashCount ?? 0) + 1
1298
+ // A reap may already have deleted the record + home; never resurrect it from the exit handler.
1299
+ if (records.get(forkId) === rec) {
1300
+ writeForkMetaOf(rec)
1301
+ persist()
1302
+ }
1303
+ if (!rec.intentionalStop && runtimeSupervise && !disposed) {
1304
+ const plan = planRestart(exitInfo, rec, {
1305
+ maxRestarts: cfg.maxRestarts, baseMs: cfg.backoffBaseMs,
1306
+ maxMs: cfg.backoffMaxMs, jitter: cfg.backoffJitter,
1307
+ })
1308
+ if (plan.action === 'restart' && rec.cliPath && records.get(forkId) === rec) {
1309
+ rec.restartCount = (rec.restartCount ?? 0) + 1
1310
+ rec.restartScheduledAt = nowMs() + plan.delayMs
1311
+ rec.status = FORK_STATUS.PROVISIONING
1312
+ rec.lastError = `crash-restart-in-${plan.delayMs}ms`
1313
+ writeForkMetaOf(rec)
1314
+ rec.restartTimer = setTimeout(() => { rec.restartTimer = null; void restartFork(rec) }, plan.delayMs)
1315
+ audit(AUDIT.FORK_CRASHED, { forkId, restartInMs: plan.delayMs, attempt: plan.attempt, cause: plan.reason })
1316
+ } else if (plan.action === 'give-up') {
1317
+ rec.lastError = `restart-given-up:${plan.reason}`
1318
+ writeForkMetaOf(rec)
1319
+ audit(AUDIT.FORK_CRASHED, { forkId, restartGivenUp: plan.reason })
1320
+ // 放弃重启 = 终态:不会再有自动重试,订阅者据此 fail-fast。
1321
+ emit(EVENTS.restartFailed, { forkId, error: rec.lastError, terminal: true })
1322
+ }
1323
+ }
1324
+ finish({ ok: false, error: 'exited', code: exitInfo.code, signal, kind: exitInfo.kind, log: collector.tail(20) })
1325
+ })
1326
+
1327
+ timer = setTimeout(() => {
1328
+ rec.lastError = 'timeout'
1329
+ const stopped = stopRecord(rec)
1330
+ persist()
1331
+ finish({
1332
+ ok: false,
1333
+ error: 'timeout',
1334
+ pid: rec.pid,
1335
+ timeoutMs,
1336
+ killed: stopped.killed,
1337
+ log: collector.tail(40),
1338
+ })
1339
+ }, timeoutMs)
1340
+ })
1341
+ }
1342
+
1343
+ // ───────────────────────────── stop / reap ─────────────────────────────
1344
+ /** Shared stop primitive: kill the tree, mark stopped, release the port. Idempotent. */
1345
+ const stopRecord = (rec) => {
1346
+ rec.intentionalStop = true
1347
+ if (rec.restartTimer) { clearTimeout(rec.restartTimer); rec.restartTimer = null }
1348
+ rec.restartScheduledAt = null
1349
+ rec.ready = false
1350
+ // token 随进程死亡作废:绝不留给下一次 spawn 复用(restartFork 会重新生成)。
1351
+ rec.adapterToken = null
1352
+ const wasRunning = !!rec.running || !!rec.child || !!rec.starting
1353
+ const pid = rec.pid ?? rec.child?.pid ?? null
1354
+ if (wasRunning) {
1355
+ killTree(rec)
1356
+ rec.running = false
1357
+ rec.child = null
1358
+ rec.starting = false
1359
+ rec.status = FORK_STATUS.STOPPED
1360
+ rec.exitedAt = nowMs()
1361
+ } else if (rec.status !== FORK_STATUS.REAPED) {
1362
+ rec.status = FORK_STATUS.STOPPED
1363
+ }
1364
+ if (rec.port) releaseOwnedPort(rec.port, rec.forkId)
1365
+ return { ok: true, forkId: rec.forkId, pid, killed: wasRunning }
1366
+ }
1367
+
1368
+ /** Kill the process tree. Idempotent: an already-dead fork returns ok with killed:false. */
1369
+ const stop = (forkId) => {
1370
+ ensureRegistry()
1371
+ ensurePorts()
1372
+ const id = String(forkId ?? '').trim()
1373
+ const rec = records.get(id)
1374
+ if (!rec) return { ok: false, error: 'not-found', forkId: id }
1375
+ const res = stopRecord(rec)
1376
+ persist()
1377
+ return res
1378
+ }
1379
+
1380
+ /** stop + removeTree(homeDir) + registry delete. removeTree is junction-safe. */
1381
+ const reap = (forkId) => {
1382
+ ensureRegistry()
1383
+ ensurePorts()
1384
+ const id = String(forkId ?? '').trim()
1385
+ const rec = records.get(id)
1386
+ if (!rec) return { ok: false, error: 'not-found', forkId: id }
1387
+ stop(id)
1388
+ rec.status = FORK_STATUS.REAPED
1389
+ writeForkMetaOf(rec)
1390
+ const homeDir = rec.homeDir ? path.resolve(rec.homeDir) : ''
1391
+ let removed = false
1392
+ if (homeDir && isSafeToRemove(homeDir)) removed = exists(homeDir) ? removeTree(homeDir) : false
1393
+ records.delete(id)
1394
+ persist()
1395
+ emit(EVENTS.reaped, { forkId: id })
1396
+ audit(AUDIT.FORK_REAPED, { forkId: id, homeDir, removed })
1397
+ return { ok: true, forkId: id, removed, homeDir }
1398
+ }
1399
+
1400
+ // ───────────────────────────── introspection ─────────────────────────────
1401
+ const status = (forkId) => {
1402
+ ensureRegistry()
1403
+ const id = String(forkId ?? '').trim()
1404
+ const rec = records.get(id)
1405
+ if (!rec) return { forkId: id, running: false, pid: null, error: 'not-found' }
1406
+ return publicFork(rec)
1407
+ }
1408
+
1409
+ const list = () => {
1410
+ ensureRegistry()
1411
+ return [...records.values()]
1412
+ .map(publicFork)
1413
+ .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.forkId).localeCompare(String(b.forkId)))
1414
+ }
1415
+
1416
+ const logs = (forkId, tailLines = 40) => {
1417
+ ensureRegistry()
1418
+ const rec = records.get(String(forkId ?? '').trim())
1419
+ if (!rec || !rec.log) return ''
1420
+ return rec.log.tail(tailLines)
1421
+ }
1422
+
1423
+ /**
1424
+ * v3 Adapter Bearer token(内存 only)。母体用它给 Remote/AdapterClient 加
1425
+ * `Authorization: Bearer`;**不要**把它塞进 status()/publicFork()(那会随
1426
+ * `/fang/api/status` 暴露给浏览器,也会落 forks.json)。
1427
+ * @returns {string} fork 不存在或已停止时为空串
1428
+ */
1429
+ const adapterToken = (forkId) => {
1430
+ ensureRegistry()
1431
+ const rec = records.get(String(forkId ?? '').trim())
1432
+ return typeof rec?.adapterToken === 'string' ? rec.adapterToken : ''
1433
+ }
1434
+
1435
+ /**
1436
+ * v3.0.5/B4:只在内存中返回带 token 的 Web URL,用于“打开分身”。
1437
+ * 该值绝不落盘、不进入 publicFork/list/status。
1438
+ */
1439
+ const openUrl = (forkId) => {
1440
+ ensureRegistry()
1441
+ const rec = records.get(String(forkId ?? '').trim())
1442
+ if (!rec || !rec.running || !rec.url) return { ok: false, error: 'not-running' }
1443
+ return { ok: true, forkId: rec.forkId, url: rec.url, port: rec.port ?? null }
1444
+ }
1445
+
1446
+ /** Re-spawn a crashed-but-ready fork after the backoff timer fires. */
1447
+ const restartFork = async (rec) => {
1448
+ if (disposed || rec.intentionalStop || records.get(rec.forkId) !== rec) return
1449
+ if (!rec.cliPath) {
1450
+ rec.lastError = 'restart-no-cli'
1451
+ writeForkMetaOf(rec)
1452
+ emit(EVENTS.restartFailed, { forkId: rec.forkId, error: 'restart-no-cli', terminal: true })
1453
+ return
1454
+ }
1455
+ rec.ready = false
1456
+ const out = await spawn({
1457
+ forkId: rec.forkId, homeDir: rec.homeDir, profile: rec.profile,
1458
+ cliPath: rec.cliPath, taskTitle: rec.taskTitle ?? '',
1459
+ })
1460
+ if (!out.ok && !disposed && records.get(rec.forkId) === rec) {
1461
+ rec.lastError = `restart-failed:${out.error}`
1462
+ writeForkMetaOf(rec)
1463
+ // 自动重启尝试失败:Forker 可能还会按 supervise 计划再试,因此 terminal=false。
1464
+ emit(EVENTS.restartFailed, {
1465
+ forkId: rec.forkId,
1466
+ error: String(out.error ?? 'restart-failed'),
1467
+ terminal: false,
1468
+ })
1469
+ }
1470
+ }
1471
+
1472
+ /** HTTP liveness for a fork (any response counts); process state is reported separately. */
1473
+ const health = async (forkId) => {
1474
+ ensureRegistry()
1475
+ ensurePorts()
1476
+ const id = String(forkId ?? '').trim()
1477
+ const rec = records.get(id)
1478
+ if (!rec) return { ok: false, error: 'not-found', forkId: id }
1479
+ const alive = !!rec.running && !!rec.child && rec.child.exitCode === null
1480
+ const http = alive && rec.port ? await probeHttpAlive(rec.port, { path: cfg.healthPath }) : false
1481
+ return { ok: true, forkId: id, status: rec.status, running: alive, pid: rec.pid ?? null, port: rec.port ?? null, http, url: rec.url ?? null }
1482
+ }
1483
+
1484
+ /** Reconcile persisted records after a mother restart; never kills a live foreign pid. */
1485
+ const recover = async () => {
1486
+ ensureRegistry()
1487
+ ensurePorts()
1488
+ const actions = reconcileOrphans(
1489
+ [...records.values()].map((rec) => ({ forkId: rec.forkId, status: rec.status, pid: rec.pid })),
1490
+ isPidAlive,
1491
+ )
1492
+ for (const action of actions) {
1493
+ if (action.action !== 'mark-crashed') continue
1494
+ const rec = records.get(action.forkId)
1495
+ if (!rec) continue
1496
+ rec.pid = null
1497
+ rec.running = false
1498
+ rec.child = null
1499
+ rec.ready = false
1500
+ rec.status = FORK_STATUS.CRASHED
1501
+ rec.lastError = `orphaned:${action.reason}`
1502
+ if (rec.port) releaseOwnedPort(rec.port, rec.forkId)
1503
+ writeForkMetaOf(rec)
1504
+ }
1505
+ persist()
1506
+ return { ok: true, actions, forks: actions.filter((a) => a.action === 'mark-crashed').length }
1507
+ }
1508
+
1509
+ /** 真实进程存活判断(用于归档确认 stop 真的生效,而不是只看 rec.running 状态)。 */
1510
+ const alive = (forkId) => {
1511
+ ensureRegistry()
1512
+ const rec = records.get(String(forkId ?? '').trim())
1513
+ if (!rec) return false
1514
+ const pid = rec.pid ?? rec.child?.pid ?? null
1515
+ return isPidAlive(pid)
1516
+ }
1517
+
1518
+ const service = {
1519
+ role: gate.role,
1520
+ provision,
1521
+ spawn,
1522
+ stop,
1523
+ reap,
1524
+ alive,
1525
+ health,
1526
+ recover,
1527
+ status,
1528
+ list,
1529
+ logs,
1530
+ adapterToken,
1531
+ getToken: adapterToken,
1532
+ openUrl,
1533
+ onTokenRefresh(handler) {
1534
+ if (typeof handler !== 'function') return () => {}
1535
+ tokenRefreshHandlers.add(handler)
1536
+ return () => tokenRefreshHandlers.delete(handler)
1537
+ },
1538
+ allocatePort,
1539
+ releasePort,
1540
+ ensureTemplate,
1541
+ portPool: () => ({ ...portPool }),
1542
+ ports: () => {
1543
+ ensurePorts()
1544
+ return portsSnapshot()
1545
+ },
1546
+ get config() {
1547
+ return { ...cfg }
1548
+ },
1549
+ /** 运行时看护状态;enabled 可在不重启宿主的情况下切换。 */
1550
+ getSupervision() {
1551
+ return {
1552
+ enabled: runtimeSupervise,
1553
+ configured: cfg.supervise === true,
1554
+ maxRestarts: cfg.maxRestarts,
1555
+ backoffBaseMs: cfg.backoffBaseMs,
1556
+ backoffMaxMs: cfg.backoffMaxMs,
1557
+ backoffJitter: cfg.backoffJitter,
1558
+ healthPath: cfg.healthPath,
1559
+ }
1560
+ },
1561
+ setSupervise(enabled) {
1562
+ runtimeSupervise = enabled === true
1563
+ try { audit(AUDIT.FORK_SUPERVISION, { enabled: runtimeSupervise }) } catch { /* audit optional */ }
1564
+ return { ok: true, enabled: runtimeSupervise }
1565
+ },
1566
+ }
1567
+
1568
+ ctx.effect(() => ctx.provide(SERVICE.forker, service))
1569
+ // Plugin unload must never leave orphan children (or leaked pool ports) behind.
1570
+ ctx.effect(() => () => {
1571
+ disposed = true
1572
+ for (const pending of [...pendingSpawns]) {
1573
+ try {
1574
+ pending()
1575
+ } catch {
1576
+ /* ignore */
1577
+ }
1578
+ }
1579
+ for (const rec of records.values()) {
1580
+ if (rec.restartTimer) { clearTimeout(rec.restartTimer); rec.restartTimer = null }
1581
+ if (rec.running || rec.child || rec.starting) {
1582
+ rec.intentionalStop = true
1583
+ killTree(rec)
1584
+ rec.running = false
1585
+ rec.child = null
1586
+ rec.starting = false
1587
+ }
1588
+ }
1589
+ for (const [port, forkId] of [...ownedPorts]) {
1590
+ try {
1591
+ releaseOwnedPort(port, forkId)
1592
+ } catch {
1593
+ /* best effort on unload */
1594
+ }
1595
+ }
1596
+ persist()
1597
+ })
1598
+ return service
1599
+ }
1600
+
1601
+ export const _internals = {
1602
+ createTextTail,
1603
+ safeProfile,
1604
+ isPidAlive,
1605
+ probePortFree,
1606
+ DEFAULTS,
1607
+ EVENTS,
1608
+ DEFAULT_PORT_POOL,
1609
+ CLEAN_TEMPLATE_BUNDLES,
1610
+ }