@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,129 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // decision-events — 决策中心 bus 事件 → 一条 HTTP SSE 流。
5
+ //
6
+ // 只负责「把事件写进一个已鉴权的响应」:路由层负责 auth/Host/Origin,本模块不碰 token。
7
+ // 事件:ready(初始 rev/计数)、ticket(created/updated)、ping(心跳)。
8
+ // 客户端断开/出错即退订,绝不把 bus listener 泄漏到下一个请求。
9
+ export const DECISION_EVENT_TOPICS = Object.freeze([
10
+ 'fang/decision-created',
11
+ 'fang/decision-updated',
12
+ ])
13
+
14
+ /**
15
+ * @param {object} input
16
+ * @param {any} input.req
17
+ * @param {any} input.res 已通过路由鉴权的 Node 响应
18
+ * @param {{on:Function,off?:Function}} input.bus kernel bus
19
+ * @param {{list?:Function}} [input.center] 决策中心服务(只读 list 用于 ready)
20
+ * @param {number} [input.heartbeatMs]
21
+ * @param {Function} [input.now]
22
+ * @returns {{close:Function, send:Function, headers:object, closed:boolean}}
23
+ */
24
+ export function openDecisionEventStream({
25
+ req,
26
+ res,
27
+ bus,
28
+ center = null,
29
+ heartbeatMs = 15000,
30
+ now = () => Date.now(),
31
+ } = {}) {
32
+ if (!req || !res || !bus || typeof bus.on !== 'function') {
33
+ throw new Error('openDecisionEventStream: req/res/bus required')
34
+ }
35
+ const headers = {
36
+ 'content-type': 'text/event-stream; charset=utf-8',
37
+ 'cache-control': 'no-store, no-cache, must-revalidate',
38
+ connection: 'keep-alive',
39
+ 'x-accel-buffering': 'no',
40
+ }
41
+ res.writeHead(200, headers)
42
+ try { res.flushHeaders?.() } catch { /* fake/undici responses may not support it */ }
43
+
44
+ let closed = false
45
+ const cleanups = []
46
+ const send = (event, data) => {
47
+ if (closed) return false
48
+ try {
49
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
50
+ return true
51
+ } catch {
52
+ close()
53
+ return false
54
+ }
55
+ }
56
+ const close = () => {
57
+ if (closed) return
58
+ closed = true
59
+ for (const off of cleanups.reverse()) {
60
+ try { if (typeof off === 'function') off() } catch { /* ignore */ }
61
+ }
62
+ cleanups.length = 0
63
+ try { res.end() } catch { /* ignore */ }
64
+ }
65
+
66
+ // ready:客户端据此判断「这确实是新流的首帧」,而不是把旧缓存当实时。
67
+ let initial = null
68
+ try { initial = typeof center?.list === 'function' ? center.list({ includeArchived: false }) : null } catch { initial = null }
69
+ send('ready', {
70
+ at: now(),
71
+ rev: Number(initial?.rev) || 0,
72
+ counts: initial?.counts ?? null,
73
+ })
74
+
75
+ for (const topic of DECISION_EVENT_TOPICS) {
76
+ const handler = (ticket) => {
77
+ send('ticket', {
78
+ at: now(),
79
+ type: topic.endsWith('-created') ? 'created' : 'updated',
80
+ // 紧凑提示字段(客户端也可只按 rev 拉增量),ticket 一并带上避免多一跳。
81
+ rev: Number(ticket?.rev) || 0,
82
+ ids: ticket?.id ? [String(ticket.id)] : [],
83
+ ticket,
84
+ })
85
+ }
86
+ const off = bus.on(topic, handler)
87
+ cleanups.push(() => { try { if (typeof off === 'function') off(); else bus.off?.(topic, handler) } catch { /* ignore */ } })
88
+ }
89
+
90
+ const heartbeat = setInterval(() => {
91
+ if (!send('ping', { at: now() })) return
92
+ }, Math.max(10, Number(heartbeatMs) || 15000))
93
+ heartbeat.unref?.()
94
+ cleanups.push(() => clearInterval(heartbeat))
95
+
96
+ for (const [target, event] of [[req, 'close'], [req, 'aborted'], [res, 'close'], [res, 'error']]) {
97
+ if (target && typeof target.on === 'function') {
98
+ const fn = () => close()
99
+ try { target.on(event, fn) } catch { /* ignore */ }
100
+ cleanups.push(() => { try { target.removeListener?.(event, fn) } catch { /* ignore */ } })
101
+ }
102
+ }
103
+
104
+ return { close, send, headers, get closed() { return closed } }
105
+ }
106
+
107
+ /** 紧凑的 SSE 帧解析(客户端/测试共用;不依赖 EventSource)。 */
108
+ export function parseSseFrames(buffer = '') {
109
+ const frames = []
110
+ let rest = String(buffer)
111
+ for (;;) {
112
+ const index = rest.indexOf('\n\n')
113
+ if (index < 0) break
114
+ const raw = rest.slice(0, index)
115
+ rest = rest.slice(index + 2)
116
+ let event = 'message'
117
+ const dataLines = []
118
+ for (const line of raw.split('\n')) {
119
+ if (line.startsWith('event:')) event = line.slice(6).trim()
120
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
121
+ }
122
+ let data = null
123
+ if (dataLines.length) {
124
+ try { data = JSON.parse(dataLines.join('\n')) } catch { data = null }
125
+ }
126
+ frames.push({ event, data })
127
+ }
128
+ return { frames, rest }
129
+ }
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // decision-ingest-client — 给独立进程(PJM/Worker/HarnessMM/PluginUpdater/SuperAgent)
5
+ // 用的 fork 侧 ingest 客户端:
6
+ // * 从母体 dataDir 发现 endpoint + token(DSH_FANG_MOTHER 或显式路径)
7
+ // * 直连 POST /fang/api/decisions/ingest
8
+ // * 失败时落本地 outbox,可 flush 重放(耐久,不丢 ticket)
9
+ // 注意:客户端存在 ≠ 业务已接;外部进程仍需在事件处调用 .submit()。
10
+ import process from 'node:process'
11
+
12
+ import { createDecisionOutbox, resolveMotherTarget } from './decision-outbox.mjs'
13
+
14
+ export function createForkIngestClient({
15
+ dataDir,
16
+ motherDataDir = process.env.DSH_FANG_MOTHER,
17
+ fetchImpl,
18
+ logger,
19
+ } = {}) {
20
+ const dir = String(dataDir ?? '').trim()
21
+ if (!dir) throw new TypeError('createForkIngestClient: dataDir required (outbox directory)')
22
+ const outbox = createDecisionOutbox({ dataDir: dir, fetchImpl, logger })
23
+ const target = () => resolveMotherTarget(motherDataDir)
24
+ return {
25
+ target,
26
+ motherDataDir: String(motherDataDir ?? ''),
27
+ /** 直连母体;失败自动落 outbox,返回 {ok, queued, ticket, outboxId?}。 */
28
+ submit(payload) {
29
+ return outbox.submit(payload, { motherDataDir })
30
+ },
31
+ flush() {
32
+ return outbox.flush({ motherDataDir })
33
+ },
34
+ pending() {
35
+ return outbox.pending()
36
+ },
37
+ list() {
38
+ return outbox.list()
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // decision-outbox — fork 侧的「向母体提交 decision」耐久 outbox(P4 地基)。
5
+ //
6
+ // 方向:fork → mother 走 HTTP ingest,不走 adapter(adapter 是 mother → fork 控制通道)。
7
+ // 母体地址/token 从 DSH_FANG_MOTHER 指向的母体 dataDir 读取:
8
+ // <motherDataDir>/endpoint.json { url }
9
+ // <motherDataDir>/fang-auth.json { token }(0600)
10
+ //
11
+ // 现状:库本身可用,但还没有接进 fork 的 Agent tool / umbrella ASK;未接 = 不算接线。
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { randomUUID } from 'node:crypto'
15
+
16
+ import { ensureDir, readJson, writeJsonAtomic } from '../kernel/index.mjs'
17
+
18
+ export const OUTBOX_DIRNAME = 'decision-outbox'
19
+
20
+ export function resolveMotherTarget(motherDataDir) {
21
+ const dir = String(motherDataDir ?? '').trim()
22
+ if (!dir) return { ok: false, reason: 'mother-data-dir-required' }
23
+ const endpoint = readJson(path.join(dir, 'endpoint.json'), null)
24
+ const auth = readJson(path.join(dir, 'fang-auth.json'), null)
25
+ const url = String(endpoint?.url ?? '').replace(/\/+$/, '')
26
+ const token = String(auth?.token ?? '')
27
+ if (!url || !token) return { ok: false, reason: 'mother-endpoint-unavailable', url, hasToken: Boolean(token) }
28
+ return { ok: true, url, token }
29
+ }
30
+
31
+ /**
32
+ * @param {{dataDir:string, fetchImpl?:Function, logger?:object, now?:()=>number}} options
33
+ */
34
+ export function createDecisionOutbox(options = {}) {
35
+ const dataDir = String(options.dataDir ?? '')
36
+ if (!dataDir) throw new Error('createDecisionOutbox: dataDir required')
37
+ const dir = path.join(dataDir, OUTBOX_DIRNAME)
38
+ const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch
39
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
40
+ const log = (level, message) => { try { options.logger?.[level]?.(`[decision-outbox] ${message}`) } catch { /* ignore */ } }
41
+ const fileOf = (id) => path.join(dir, `${id}.json`)
42
+ const listIds = () => {
43
+ try { return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)).sort() } catch { return [] }
44
+ }
45
+
46
+ function enqueue(payload) {
47
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('enqueue: payload object required')
48
+ ensureDir(dir)
49
+ const id = `out-${now()}-${randomUUID()}`
50
+ const record = { id, payload, createdAt: now(), attempts: 0, nextAt: 0 }
51
+ writeJsonAtomic(fileOf(id), record)
52
+ return record
53
+ }
54
+
55
+ async function post(target, payload) {
56
+ const response = await fetchImpl(`${target.url}/fang/api/decisions/ingest`, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json', 'x-fang-auth': target.token },
59
+ body: JSON.stringify(payload),
60
+ })
61
+ const text = await response.text().catch(() => '')
62
+ let body = null
63
+ try { body = JSON.parse(text) } catch { body = null }
64
+ return { ok: response.ok, status: response.status, body }
65
+ }
66
+
67
+ /** 先直连母体;失败(含母体不可达)就落 outbox,返回 queued:true。 */
68
+ async function submit(payload, { motherDataDir } = {}) {
69
+ const target = resolveMotherTarget(motherDataDir)
70
+ if (!target.ok) {
71
+ const record = enqueue(payload)
72
+ return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: target.reason }
73
+ }
74
+ try {
75
+ const result = await post(target, payload)
76
+ if (result.ok) return { ok: true, queued: false, status: result.status, ticket: result.body?.ticket ?? null }
77
+ const record = enqueue(payload)
78
+ record.lastStatus = result.status
79
+ writeJsonAtomic(fileOf(record.id), record)
80
+ return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: `http-${result.status}` }
81
+ } catch (error) {
82
+ const record = enqueue(payload)
83
+ log('warn', `submit queued: ${String(error?.message ?? error)}`)
84
+ return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: 'network-error' }
85
+ }
86
+ }
87
+
88
+ /** 重放 outbox;成功即删文件,失败保留并写退避 nextAt。 */
89
+ async function flush({ motherDataDir, limit = 50 } = {}) {
90
+ const target = resolveMotherTarget(motherDataDir)
91
+ const ids = listIds()
92
+ if (!target.ok) return { ok: false, reason: target.reason, sent: 0, failed: 0, remaining: ids.length }
93
+ let sent = 0
94
+ let failed = 0
95
+ for (const id of ids.slice(0, Math.max(0, limit))) {
96
+ const record = readJson(fileOf(id), null)
97
+ if (!record?.payload) { try { fs.unlinkSync(fileOf(id)) } catch { /* ignore */ } continue }
98
+ try {
99
+ const result = await post(target, record.payload)
100
+ if (result.ok) {
101
+ try { fs.unlinkSync(fileOf(id)) } catch { /* ignore */ }
102
+ sent += 1
103
+ } else {
104
+ record.attempts = (Number(record.attempts) || 0) + 1
105
+ record.nextAt = now() + Math.min(60_000, 2 ** record.attempts * 1000)
106
+ record.lastStatus = result.status
107
+ writeJsonAtomic(fileOf(id), record)
108
+ failed += 1
109
+ }
110
+ } catch (error) {
111
+ record.attempts = (Number(record.attempts) || 0) + 1
112
+ record.nextAt = now() + Math.min(60_000, 2 ** record.attempts * 1000)
113
+ record.lastError = String(error?.message ?? error).slice(0, 300)
114
+ writeJsonAtomic(fileOf(id), record)
115
+ failed += 1
116
+ }
117
+ }
118
+ return { ok: true, sent, failed, remaining: listIds().length }
119
+ }
120
+
121
+ return {
122
+ dir,
123
+ enqueue,
124
+ submit,
125
+ flush,
126
+ pending: () => listIds().length,
127
+ list: () => listIds().map((id) => readJson(fileOf(id), null)).filter(Boolean),
128
+ }
129
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // v3.2.0 四色决策状态机(唯一事实来源)。
5
+ // `expiresAt` 是归档期限;`waitDeadlineAt` 只是 Agent 的流程等待窗口,二者不可混用。
6
+ export const DECISION_LEVEL_POLICY = Object.freeze({
7
+ red: Object.freeze({ waitMode: 'human', graceMs: null, afterWait: 'keep-waiting', label: '必须人工选择', priority: 'P0', risk: 'high' }),
8
+ yellow: Object.freeze({ waitMode: 'grace-defer', graceMs: 3 * 60_000, afterWait: 'defer', label: '3 分钟后继续,可补选', priority: 'P1', risk: 'medium' }),
9
+ blue: Object.freeze({ waitMode: 'grace-recommended', graceMs: 60_000, afterWait: 'recommended', label: '1 分钟后按推荐执行', priority: 'P2', risk: 'low' }),
10
+ green: Object.freeze({ waitMode: 'immediate', graceMs: 0, afterWait: 'recommended', label: '自动执行并留底', priority: 'P3', risk: 'info' }),
11
+ })
12
+
13
+ export const DECISION_PRIORITIES = Object.freeze(['P0', 'P1', 'P2', 'P3'])
14
+ export const DECISION_RISKS = Object.freeze(['high', 'medium', 'low', 'info'])
15
+
16
+ /** 调用方可以覆盖默认优先级/风险(例如低等级但高风险的安全票)。 */
17
+ export function normalizeDecisionPriority(value, level) {
18
+ const raw = String(value ?? '').trim().toUpperCase()
19
+ if (DECISION_PRIORITIES.includes(raw)) return raw
20
+ return decisionPolicy(level).priority
21
+ }
22
+
23
+ export function normalizeDecisionRisk(value, level) {
24
+ const raw = String(value ?? '').trim().toLowerCase()
25
+ const alias = { critical: 'high', high: 'high', medium: 'medium', med: 'medium', low: 'low', info: 'info', none: 'info' }
26
+ const mapped = alias[raw]
27
+ if (mapped) return mapped
28
+ return decisionPolicy(level).risk
29
+ }
30
+
31
+ export function decisionPolicy(level) {
32
+ return DECISION_LEVEL_POLICY[level] ?? DECISION_LEVEL_POLICY.yellow
33
+ }
34
+
35
+ export function decisionWaitMs(level, timeoutMinutes) {
36
+ const policy = decisionPolicy(level)
37
+ if (policy.graceMs === null) return null
38
+ // timeoutMinutes 对黄/蓝表示等待窗口;未显式指定时使用该等级的默认值。
39
+ const override = Number(timeoutMinutes)
40
+ if (Number.isFinite(override) && override > 0) return Math.max(1, Math.round(override * 60_000))
41
+ return policy.graceMs
42
+ }
43
+
44
+ /** 推荐项优先;低风险票没有推荐标记时确定性地取第一项,保证流程能继续。 */
45
+ export function recommendedDecisionOption(options) {
46
+ const list = Array.isArray(options) ? options : []
47
+ return list.find((option) => option?.isRecommended === true) ?? list[0] ?? null
48
+ }
49
+
50
+ export function isDeferredDecision(ticket) {
51
+ return ticket?.status === 'pending' && ticket?.level === 'yellow' && Number(ticket?.deferredAt) > 0
52
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // 新用户默认“元宝”:安装不激活 / 安装并激活 两组插件,以及本机插件配置复制选项。
3
+ export const DEFAULT_ESSENCE_NAME = '默认元宝'
4
+
5
+ export const DEFAULT_ESSENCE_PRESET = Object.freeze({
6
+ version: 1,
7
+ groups: Object.freeze([
8
+ {
9
+ key: 'installed-inactive',
10
+ title: '安装不激活',
11
+ enabled: false,
12
+ specs: Object.freeze([
13
+ '@twinsearth/dsh-2xray',
14
+ '@michengai/dsh-agency-agents',
15
+ 'dsh-better-sidebar',
16
+ 'dsh-context',
17
+ '@liustack/modlens',
18
+ '@liustack/modsearch',
19
+ ]),
20
+ },
21
+ {
22
+ key: 'installed-active',
23
+ title: '安装并激活',
24
+ enabled: true,
25
+ specs: Object.freeze([
26
+ 'dshmarket',
27
+ '@twinsearth/dsh-im2a-plugin',
28
+ '@openviking/dsh-memory-plugin',
29
+ '@twinsearth/w2m-dsh-plugin',
30
+ ]),
31
+ },
32
+ ]),
33
+ })
34
+
35
+ /** 从 pluginManager.listPlugins() 的结果里判断某个包是否已安装(模糊匹配包名末尾)。 */
36
+ export function matchInstalled(entries, spec) {
37
+ const wanted = String(spec ?? '').toLowerCase()
38
+ const bare = wanted.includes('/') ? wanted.split('/').pop() : wanted
39
+ for (const entry of entries ?? []) {
40
+ const name = String(entry?.name ?? entry?.id ?? '').toLowerCase()
41
+ if (!name) continue
42
+ if (name === wanted || name.endsWith('/' + bare) || name.includes(bare)) return entry
43
+ }
44
+ return null
45
+ }
46
+
47
+ export function planDefaultEssence(entries) {
48
+ return {
49
+ name: DEFAULT_ESSENCE_NAME,
50
+ version: DEFAULT_ESSENCE_PRESET.version,
51
+ groups: DEFAULT_ESSENCE_PRESET.groups.map((group) => ({
52
+ key: group.key,
53
+ title: group.title,
54
+ enabled: group.enabled,
55
+ plugins: group.specs.map((spec) => {
56
+ const hit = matchInstalled(entries, spec)
57
+ return { spec, installed: Boolean(hit), pluginId: hit?.id ?? null }
58
+ }),
59
+ })),
60
+ }
61
+ }
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // desktop-launch — 把 DSH Desktop 启动挂到 restart-supervisor 下的「真实入口」适配器。
5
+ //
6
+ // 它不修改 DSH Desktop 安装目录,也不自动启动;只负责构造:
7
+ // node tools/restart-supervisor.mjs [--execute] --data-root <dir> -- <desktop-exe> <args...>
8
+ // 由 supervisor 决定:退出码 75 + 已武装清单 + pid 匹配才重启;默认 dry-run。
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ import {
14
+ applyDisabledRows,
15
+ checkBundleRows,
16
+ findFangPackageDir,
17
+ readBundleRows,
18
+ readPackageName,
19
+ } from './boot-guard.mjs'
20
+
21
+ const isExecutableFile = (file) => {
22
+ try { return fs.statSync(file).isFile() } catch { return false }
23
+ }
24
+
25
+ /** 从 env/DSH 参数推断本机 desktop profile(用于无 --profile-dir 时自动自检)。 */
26
+ export function detectDesktopProfileDir({ env = process.env, args = [] } = {}) {
27
+ const hasPackage = (dir) => {
28
+ try { return isExecutableFile(path.join(String(dir), 'package.json')) } catch { return false }
29
+ }
30
+ const explicit = String(env?.DSH_PROFILE_DIR ?? '').trim()
31
+ if (explicit && hasPackage(explicit)) return path.resolve(explicit)
32
+ let profile = String(env?.DSH_PROFILE ?? '').trim()
33
+ if (!profile) {
34
+ const index = Array.isArray(args) ? args.findIndex((value) => value === '--profile' || value === '-p') : -1
35
+ if (index >= 0 && args[index + 1]) profile = String(args[index + 1])
36
+ else {
37
+ const inline = Array.isArray(args) ? args.find((value) => value.startsWith('--profile=')) : null
38
+ if (inline) profile = inline.slice('--profile='.length)
39
+ }
40
+ }
41
+ if (!profile) profile = 'desktop'
42
+ const home = String(env?.DSH_HOME ?? '').trim() || path.join(String(env?.USERPROFILE ?? env?.HOME ?? '').trim() || '.', '.dsh')
43
+ if (!home) return ''
44
+ const candidate = path.join(path.resolve(home), 'profiles', profile)
45
+ return hasPackage(candidate) ? candidate : ''
46
+ }
47
+
48
+ const pidAlive = (pid) => {
49
+ const value = Number(pid)
50
+ if (!Number.isInteger(value) || value <= 0) return false
51
+ try { process.kill(value, 0); return true } catch (error) { return error?.code === 'EPERM' }
52
+ }
53
+
54
+ /** 读取 restart-guard 写的 boot-state;上一进程 booting/crashed 且 pid 已死 = 启动期崩溃。 */
55
+ export function readUncleanBoot(dataRoot) {
56
+ const root = String(dataRoot ?? '').trim()
57
+ if (!root) return null
58
+ try {
59
+ const file = path.join(path.resolve(root), 'restart', 'boot-state.json')
60
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
61
+ if (!raw || (raw.state !== 'booting' && raw.state !== 'crashed')) return null
62
+ if (Number(raw.pid) === process.pid) return null
63
+ if (pidAlive(raw.pid)) return null
64
+ return { state: String(raw.state), pid: Number(raw.pid) || null, at: Number(raw.at) || 0, error: raw.error ?? null }
65
+ } catch { return null }
66
+ }
67
+
68
+ /**
69
+ * 启动前跑 boot-guard:失败时可按“安全模式”禁用全部 fang 行。
70
+ * 默认不写 profile;调用方显式 safeModeOnFailure=true 才会改(会先备份)。
71
+ * dataRoot 给出时,若上次 boot-state 为 booting/crashed(启动期崩溃),同样走安全模式熔断。
72
+ */
73
+ export function runBootGuard(profileDir, { dataRoot = '', safeModeOnFailure = false, timeoutMs = 8000 } = {}) {
74
+ const dir = String(profileDir ?? '').trim()
75
+ if (!dir) return { ok: true, skipped: 'no-profile-dir' }
76
+ const resolved = path.resolve(dir)
77
+ if (!isExecutableFile(path.join(resolved, 'package.json'))) return { ok: false, error: 'profile-package-missing', profileDir: resolved }
78
+ const pkgDir = findFangPackageDir(resolved)
79
+ if (!pkgDir) return { ok: true, skipped: 'fang-package-not-found', profileDir: resolved }
80
+ const parsed = readBundleRows(pkgDir)
81
+ if (!parsed.ok) return { ok: false, error: 'bundle-patch-invalid', detail: parsed.error, profileDir: resolved }
82
+ const uncleanBoot = readUncleanBoot(dataRoot)
83
+ const disableAllForSafeMode = (reason, findings) => {
84
+ const applied = applyDisabledRows(resolved, parsed.rows.map((row) => row.id))
85
+ if (!applied.ok) return { ok: false, error: 'safe-mode-apply-failed', detail: applied.error, findings, profileDir: resolved, reason }
86
+ return { ok: true, safeModeApplied: true, reason, applied, findings, checked: findings.length, profileDir: resolved, packageDir: pkgDir }
87
+ }
88
+ if (uncleanBoot) {
89
+ if (!safeModeOnFailure) return { ok: false, error: 'unclean-boot', uncleanBoot, profileDir: resolved, packageDir: pkgDir }
90
+ return disableAllForSafeMode('unclean-boot', [{ code: 'unclean-boot', level: 'block', detail: JSON.stringify(uncleanBoot) }])
91
+ }
92
+ const checked = checkBundleRows(parsed.rows, { profileDir: resolved, ownPackage: readPackageName(pkgDir), timeoutMs })
93
+ if (checked.ok) return { ok: true, checked: checked.checked, findings: checked.findings, profileDir: resolved, packageDir: pkgDir }
94
+ const blocked = checked.findings.filter((finding) => finding.level === 'block')
95
+ if (!safeModeOnFailure) {
96
+ return { ok: false, error: 'boot-guard-blocked', findings: checked.findings, blocked, profileDir: resolved, packageDir: pkgDir }
97
+ }
98
+ return disableAllForSafeMode('boot-guard-blocked', checked.findings)
99
+ }
100
+
101
+ /**
102
+ * @param {{exe?:string, dataRoot?:string, profileDir?:string, args?:string[], execute?:boolean, nodePath?:string, supervisorPath?:string}} input
103
+ * @returns {{ok:true, argv:string[], dryRun:boolean, exe:string, dataRoot:string, profileDir:string} | {ok:false, error:string}}
104
+ */
105
+ export function resolveDesktopLaunch(input = {}) {
106
+ const exe = String(input.exe ?? '').trim()
107
+ if (!exe) return { ok: false, error: 'desktop-exe-required' }
108
+ const resolvedExe = path.resolve(exe)
109
+ if (!isExecutableFile(resolvedExe)) return { ok: false, error: 'desktop-exe-missing' }
110
+ const dataRoot = String(input.dataRoot ?? '').trim()
111
+ if (!dataRoot) return { ok: false, error: 'data-root-required' }
112
+ const resolvedRoot = path.resolve(dataRoot)
113
+ const profileDir = String(input.profileDir ?? '').trim()
114
+ if (profileDir) {
115
+ const resolvedProfile = path.resolve(profileDir)
116
+ if (!isExecutableFile(path.join(resolvedProfile, 'package.json'))) return { ok: false, error: 'profile-dir-missing' }
117
+ }
118
+ const supervisorPath = input.supervisorPath
119
+ ? path.resolve(String(input.supervisorPath))
120
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools', 'restart-supervisor.mjs')
121
+ if (!isExecutableFile(supervisorPath)) return { ok: false, error: 'supervisor-missing' }
122
+ const nodePath = String(input.nodePath ?? process.execPath)
123
+ const execute = input.execute === true
124
+ const desktopArgs = Array.isArray(input.args) ? input.args.map(String) : []
125
+ const argv = [
126
+ nodePath, supervisorPath,
127
+ ...(execute ? ['--execute'] : []),
128
+ '--data-root', resolvedRoot,
129
+ '--', resolvedExe, ...desktopArgs,
130
+ ]
131
+ return { ok: true, argv, dryRun: !execute, exe: resolvedExe, dataRoot: resolvedRoot, profileDir: profileDir ? path.resolve(profileDir) : '' }
132
+ }
133
+
134
+ /** 只用于展示,不做 shell 拼接执行。 */
135
+ export function describeDesktopLaunch(plan) {
136
+ if (!plan?.ok) return `desktop-launch: ${plan?.error ?? 'invalid'}`
137
+ return `${plan.dryRun ? '[dry-run] ' : '[execute] '}${plan.argv.map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(' ')}`
138
+ }