@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 +11 -0
- package/lib/client.js +3 -0
- package/lib/index.js +39 -3
- package/lib/kernel/acp.mjs +12 -1
- package/lib/kernel/bridge.mjs +140 -25
- package/lib/kernel/keeper.mjs +28 -4
- package/package.json +1 -1
- package/scripts/build-client.mjs +1 -1
- package/src/client.js +3 -0
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
|
-
//
|
|
108
|
-
|
|
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
|
|
package/lib/kernel/acp.mjs
CHANGED
|
@@ -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(
|
|
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))
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
const
|
|
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
|
-
|
|
109
|
+
cands.push(join(p, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'))
|
|
38
110
|
}
|
|
39
|
-
|
|
40
|
-
return
|
|
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
|
-
|
|
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
|
|
137
|
-
if (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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
}))
|
package/lib/kernel/keeper.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/scripts/build-client.mjs
CHANGED
|
@@ -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', '
|
|
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
|
]
|