@zmainer/dsh-wx-bridge 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,6 +78,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
78
78
  | `dataDir` | 桥的状态/日志/工作目录 | `$DSH_HOME/wxbridge` |
79
79
  | `cwd` | 微信任务的默认工作目录 | 宿主启动目录 |
80
80
  | `vault` | Obsidian 知识库绝对路径(可选,供提示词模板占位符使用) | 空 |
81
+ | `dshBin` | DSH 运行时入口(`<安装目录>/resources/dsh-runtime/lib/bin.js`)。**留空=五级自动探测**:显式参数/环境变量 → 宿主自证快照 → PATH/npm 全局 → 桌面安装目录扫描 → 运行中进程嗅探 | 自动 |
81
82
  | `intervalMs` / `staleMs` | 自检间隔 / 心跳判新阈值 | 300000 |
82
83
  | `prompt` | 手机任务的**固定前置提示词**(兜底路径用;留空 = 不加任何前缀) | 空 |
83
84
  | `acp.enabled` | 关掉 ACP 路径(退回一次性 headless) | true |
@@ -146,6 +147,16 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
146
147
 
147
148
  ## 最近变更
148
149
 
150
+ - **1.0.9**:修「手机发消息永远没答复 / 5 分钟后才报 ACP 超时」——
151
+ 根因是**运行时入口解析不到**(桌面端装在非标准目录时三级探测全部落空),ACP 子进程拿到空路径,
152
+ 而 `node "" --profile acp` 会进入「读 stdin」模式:**不回应协议、也不退出**,只能等满超时。
153
+ ① `dshBin` 改为**五级解析**:显式参数/环境变量 → **宿主自证快照**(宿主进程自己就是 `<runtime>/lib/bin.js`,
154
+ 每次启动写 `<dataDir>/host-runtime.json`)→ PATH/npm 全局 → 桌面安装目录扫描(`%LOCALAPPDATA%\Programs\*`、
155
+ `Program Files*`)→ **运行中进程嗅探**(从别的 DSH 进程命令行里取 `dsh-runtime\lib\bin.js`);
156
+ ② **空入口快速失败**:ACP 入口不存在直接报错(不再空跑 5 分钟),一次性兜底路径也不再盲试 `spawn('dsh')`,
157
+ 报错文案直接给出该填什么;
158
+ ③ 独立 keeper 每轮自检补读快照/配置 —— 改好配置**不必重启 keeper**;
159
+ ④ 面板诊断新增「运行时入口」一行,`node lib/kernel/bridge.mjs --selftest-runtime` 一次看全解析链。
149
160
  - **1.0.8**:手机指令可用性打磨 ——
150
161
  ① `/help` 重写:按「看状态 / 管任务 / 会话 / 档位模型 / 工作区 / 权限」分组,每条写清**作用**;
151
162
  ② `/task` 带**进度**(已跑多久、最近在用哪个工具、多久之前),不再只显示"running";
