@zmainer/dsh-wx-bridge 1.1.2 → 1.1.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.
- package/README.md +11 -1
- package/lib/kernel/bridge.mjs +98 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -229,7 +229,7 @@ echo "来自管道的长文本" | node "<dataDir>/wxpush.mjs" --stdin
|
|
|
229
229
|
| Node | **`>=22.19.0`**(`package.json` 的 `engines`):会话文件是**多帧 zstd**,逐帧解压用到 `node:zlib` 的 `zstdDecompressSync`。桌面端自带运行时(本机实测 v26)与系统 Node(本机 v22.19)都验证过 |
|
|
230
230
|
| 操作系统 | **Windows 已实测**(进程树终止用 `taskkill /T /F`;运行时入口探测含 `%LOCALAPPDATA%\Programs\*` 扫描、PID 复用判断)。macOS / Linux **未实测**:内核是纯 Node,但上述几处是 Windows 实现,跨平台请先跑 `node lib/kernel/bridge.mjs --selftest-runtime` |
|
|
231
231
|
| 依赖 | **零运行时依赖**(只用 Node 内建模块);不依赖任何 `@deepseek-ai/*` 包(宿主能力通过 `ctx.inject` 取用,缺失即降级) |
|
|
232
|
-
| 自测 | `--selftest-runtime`(运行时入口)/ `--selftest-approval`(审批卡与回复解析)/ `--selftest-outbox`(推送信箱)/ `--selftest-files` / `--selftest-inbound` / `--selftest-presets` |
|
|
232
|
+
| 自测 | `--selftest-runtime`(运行时入口)/ `--selftest-approval`(审批卡与回复解析)/ `--selftest-acp-approval`(审批 ↔ ACP `session/request_permission` 的线上契约)/ **`--selftest-overlays`(叠层模板与生成物:预设一致 + 推送提示 + 缩进)** / `--selftest-outbox`(推送信箱)/ `--selftest-files` / `--selftest-inbound` / `--selftest-presets` |
|
|
233
233
|
|
|
234
234
|
## 常见问题 / Troubleshooting
|
|
235
235
|
|
|
@@ -266,6 +266,16 @@ ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳
|
|
|
266
266
|
|
|
267
267
|
## 最近变更
|
|
268
268
|
|
|
269
|
+
- **1.1.4**:修「生成物把新预设静默顶掉」——「对话档」的 ACP 叠层(`acp-overlay-chat.gen.yml`)是**磁盘缓存**,
|
|
270
|
+
改 `acp.permPreset` 或模板后不重启桥就会继续用旧的(本机实测:模板已是 `workspace-write`,磁盘上仍是
|
|
271
|
+
`danger-full-access` ⇒ 收紧预设被无声抵消)。现在每次生成都重写、不再依赖进程内缓存,
|
|
272
|
+
并把改写逻辑抽成纯函数 `buildChatOverlayText()`;新增 `--selftest-overlays`(模板预设 == 配置、
|
|
273
|
+
生成文本带推送命令、`defaultPreset` 行不含旧值、persona 缩进未破坏,四项断言全过)。
|
|
274
|
+
- **1.1.3**:新增 `--selftest-acp-approval` —— 把审批卡 ↔ ACP `session/request_permission` 的**线上契约**
|
|
275
|
+
变成可离线跑的断言:按 `acp.mjs` 实际收发的帧形状构造四类选项集(三选一 / 允许+拒绝 / 只有允许 / 空),
|
|
276
|
+
验证「批准 / 拒绝」映射到的 `optionId`、以及应答负载(`{outcome:{outcome:'selected',optionId}}`,
|
|
277
|
+
挑不到时 `cancelled`)。空选项集必须两侧都为空(fail-closed)。运行日志侧另记一条协议事实:
|
|
278
|
+
iLink 的 `getupdates` **不会**回投我们自己发出的消息(2026-09-24 实测)。
|
|
269
279
|
- **1.1.2**:修 `wxpush.mjs` 的**默认数据目录解析**——原来只按 `$DSH_HOME/wxbridge` 兜底,桌面端
|
|
270
280
|
(Electron)里 `DSH_HOME` 常为空或指向另一个 home,于是消息被排进 `~/.dsh/wxbridge/outbox`,
|
|
271
281
|
而桥在 `%APPDATA%\dsh-desktop\harness\wxbridge` 取件 ⇒ **排了队没人发**(本机实测)。
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -858,29 +858,41 @@ function ensurePushHelper() {
|
|
|
858
858
|
} catch (e) { log('push-helper-failed', { error: String(e?.message ?? e) }); return '' }
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
-
|
|
861
|
+
/**
|
|
862
|
+
* 把模板文本改写成实际要用的叠层文本(纯函数:可离线断言,不必真跑一遍桥)。
|
|
863
|
+
* 规则:`defaultPreset:` 一行**无论写的是什么值**都换成当前 `acp.permPreset`;
|
|
864
|
+
* 再按模板最后的缩进追加提示行(推送命令要给手机侧的 agent 看)。
|
|
865
|
+
*/
|
|
866
|
+
export function buildChatOverlayText(text, permPreset, hintLines) {
|
|
867
|
+
const lines = String(text ?? '').split(NL)
|
|
868
|
+
while (lines.length && !String(lines[lines.length - 1]).trim()) lines.pop()
|
|
869
|
+
const indent = (String(lines[lines.length - 1] || '').match(/^\s*/) || [''])[0]
|
|
870
|
+
const body = lines.map((l) => (/^(\s*defaultPreset:\s*)\S+\s*$/.test(l) ? l.replace(/^(\s*defaultPreset:\s*)\S+\s*$/, '$1' + permPreset) : l))
|
|
871
|
+
const hints = Array.isArray(hintLines) ? hintLines : []
|
|
872
|
+
return body.concat(hints.map((h) => indent + h)).join(NL) + NL
|
|
873
|
+
}
|
|
874
|
+
|
|
862
875
|
/**
|
|
863
876
|
* 「普通对话」档的叠层要带上推送提示:persona 是 `>-` 折叠标量,
|
|
864
877
|
* 追加同缩进的行即可(叶子行缩进从模板末尾算,别写死空格数)。
|
|
878
|
+
* ⚠️ 生成结果**缓存在磁盘**(`acp-overlay-chat.gen.yml`):改了 `acp.permPreset` 或模板后
|
|
879
|
+
* 必须重启桥才会重写,否则老生成文件会把新预设**静默顶掉**(2026-09-24 实测踩过)。
|
|
880
|
+
* 这里**每次都重写**(一次几毫秒):宁可多写一次,也不要让"磁盘上的旧生成物"成为隐形真源。
|
|
865
881
|
*/
|
|
866
882
|
function ensureChatOverlay() {
|
|
867
883
|
const src = join(HERE, 'acp-overlay-chat.yml')
|
|
868
|
-
if (chatOverlayPath) return chatOverlayPath
|
|
869
884
|
let text = ''
|
|
870
|
-
try { text = readFileSync(src, 'utf8') } catch {
|
|
871
|
-
if (text.includes('wxpush.mjs'))
|
|
872
|
-
const
|
|
873
|
-
while (lines.length && !String(lines[lines.length - 1]).trim()) lines.pop()
|
|
874
|
-
const indent = (String(lines[lines.length - 1] || '').match(/^\s*/) || [''])[0]
|
|
875
|
-
const body = lines.map((l) => (/^(\s*defaultPreset:\s*)\S+\s*$/.test(l) ? l.replace(/^(\s*defaultPreset:\s*)\S+\s*$/, '$1' + ACP_PERM_PRESET) : l))
|
|
876
|
-
const out = body.concat(pushHintLines().map((h) => indent + h)).join(NL) + NL
|
|
885
|
+
try { text = readFileSync(src, 'utf8') } catch { return src }
|
|
886
|
+
if (text.includes('wxpush.mjs')) return src
|
|
887
|
+
const out = buildChatOverlayText(text, ACP_PERM_PRESET, pushHintLines())
|
|
877
888
|
try {
|
|
878
889
|
const f = join(STATE_DIR, 'acp-overlay-chat.gen.yml')
|
|
879
890
|
writeFileSync(f, out)
|
|
891
|
+
// 只在内容变了时记一行,避免每次 spawn 都刷日志
|
|
892
|
+
if (!chatOverlayPath) log('chat-overlay-written', { file: f, permPreset: ACP_PERM_PRESET })
|
|
880
893
|
chatOverlayPath = f
|
|
881
|
-
log('chat-overlay-written', { file: f })
|
|
882
894
|
return f
|
|
883
|
-
} catch (e) { log('chat-overlay-failed', { error: String(e?.message ?? e) });
|
|
895
|
+
} catch (e) { log('chat-overlay-failed', { error: String(e?.message ?? e) }); return src }
|
|
884
896
|
}
|
|
885
897
|
|
|
886
898
|
function moveOutboxFile(file, dir, name, reason) {
|
|
@@ -2123,6 +2135,77 @@ async function main() {
|
|
|
2123
2135
|
}, null, 1))
|
|
2124
2136
|
process.exit(rows.every((r) => r.ok) && cli.ok ? 0 : 1)
|
|
2125
2137
|
}
|
|
2138
|
+
if (args.includes('--selftest-acp-approval')) {
|
|
2139
|
+
// 离线自测:审批卡 ↔ ACP `session/request_permission` 的**线上契约**
|
|
2140
|
+
// (帧形状、optionId 选择、应答负载都按 acp.mjs 里实际收发的样子构造;不连网络、不拉子进程)
|
|
2141
|
+
const cases = [
|
|
2142
|
+
{ name: '标准三选一', options: [
|
|
2143
|
+
{ optionId: 'allow-once', kind: 'allow_once', name: '允许一次' },
|
|
2144
|
+
{ optionId: 'allow-always', kind: 'allow_always', name: '总是允许' },
|
|
2145
|
+
{ optionId: 'reject-once', kind: 'reject_once', name: '拒绝' }],
|
|
2146
|
+
allow: 'allow-once', reject: 'reject-once' },
|
|
2147
|
+
{ name: '只有允许/拒绝两选', options: [
|
|
2148
|
+
{ optionId: 'a1', kind: 'allow', name: '允许' },
|
|
2149
|
+
{ optionId: 'r1', kind: 'reject', name: '拒绝' }],
|
|
2150
|
+
allow: 'a1', reject: 'r1' },
|
|
2151
|
+
{ name: '无拒绝项(上游只给允许)', options: [{ optionId: 'a1', kind: 'allow', name: '允许' }],
|
|
2152
|
+
allow: 'a1', reject: null },
|
|
2153
|
+
{ name: '空选项 → 两侧都必须为空(fail-closed)', options: [], allow: null, reject: null },
|
|
2154
|
+
]
|
|
2155
|
+
const resolved = cases.map((c) => {
|
|
2156
|
+
const opts = c.options
|
|
2157
|
+
const card = formatApprovalCard({ n: 1, tool: 'Bash: 删除 build 目录', options: opts, timeoutSec: 120 })
|
|
2158
|
+
const fromReply = (t) => { const p = parseApprovalReply(t); return pickOptionByDecision(opts, p.decision)?.optionId ?? null }
|
|
2159
|
+
const payload = (optionId) => optionId
|
|
2160
|
+
? { jsonrpc: '2.0', id: 7, result: { outcome: { outcome: 'selected', optionId } } }
|
|
2161
|
+
: { jsonrpc: '2.0', id: 7, result: { outcome: { outcome: 'cancelled' } } }
|
|
2162
|
+
return {
|
|
2163
|
+
name: c.name,
|
|
2164
|
+
allowByReply: fromReply('批准'), expectAllow: c.allow,
|
|
2165
|
+
rejectByReply: fromReply('拒绝'), expectReject: c.reject,
|
|
2166
|
+
ok: fromReply('批准') === c.allow && fromReply('拒绝') === c.reject,
|
|
2167
|
+
wireOnAllow: payload(c.allow),
|
|
2168
|
+
cardLines: card.split(NL).length,
|
|
2169
|
+
}
|
|
2170
|
+
})
|
|
2171
|
+
const ok = resolved.every((r) => r.ok)
|
|
2172
|
+
console.log('[selftest-acp-approval] ' + JSON.stringify({
|
|
2173
|
+
frame: { jsonrpc: '2.0', id: 7, method: 'session/request_permission',
|
|
2174
|
+
params: { sessionId: '<peer-session>', toolCall: { title: 'Bash: 删除 build 目录' }, options: cases[0].options } },
|
|
2175
|
+
timeoutSec: Math.round(APPROVAL_TIMEOUT_MS / 1000),
|
|
2176
|
+
permPreset: ACP_PERM_PRESET, permPolicy: ACP_PERM,
|
|
2177
|
+
resolved,
|
|
2178
|
+
}, null, 1))
|
|
2179
|
+
process.exit(ok ? 0 : 1)
|
|
2180
|
+
}
|
|
2181
|
+
if (args.includes('--selftest-overlays')) {
|
|
2182
|
+
// 离线自测:两个叠层模板里的 defaultPreset 必须等于当前 acp.permPreset;
|
|
2183
|
+
// 且「对话档」生成文本要保留 persona 缩进、并把推送命令带进去。
|
|
2184
|
+
// 起因:生成物是**磁盘缓存**,改了预设或模板后不重启桥就会拿旧的(2026-09-24 实测)。
|
|
2185
|
+
const read = (f) => { try { return readFileSync(join(HERE, f), 'utf8') } catch { return '' } }
|
|
2186
|
+
const presetOf = (t) => ((t.match(/^\s*defaultPreset:\s*(\S+)\s*$/m) || [])[1] || null)
|
|
2187
|
+
const rows = [
|
|
2188
|
+
['acp-overlay.yml', presetOf(read('acp-overlay.yml'))],
|
|
2189
|
+
['acp-overlay-chat.yml', presetOf(read('acp-overlay-chat.yml'))],
|
|
2190
|
+
].map(([file, got]) => ({ file, got, want: ACP_PERM_PRESET, ok: got === ACP_PERM_PRESET }))
|
|
2191
|
+
const gen = buildChatOverlayText(read('acp-overlay-chat.yml'), ACP_PERM_PRESET, pushHintLines())
|
|
2192
|
+
const genPreset = presetOf(gen)
|
|
2193
|
+
const genRows = [
|
|
2194
|
+
{ name: '生成文本的 defaultPreset', got: genPreset, want: ACP_PERM_PRESET, ok: genPreset === ACP_PERM_PRESET },
|
|
2195
|
+
{ name: '生成文本带推送命令', got: gen.includes(PUSH_CLI) ? 'yes' : 'no', want: 'yes', ok: gen.includes(PUSH_CLI) },
|
|
2196
|
+
{ name: '生成文本的 defaultPreset 行不含旧值', got: (/^\s*defaultPreset:.*danger-full-access/m.test(gen)) ? 'yes' : 'no', want: 'no', ok: !/^\s*defaultPreset:.*danger-full-access/m.test(gen) },
|
|
2197
|
+
{ name: 'persona 缩进未破坏', got: gen.includes(' 你是「手机微信通道」') ? 'ok' : 'bad', want: 'ok', ok: gen.includes(' 你是「手机微信通道」') },
|
|
2198
|
+
]
|
|
2199
|
+
const stale = (() => { try { return presetOf(readFileSync(join(STATE_DIR, 'acp-overlay-chat.gen.yml'), 'utf8')) } catch { return null } })()
|
|
2200
|
+
const ok = [...rows, ...genRows].every((r) => r.ok)
|
|
2201
|
+
console.log('[selftest-overlays] ' + JSON.stringify({
|
|
2202
|
+
permPreset: ACP_PERM_PRESET, templates: rows, generated: genRows,
|
|
2203
|
+
onDiskGenerated: { preset: stale, note: '磁盘上那份是上次生成的结果;与本进程不同 = 需要重启桥才会重写' },
|
|
2204
|
+
pushCli: PUSH_CLI,
|
|
2205
|
+
}, null, 1))
|
|
2206
|
+
process.exit(ok ? 0 : 1)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2126
2209
|
acquireLock()
|
|
2127
2210
|
// 上次进程遗留的运行态任务:本轮一启动就标成 interrupted。
|
|
2128
2211
|
// 否则 runningCount() 永远虚高,`while (runningCount() >= MAX_CONCURRENT_TASKS)` 会把新任务永久挡住。
|
|
@@ -2215,6 +2298,10 @@ async function main() {
|
|
|
2215
2298
|
const key = msgKey(msg)
|
|
2216
2299
|
if (seen.has(key)) continue
|
|
2217
2300
|
seen.add(key); state.processed.push(key)
|
|
2301
|
+
// 自回显(协议事实,2026-09-24 实测):iLink 的 getupdates **不会**把我们自己发出的消息投回来
|
|
2302
|
+
//(两条主动推送发出后无任何 inbound 回流);但随出站下发的是 message_type=2(MESSAGE_TYPE_BOT),
|
|
2303
|
+
// 若上游将来改成回显,症状是"桥把自己的回执/审批卡当成新任务反复执行"——届时在此按
|
|
2304
|
+
// `message_type === MESSAGE_TYPE_BOT` 或自己的 user_id 拦截即可。
|
|
2218
2305
|
log('inbound', { text: inboundText(msg).slice(0, 80), types: inboundItemTypes(msg) })
|
|
2219
2306
|
try { await handleMessage(wechatToken, msg) } catch (e) { log('handle-error', { error: String(e?.message ?? e) }) }
|
|
2220
2307
|
}
|