package/lib/client.js CHANGED
@@ -269,6 +269,9 @@ window.__ModuleLoader__.load({
269
269
  const rows = [
270
270
  ['桥状态', state + '(' + (b.phase || 'n/a') + ')'],
271
271
  ['进程', b.pid ? ('pid ' + b.pid + '|心跳 ' + (b.ageSec === null ? 'n/a' : b.ageSec + 's 前')) : '无'],
272
+ ['运行时入口', b.runtimeBin
273
+ ? (b.runtimeBin + (b.runtimeBinOk ? '' : '(⚠️ 文件不存在)'))
274
+ : '⚠️ 未解析(手机对话会失败,请设置 dshBin)'],
272
275
  ['数据目录', b.dataDir || 'n/a'],
273
276
  ['默认工作区', b.cwd || 'n/a'],
274
277
  ]
package/lib/index.js CHANGED
@@ -84,6 +84,7 @@ export function apply(ctx, config) {
84
84
  let keeper = null
85
85
  let timer = null
86
86
  let pairing = null
87
+ let runtimeInfo = { bin: '', ok: false, source: '' }
87
88
 
88
89
  /** 按钮操作日志(环形缓冲):面板要能看到"我点了什么、结果如何"。 */
89
90
  const actions = []
@@ -97,6 +98,32 @@ export function apply(ctx, config) {
97
98
  try { return JSON.parse(readFileSync(f, 'utf8')) || {} } catch { return {} }
98
99
  }
99
100
 
101
+ /**
102
+ * 宿主自己的运行时入口:**宿主进程就是 `<runtime>/lib/bin.js`**(argv[1]),
103
+ * 这是唯一无需猜测的权威答案(2026-09-22:其他用户装在非标准目录,三级探测全落空
104
+ * → ACP 子进程拿到空路径 `node "" --profile acp` → 挂死 5 分钟,手机端只收到一条失败提示)。
105
+ */
106
+ const detectHostRuntime = () => {
107
+ const a1 = String(process.argv[1] || '')
108
+ const bin = (existsSync(a1) && /(^|[\\/])bin\.js$/i.test(a1)) ? a1 : ''
109
+ const node = (() => { const p = String(process.execPath || ''); return existsSync(p) ? p : '' })()
110
+ return { bin, node, argv1: a1 }
111
+ }
112
+ /** 把宿主运行时写进 `<dataDir>/host-runtime.json`:独立 keeper / 桥 / 下次启动都读它。 */
113
+ const writeHostRuntime = (dataDir, rt) => {
114
+ if (!dataDir || !rt.bin) {
115
+ ctx.logger?.warn?.('wxbridge: 无法自证宿主运行时入口(argv[1]=' + (rt.argv1 || '空') + ')——手机对话可能失败')
116
+ return
117
+ }
118
+ try {
119
+ mkdirSync(dataDir, { recursive: true })
120
+ writeFileSync(join(dataDir, 'host-runtime.json'), JSON.stringify({
121
+ bin: rt.bin, node: rt.node, source: 'host-argv', hostPid: process.pid, at: new Date().toISOString(),
122
+ }, null, 1))
123
+ ctx.logger?.info?.('wxbridge: 宿主运行时入口 ' + rt.bin)
124
+ } catch (e) { ctx.logger?.warn?.('wxbridge: 写 host-runtime.json 失败 ' + String(e?.message ?? e)) }
125
+ }
126
+
100
127
  const boot = async () => {
101
128
  const file = readConfigFile()
102
129
  const pick = (k) => cfg[k] || file[k] || undefined
@@ -104,8 +131,12 @@ export function apply(ctx, config) {
104
131
  const vault = pick('vault')
105
132
  const cwd = pick('cwd')
106
133
  const hostHome = pick('hostHome') || process.env.DSH_HOME || ''
107
- // 自动探测宿主运行时:会话 schema 必须与应用一致,否则外部写的会话在应用里打不开
108
- const dshBin = pick('dshBin') || process.env.DSH_RUNTIME_BIN || ''
134
+ // 运行时入口:宿主自证(argv[1])优先,其次配置/环境。找不到就明确告警——
135
+ // 「入口为空」的表现曾经是"手机端永远没答复",必须当场看得见。
136
+ const host = detectHostRuntime()
137
+ const dshBin = pick('dshBin') || host.bin || process.env.DSH_RUNTIME_BIN || ''
138
+ runtimeInfo = { bin: dshBin, ok: !!dshBin && existsSync(dshBin), source: pick('dshBin') ? 'config' : (host.bin ? 'host-argv' : 'unresolved') }
139
+ if (!runtimeInfo.ok) ctx.logger?.warn?.('wxbridge: 未解析到 DSH 运行时入口(dshBin 为空)——请设置 config.dshBin,否则手机对话会快速失败')
109
140
  const ownership = cfg.lifecycle === 'host' || file.lifecycle === 'host' ? 'host' : 'standalone'
110
141
  const { createKeeper } = await import(new URL('./kernel/keeper.mjs', import.meta.url).href)
111
142
  keeper = createKeeper({
@@ -113,7 +144,7 @@ export function apply(ctx, config) {
113
144
  dshBin,
114
145
  dataDir,
115
146
  cwd,
116
- node: cfg.nodePath || undefined,
147
+ node: cfg.nodePath || host.node || undefined,
117
148
  intervalMs: cfg.intervalMs || file.intervalMs || undefined,
118
149
  staleMs: cfg.staleMs || file.staleMs || undefined,
119
150
  bridgePath: join(KERNEL, 'bridge.mjs'),
@@ -125,6 +156,7 @@ export function apply(ctx, config) {
125
156
  })
126
157
  keeper.lifecycle = ownership
127
158
  const st = keeper.status()
159
+ writeHostRuntime(st.dataDir, host)
128
160
  ctx.logger?.info?.('wxbridge: data=' + st.dataDir + ' cwd=' + st.cwd + ' pid=' + st.pid + ' pidOk=' + st.pidOk)
129
161
  const standaloneKeeper = (() => { try { const j = JSON.parse(readFileSync(join(st.dataDir, 'keeper.lock'), 'utf8')); return j && j.pid ? Number(j.pid) : 0 } catch { return 0 } })()
130
162
  ctx.logger?.info?.('wxbridge: 独立 keeper pid=' + (standaloneKeeper || '无') + '(负责拉起/自愈;宿主半只做看门狗,不抢拉起)')
@@ -216,6 +248,8 @@ export function apply(ctx, config) {
216
248
 
217
249
  const bridgeView = () => {
218
250
  if (!keeper) return null
251
+ const rt = (keeper.runtime ? keeper.runtime() : { dshBin: runtimeInfo.bin }) || {}
252
+ const rtBin = rt.dshBin || runtimeInfo.bin || ''
219
253
  const st = keeper.status()
220
254
  const hb = st.heartbeat || {}
221
255
  let peers = 0, tasks = 0, allowed = 0, stateOk = false, manualStop = null
@@ -234,6 +268,7 @@ export function apply(ctx, config) {
234
268
  polls: hb.polls ?? null, boot: hb.boot ?? null, running: hb.tasks ?? 0,
235
269
  peers, tasks, allowedUsers: allowed, stateOk, manualStop,
236
270
  dataDir: st.dataDir, cwd: st.cwd, intervalMs: st.intervalMs, staleMs: st.staleMs,
271
+ runtimeBin: rtBin, runtimeBinOk: !!rtBin && existsSync(rtBin), runtimeSource: runtimeInfo.source,
237
272
  }
238
273
  }
239
274
 
@@ -245,6 +280,7 @@ export function apply(ctx, config) {
245
280
  out.contract = cp && existsSync(cp) ? cp : join(KERNEL, 'second-brain-contract.txt')
246
281
  const pdir = join(process.env.DSH_HOME || '', 'profiles')
247
282
  try { for (const d of readdirSync(pdir)) { const pj = join(pdir, d, 'package.json'); let nm = d; try { nm = JSON.parse(readFileSync(pj, 'utf8')).name || d } catch {} out.profiles.push(nm) } } catch {}
283
+ out.runtime = { bin: runtimeInfo.bin, ok: runtimeInfo.ok, source: runtimeInfo.source }
248
284
  return out
249
285
  }
250
286
 
@@ -15,6 +15,7 @@
15
15
  * 传输:stdio 上的 NDJSON(一行一个 JSON-RPC)。stdout 只有协议流量,日志在 stderr。
16
16
  */
17
17
  import { spawn } from 'node:child_process'
18
+ import { existsSync } from 'node:fs'
18
19
 
19
20
  const PROTOCOL_VERSION = 1
20
21
  const NL = '\n'
@@ -23,6 +24,7 @@ export class AcpHost {
23
24
  /**
24
25
  * @param {object} o
25
26
  * @param {string} o.bin dsh 入口(bin.js 绝对路径)
27
+ * @param {string} [o.node] 用哪个 node 跑它(默认本进程的 execPath)
26
28
  * @param {string} [o.home] 子进程 DSH_HOME(决定会话写进哪个仓库)
27
29
  * @param {string} [o.cwd] 子进程工作目录(默认进程 cwd)
28
30
  * @param {string} [o.patch] --patch 叠加层文件(如停用宿主专属插件)
@@ -32,6 +34,7 @@ export class AcpHost {
32
34
  */
33
35
  constructor(o) {
34
36
  this.bin = o.bin
37
+ this.node = o.node || process.execPath
35
38
  this.home = o.home || ''
36
39
  this.cwd = o.cwd || process.cwd()
37
40
  this.patch = o.patch || ''
@@ -63,11 +66,19 @@ export class AcpHost {
63
66
  }
64
67
 
65
68
  async _start() {
69
+ // 入口必须先自证存在:spawn(node, ['']) 会让 node 进入「读 stdin」模式——
70
+ // 不回应协议、也不退出,调用方只能等满超时(2026-09-22 其他用户实况:
71
+ // 「手机发消息永远没答复,5 分钟后收到一条失败提示」)。宁可当场报错。
72
+ if (!this.bin || !existsSync(this.bin)) {
73
+ this.log('acp-bin-missing', { bin: this.bin || '', node: this.node })
74
+ throw new Error('ACP 通道不可用:DSH 运行时入口未解析(dshBin=' + (this.bin || '空') + ')。'
75
+ + '请把插件配置里的 dshBin 指向 <安装目录>/resources/dsh-runtime/lib/bin.js,或设环境变量 DSH_BIN 后重启。')
76
+ }
66
77
  const args = [this.bin, '--profile', 'acp']
67
78
  if (this.patch) args.push('--patch', this.patch)
68
79
  const env = { ...process.env }
69
80
  if (this.home) env.DSH_HOME = this.home
70
- const child = spawn(process.execPath, args, { cwd: this.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env })
81
+ const child = spawn(this.node, args, { cwd: this.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env })
71
82
  this.child = child
72
83
  this.log('acp-spawn', { pid: child.pid, home: this.home || '(inherit)', patch: this.patch || null })
73
84
  child.stdout.on('data', (b) => this._onData(b))
@@ -8,10 +8,11 @@
8
8
  * --data-dir / BRIDGE_DATA / WXBRIDGE_DATA / $DSH_HOME/wxbridge 数据目录
9
9
  * --cwd / BRIDGE_CWD 默认工作区
10
10
  * --contract / CONTRACT_FILE 身份契约文件
11
- * DSH_BIN / PATH 上的 dsh / npm 全局安装位 DSH 入口
11
+ * 运行时入口 dshBin(5 级解析,见 resolveDshBin):--dsh-bin/BRIDGE_DSH_BIN/DSH_BIN
12
+ * <dataDir>/host-runtime.json(宿主自证)→ PATH/npm 全局 → 桌面安装目录 → 运行中进程嗅探
12
13
  */
13
14
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs'
14
- import { spawn } from 'node:child_process'
15
+ import { spawn, execFileSync } from 'node:child_process'
15
16
  import { AcpHost } from './acp.mjs'
16
17
  import { ensureAcpPresetSupport, acpPresetSupported, acpPackageFile } from './acp-preset-shim.mjs'
17
18
  import { randomUUID, randomBytes, createHash } from 'node:crypto'
@@ -27,23 +28,101 @@ const DEBUG = args.includes('--debug')
27
28
  const NL = String.fromCharCode(10)
28
29
  const HERE = dirname(fileURLToPath(import.meta.url))
29
30
 
31
+ function fileOk(p) { try { return !!p && existsSync(p) && statSync(p).isFile() } catch { return false } }
32
+ function pickExisting(list) { for (const f of list) { if (fileOk(f)) return f } return '' }
33
+ /**
34
+ * 宿主写下的运行时快照(宿主半每次启动都会刷新它):
35
+ * 宿主进程自己就是 <runtime>/lib/bin.js,所以它给的路径**一定**和"读会话的那一端"同版本
36
+ *(schema 必须一致,见 E-2026-09-21-07)。
37
+ */
38
+ function readHostRuntime() {
39
+ for (const f of [process.env.BRIDGE_HOST_RUNTIME, join(STATE_DIR, 'host-runtime.json')]) {
40
+ try {
41
+ const j = JSON.parse(readFileSync(f, 'utf8'))
42
+ if (j && typeof j === 'object') return j
43
+ } catch {}
44
+ }
45
+ return {}
46
+ }
47
+ const RT_RE = /[\\/]lib[\\/]bin\.js$/i
48
+ /** 桌面发行版布局:<根>/<任意子目录>/resources/dsh-runtime/lib/bin.js(含便携解包布局)。 */
49
+ function desktopRuntimeCandidates() {
50
+ const out = []
51
+ const roots = [
52
+ join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'Programs'),
53
+ process.env.PROGRAMFILES || 'C:/Program Files',
54
+ process.env['PROGRAMFILES(X86)'] || 'C:/Program Files (x86)',
55
+ join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), ''),
56
+ ]
57
+ for (const root of roots) {
58
+ let subs = []
59
+ try { subs = readdirSync(root) } catch { continue }
60
+ for (const s of subs) {
61
+ out.push(join(root, s, 'resources', 'dsh-runtime', 'lib', 'bin.js')) // Electron 发行版
62
+ out.push(join(root, s, 'dsh-runtime', 'lib', 'bin.js')) // 便携解包
63
+ out.push(join(root, s, 'resources', 'app', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
64
+ }
65
+ }
66
+ return out
67
+ }
68
+ /**
69
+ * 从**正在运行的进程**里嗅探运行时:桌面端无论装在哪个盘、哪个目录,只要它在跑,
70
+ * 命令行里就带着 `<...>/dsh-runtime/lib/bin.js`(Electron 主进程的 ExecutablePath 也能推)。
71
+ * 只在前面几级全落空时才动用(PowerShell 起步约 1 秒)。
72
+ */
73
+ function wmiRuntimeCandidates() {
74
+ if (process.platform !== 'win32') return []
75
+ const out = []
76
+ try {
77
+ const ps = 'Get-CimInstance Win32_Process | Select-Object CommandLine,ExecutablePath | ConvertTo-Json -Compress'
78
+ const raw = execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { encoding: 'utf8', windowsHide: true, timeout: 20000 })
79
+ for (const it of [].concat(JSON.parse(raw || '[]'))) {
80
+ const cmd = String(it.CommandLine || '')
81
+ for (const m of cmd.match(/[A-Za-z]:\\[^"]*?lib\\bin\.js/gi) || []) { if (RT_RE.test(m)) out.push(m) }
82
+ const exe = String(it.ExecutablePath || '')
83
+ if (/dsh|harness/i.test(exe) && /\.exe$/i.test(exe)) {
84
+ const dir = dirname(exe)
85
+ out.push(join(dir, 'resources', 'dsh-runtime', 'lib', 'bin.js'))
86
+ out.push(join(dir, 'dsh-runtime', 'lib', 'bin.js'))
87
+ }
88
+ }
89
+ } catch {}
90
+ return out
91
+ }
92
+ /**
93
+ * DSH 运行时入口解析(5 级,2026-09-22 重写):
94
+ * ① --dsh-bin / BRIDGE_DSH_BIN / DSH_BIN(显式)
95
+ * ② <dataDir>/host-runtime.json(宿主自证,最权威)
96
+ * ③ PATH 各目录 + npm 全局安装位的 @deepseek-ai/dsh
97
+ * ④ 桌面布局扫描(%LOCALAPPDATA%\Programs\*、Program Files*)
98
+ * ⑤ 运行中进程嗅探(WMI)
99
+ * 全落空 → '',**调用方必须快速失败**:绝不能拿空路径 spawn
100
+ *(`node ""` 会进入「读 stdin」模式,既不回话也不退出 → "手机永远没答复",见 E-2026-09-22-03)。
101
+ */
30
102
  function resolveDshBin() {
31
- // 显式指定优先:宿主自己的运行时(与应用同版本)才能写出应用读得懂的会话格式,
32
- // 否则会写成另一个 schema(version 3 vs 0),会话在应用里"不存在"(2026-09-21 实测)。
33
- const explicit = argOf('--dsh-bin') || process.env.BRIDGE_DSH_BIN || process.env.DSH_BIN
34
- if (explicit && existsSync(explicit)) return explicit
35
- const candidates = [process.env.DSH_BIN]
103
+ const explicit = pickExisting([argOf('--dsh-bin'), process.env.BRIDGE_DSH_BIN, process.env.DSH_BIN])
104
+ if (explicit) return explicit
105
+ const fromSnap = pickExisting([readHostRuntime().bin, readHostRuntime().dshBin])
106
+ if (fromSnap) return fromSnap
107
+ const cands = []
36
108
  for (const p of String(process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean)) {
37
- candidates.push(join(p, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
109
+ cands.push(join(p, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
38
110
  }
39
- candidates.push(join(homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
40
- return candidates.filter(Boolean).find((f) => { try { return existsSync(f) } catch { return false } }) || ''
111
+ cands.push(join(homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
112
+ return pickExisting(cands) || pickExisting(desktopRuntimeCandidates()) || pickExisting(wmiRuntimeCandidates())
113
+ }
114
+ /** 跑 ACP 子进程/一次性任务用哪个 node:显式 → 宿主快照 → 自己(进程内已证明可用)。 */
115
+ function resolveNodeBin() {
116
+ return pickExisting([argOf('--node-bin'), process.env.BRIDGE_NODE_BIN]) || pickExisting([readHostRuntime().node]) || process.execPath
41
117
  }
118
+ const RUNTIME_HINT = '找不到 DSH 运行时入口(dshBin):显式参数/环境变量、宿主自证快照、PATH/npm 全局、'
119
+ + '桌面安装目录、运行中进程五级解析全部落空。请把插件配置里的 dshBin 指向 <安装目录>/resources/dsh-runtime/lib/bin.js,'
120
+ + '或设环境变量 DSH_BIN 后重启宿主;诊断:node <插件>/lib/kernel/bridge.mjs --selftest-runtime'
42
121
 
43
122
  const STATE_DIR = resolve(argOf('--data-dir') || process.env.BRIDGE_DATA || process.env.WXBRIDGE_DATA
44
123
  || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge'))
45
124
  const CWD = resolve(argOf('--cwd') || process.env.BRIDGE_CWD || process.cwd())
46
- const DSH_BIN = resolveDshBin()
125
+ let DSH_BIN = resolveDshBin()
47
126
  const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
48
127
  const DESKTOP_HOME = process.env.OHDSH_HOME || join(homedir(), '.ohdsh')
49
128
 
@@ -79,6 +158,26 @@ const TASKS_DIR = join(STATE_DIR, 'tasks')
79
158
  const CHILD_HOME = join(STATE_DIR, 'dsh-home')
80
159
  const DESKTOP_WS_FILE = join(DESKTOP_HOME, 'storages', 'workspace.json')
81
160
  const CLI_WS_FILE = join(DSH_HOME, 'storages', 'workspace.json')
161
+ const NODE_BIN = resolveNodeBin()
162
+ /**
163
+ * 入口缺失时按需重解析(配置修好不必重启桥);发现成功就落盘,供独立 keeper / 下次启动直接用。
164
+ * 发现失败也要**出声**——这个值空着,手机侧的表现就是「永远没答复」。
165
+ */
166
+ function ensureDshBin() {
167
+ if (fileOk(DSH_BIN)) return DSH_BIN
168
+ const hit = resolveDshBin()
169
+ if (hit) {
170
+ DSH_BIN = hit
171
+ log('runtime-resolved', { bin: hit, source: 'discovered' })
172
+ try {
173
+ const cur = readHostRuntime()
174
+ if (!fileOk(cur.bin)) writeFileSync(join(STATE_DIR, 'host-runtime.json'), JSON.stringify({ bin: hit, node: NODE_BIN, source: 'discovered', pid: process.pid, at: new Date().toISOString() }, null, 1))
175
+ } catch {}
176
+ return DSH_BIN
177
+ }
178
+ log('runtime-missing', { hint: RUNTIME_HINT })
179
+ return ''
180
+ }
82
181
 
83
182
  const API_BASE = process.env.ILINK_BASE || 'https://ilinkai.weixin.qq.com'
84
183
  const PROTOCOL_VERSION = '2.4.6'
@@ -133,18 +232,15 @@ let ACP_PERM = ACP_CFG.permPolicy === 'reject' ? 'reject' : 'allow'
133
232
  * 否则应用读不懂外部写的会话 —— 2026-09-21 实锤的 v0/v3 分裂)。
134
233
  */
135
234
  function resolveAcpBin() {
136
- const explicit = ACP_CFG.dshBin || process.env.BRIDGE_ACP_DSH_BIN
137
- if (explicit && existsSync(explicit)) return explicit
235
+ const explicit = pickExisting([ACP_CFG.dshBin, process.env.BRIDGE_ACP_DSH_BIN, process.env.DSH_BIN])
236
+ if (explicit) return explicit
237
+ const fromSnap = pickExisting([readHostRuntime().bin])
238
+ if (fromSnap) return fromSnap
138
239
  if (existsSync(join(ACP_HOME, 'profiles', 'desktop'))) {
139
- const local = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
140
- const cands = [
141
- join(local, 'Programs', 'Oh-DSH Desktop', 'resources', 'dsh-runtime', 'lib', 'bin.js'),
142
- join(process.env.PROGRAMFILES || 'C:/Program Files', 'Oh-DSH Desktop', 'resources', 'dsh-runtime', 'lib', 'bin.js'),
143
- ]
144
- const hit = cands.filter((f) => { try { return existsSync(f) } catch { return false } })[0]
240
+ const hit = pickExisting(desktopRuntimeCandidates())
145
241
  if (hit) return hit
146
242
  }
147
- return DSH_BIN
243
+ return ensureDshBin()
148
244
  }
149
245
 
150
246
  for (const d of [STATE_DIR, TASKS_DIR, CHILD_HOME]) mkdirSync(d, { recursive: true })
@@ -529,7 +625,7 @@ function flattenConfigOptions(opts) {
529
625
  function getAcpHost(key) {
530
626
  const k = String(key || ACP_DEFAULT_PRESET).trim() || ACP_DEFAULT_PRESET
531
627
  if (!acpHosts.has(k)) acpHosts.set(k, new AcpHost({
532
- bin: resolveAcpBin(), home: ACP_HOME, cwd: CWD, patch: overlayFor(k),
628
+ bin: resolveAcpBin(), node: NODE_BIN, home: ACP_HOME, cwd: CWD, patch: overlayFor(k),
533
629
  permPolicy: ACP_PERM, promptTimeoutMs: TASK_TIMEOUT_MS, log,
534
630
  }))
535
631
  return acpHosts.get(k)
@@ -804,9 +900,17 @@ function killTree(pid) { try { spawn('taskkill', ['/PID', String(pid), '/T', '/F
804
900
  function runDsh(task, cwd, taskId) {
805
901
  return new Promise((resolve) => {
806
902
  const taskLog = join(TASKS_DIR, Date.now() + '-' + taskId + '.log')
807
- const argv = DSH_BIN ? [DSH_BIN, '--profile', PROFILE, task] : ['--profile', PROFILE, task]
903
+ const bin = ensureDshBin()
904
+ // 没有入口就**当场失败**:旧写法 spawn('dsh') 靠 shell,找不到就 exit 1;
905
+ // 更糟的是 ACP 那边拿空路径 spawn(node, ['']) → node 进入「读 stdin」模式,挂着不吭声
906
+ //(2026-09-22 其他用户实况:手机 5 分钟后只收到一条"执行失败")。
907
+ if (!bin) {
908
+ try { appendFileSync(taskLog, NL + '=== runtime missing: ' + process.argv.join(' ') + NL + RUNTIME_HINT + NL) } catch {}
909
+ resolve({ ok: false, text: '', error: RUNTIME_HINT, log: taskLog })
910
+ return
911
+ }
808
912
  const env = { ...process.env, DSH_HOME: (HOST_HOME || CHILD_HOME) }
809
- const child = DSH_BIN ? spawn(process.execPath, argv, { cwd, windowsHide: true, env }) : spawn('dsh', argv, { cwd, windowsHide: true, env, shell: process.platform === 'win32' })
913
+ const child = spawn(NODE_BIN, [bin, '--profile', PROFILE, task], { cwd, windowsHide: true, env })
810
914
  const childPid = child.pid
811
915
  const rec = state.tasks[taskId]
812
916
  if (rec) { rec.pid = childPid; saveState(state) }
@@ -1203,6 +1307,17 @@ async function handleMessage(token, msg) {
1203
1307
 
1204
1308
  async function main() {
1205
1309
  process.on('exit', () => { for (const [, h] of acpHosts) { try { h.stop() } catch {} } })
1310
+ if (args.includes('--selftest-runtime')) {
1311
+ const snap = readHostRuntime()
1312
+ console.log('[selftest-runtime] ' + JSON.stringify({
1313
+ dshBin: DSH_BIN, dshBinOk: fileOk(DSH_BIN), node: NODE_BIN, acpBin: resolveAcpBin(), acpBinOk: fileOk(resolveAcpBin()),
1314
+ acpHome: ACP_HOME, dataDir: STATE_DIR,
1315
+ snapshot: { bin: snap.bin || '', node: snap.node || '', source: snap.source || '', at: snap.at || '' },
1316
+ desktopHits: desktopRuntimeCandidates().filter(fileOk),
1317
+ hint: fileOk(resolveAcpBin()) ? '' : RUNTIME_HINT,
1318
+ }, null, 1))
1319
+ process.exit(0)
1320
+ }
1206
1321
  if (args.includes('--selftest-preset-check')) {
1207
1322
  const peer = { cwd: CWD }
1208
1323
  const sp = argOf('--selftest-preset-check', '')
@@ -1215,7 +1330,7 @@ async function main() {
1215
1330
  }
1216
1331
  if (args.includes('--selftest-presets')) {
1217
1332
  console.log('[selftest-presets] ' + JSON.stringify({ default: ACP_DEFAULT_PRESET,
1218
- bin: resolveAcpBin(), acpHome: ACP_HOME, roots: presetRootsDiag(),
1333
+ bin: resolveAcpBin(), binOk: fileOk(resolveAcpBin()), acpHome: ACP_HOME, roots: presetRootsDiag(),
1219
1334
  roster: listPresets().map((x) => x.id + ':' + x.name) }))
1220
1335
  process.exit(0)
1221
1336
  }
@@ -1232,7 +1347,7 @@ async function main() {
1232
1347
  const r3 = await runViaAcp('再一次:我刚才让你记的是什么词?只回复那个词。', peer, 'SELFTEST3')
1233
1348
  for (const [, h] of acpHosts) h.stop()
1234
1349
  console.log('[selftest-acp] ' + JSON.stringify({
1235
- bin: resolveAcpBin(), home: ACP_HOME, preset: presetKey(peer), patch: overlayFor(presetKey(peer)),
1350
+ bin: resolveAcpBin(), binOk: fileOk(resolveAcpBin()), home: ACP_HOME, preset: presetKey(peer), patch: overlayFor(presetKey(peer)),
1236
1351
  session: sessionSlot(peer, peer.acpMode).id,
1237
1352
  turn1: r1?.text, turn2: r2?.text, turn3: r3?.text, notice: r2?.notice || null,
1238
1353
  }))
@@ -8,6 +8,7 @@
8
8
  * BRIDGE_DATA 数据目录(必填;未给则回退 WXBRIDGE_DATA,再回退 $DSH_HOME/wxbridge)
9
9
  * BRIDGE_CWD 桥与任务的默认工作目录(默认 process.cwd())
10
10
  * BRIDGE_NODE node 可执行文件(默认 process.execPath)
11
+ * <dataDir>/host-runtime.json 宿主写的运行时快照(dshBin/node 的权威来源,宿主半每次启动刷新)
11
12
  * KEEPER_INTERVAL_MS / KEEPER_STALE_MS 自检间隔 / 心跳判新阈值(默认 5 分钟)
12
13
  *
13
14
  * 也可作为模块被宿主进程 import:导出 tick()/status()/startBridge()/stopBridge()。
@@ -22,6 +23,14 @@ import { fileURLToPath } from 'node:url'
22
23
  const NL = String.fromCharCode(10)
23
24
  const HERE = dirname(fileURLToPath(import.meta.url))
24
25
 
26
+ /**
27
+ * 宿主写的运行时快照。宿主进程自己就是 <runtime>/lib/bin.js,所以它写下的路径一定与
28
+ * 应用同版本(会话 schema 必须对得上)。独立 keeper 可能先于宿主启动,故每轮自检都补读一次。
29
+ */
30
+ export function readHostRuntimeFile(dataDir) {
31
+ try { return JSON.parse(readFileSync(join(dataDir || '.', 'host-runtime.json'), 'utf8')) || {} } catch { return {} }
32
+ }
33
+
25
34
  /**
26
35
  * 轮询是否在推进(纯函数,可单测)。
27
36
  * 关键:进度下限必须按「桥的代次 boot」隔离——桥重启后 polls 归零,
@@ -53,7 +62,7 @@ export function createKeeper(opts = {}) {
53
62
  const file = readConfigFile()
54
63
  const DATA = resolveDataDir(opts.dataDir)
55
64
  const CWD = opts.cwd || file.cwd || process.env.BRIDGE_CWD || process.env.BRIDGE_ROOT || process.cwd()
56
- const NODE = opts.node || process.env.BRIDGE_NODE || process.execPath
65
+ let NODE = opts.node || process.env.BRIDGE_NODE || readHostRuntimeFile(DATA).node || process.execPath
57
66
 
58
67
 
59
68
  const INTERVAL_MS = Number(opts.intervalMs || file.intervalMs || process.env.KEEPER_INTERVAL_MS || 5 * 60 * 1000)
@@ -64,7 +73,7 @@ export function createKeeper(opts = {}) {
64
73
  // 宿主 home:给了它,桥的子进程就把会话写进宿主的会话仓库(手机对话在桌面工作区可见)
65
74
  const HOST_HOME = opts.hostHome || file.hostHome || process.env.BRIDGE_HOST_HOME || ''
66
75
  // 与应用同版本的运行时路径(让会话写成应用可读的 schema)
67
- const DSH_BIN = opts.dshBin || file.dshBin || process.env.BRIDGE_DSH_BIN || ''
76
+ let DSH_BIN = opts.dshBin || file.dshBin || process.env.BRIDGE_DSH_BIN || readHostRuntimeFile(DATA).bin || ''
68
77
  const DETACHED = opts.detached !== false
69
78
  /**
70
79
  * monitor = 只监测与报告,绝不 spawn / 杀进程(宿主半的默认);
@@ -92,10 +101,22 @@ export function createKeeper(opts = {}) {
92
101
  BRIDGE_ROOT: DATA,
93
102
  ...(HOST_HOME ? { BRIDGE_HOST_HOME: HOST_HOME } : {}),
94
103
  ...(DSH_BIN ? { BRIDGE_DSH_BIN: DSH_BIN } : {}),
104
+ ...(NODE ? { BRIDGE_NODE_BIN: NODE } : {}),
95
105
  })
96
106
 
97
107
  const alive = (pid) => { try { process.kill(pid, 0); return true } catch { return false } }
98
108
 
109
+ /** 入口缺失/失效时按需重解析(宿主半刚写下的快照、或用户改好的配置),不必重启 keeper。 */
110
+ function refreshRuntime() {
111
+ if (DSH_BIN && existsSync(DSH_BIN)) return DSH_BIN
112
+ const cfg = readConfigFile()
113
+ const snap = readHostRuntimeFile(DATA)
114
+ const hit = [cfg.dshBin, snap.bin, process.env.BRIDGE_DSH_BIN]
115
+ .filter((x) => { try { return !!x && existsSync(x) } catch { return false } })[0]
116
+ if (hit) { DSH_BIN = hit; if (!opts.node && snap.node && existsSync(snap.node)) NODE = snap.node; log('runtime-picked-up ' + hit) }
117
+ return DSH_BIN
118
+ }
119
+
99
120
  function bridgePid() {
100
121
  try {
101
122
  if (!existsSync(LOCK)) return 0
@@ -122,10 +143,12 @@ export function createKeeper(opts = {}) {
122
143
  * 所以「宿主半的常驻守护」必须用 detached=true,只有「一次性前台探测」才用 false。
123
144
  */
124
145
  function spawnBridge(detached = true) {
146
+ refreshRuntime()
125
147
  const extra = [...BRIDGE_ARGS,
126
148
  ...(VAULT ? ['--vault', VAULT] : []),
127
149
  ...(HOST_HOME ? ['--host-home', HOST_HOME] : []),
128
- ...(DSH_BIN ? ['--dsh-bin', DSH_BIN] : [])]
150
+ ...(DSH_BIN ? ['--dsh-bin', DSH_BIN] : []),
151
+ ...(NODE ? ['--node-bin', NODE] : [])]
129
152
  // 桥的输出必须留档:原来 stdio:'ignore' → 起不来时**一个字都看不到**,
130
153
  // 面板只能显示一个刚打印的 pid(2026-09-21 其他用户"启动不了"的排障黑洞)。
131
154
  let fd = 'ignore'
@@ -227,6 +250,7 @@ export function createKeeper(opts = {}) {
227
250
  }
228
251
 
229
252
  function tick() {
253
+ refreshRuntime()
230
254
  const pid = bridgePid()
231
255
  const hb = readHeartbeat()
232
256
  const pidOk = pid > 0 && alive(pid)
@@ -291,7 +315,7 @@ export function createKeeper(opts = {}) {
291
315
  setInterval(tick, INTERVAL_MS)
292
316
  }
293
317
 
294
- return { run, tick, status, startBridge: startBridgeNow, spawnBridge, stopBridge, log, superviseMode: SUPERVISE, hostHome: HOST_HOME, dshBin: DSH_BIN, dataDir: DATA, cwd: CWD, bridgePath: BRIDGE, intervalMs: INTERVAL_MS, staleMs: STALE_MS, manualStop: readManualStop }
318
+ return { run, tick, status, startBridge: startBridgeNow, spawnBridge, stopBridge, log, superviseMode: SUPERVISE, hostHome: HOST_HOME, dshBin: DSH_BIN, runtime: () => ({ dshBin: DSH_BIN, node: NODE }), dataDir: DATA, cwd: CWD, bridgePath: BRIDGE, intervalMs: INTERVAL_MS, staleMs: STALE_MS, manualStop: readManualStop }
295
319
  }
296
320
 
297
321
  const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmainer/dsh-wx-bridge",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "dsh": {
@@ -28,7 +28,7 @@ mkdirSync(join(root, 'lib'), { recursive: true })
28
28
  writeFileSync(join(root, 'lib', 'client.js'), out)
29
29
  console.log('built lib/client.js (' + out.length + ' bytes) id=' + pkg.name)
30
30
 
31
- for (const f of ['bridge.mjs', 'keeper.mjs', 'second-brain-contract.txt', 'acp.mjs', 'acp-preset-shim.mjs', 'secure.mjs']) {
31
+ for (const f of ['bridge.mjs', 'keeper.mjs', 'second-brain-contract.txt', 'acp.mjs', 'acp-preset-shim.mjs', 'acp-overlay.yml', 'acp-overlay-chat.yml']) {
32
32
  const p = join(root, 'lib', 'kernel', f)
33
33
  if (!existsSync(p)) throw new Error('kernel artifact missing: ' + p)
34
34
  console.log('kernel ok: lib/kernel/' + f + ' (' + readFileSync(p).length + ' bytes)')
package/src/client.js CHANGED
@@ -263,6 +263,9 @@ function Panel() {
263
263
  const rows = [
264
264
  ['桥状态', state + '(' + (b.phase || 'n/a') + ')'],
265
265
  ['进程', b.pid ? ('pid ' + b.pid + '|心跳 ' + (b.ageSec === null ? 'n/a' : b.ageSec + 's 前')) : '无'],
266
+ ['运行时入口', b.runtimeBin
267
+ ? (b.runtimeBin + (b.runtimeBinOk ? '' : '(⚠️ 文件不存在)'))
268
+ : '⚠️ 未解析(手机对话会失败,请设置 dshBin)'],
266
269
  ['数据目录', b.dataDir || 'n/a'],
267
270
  ['默认工作区', b.cwd || 'n/a'],
268
271
  ]