@zmainer/dsh-wx-bridge 1.0.8
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/LICENSE +21 -0
- package/README.md +201 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +440 -0
- package/lib/index.js +650 -0
- package/lib/kernel/acp-overlay-chat.yml +26 -0
- package/lib/kernel/acp-overlay.yml +22 -0
- package/lib/kernel/acp-preset-shim.mjs +162 -0
- package/lib/kernel/acp.mjs +232 -0
- package/lib/kernel/bridge.mjs +1341 -0
- package/lib/kernel/keeper.mjs +302 -0
- package/lib/kernel/second-brain-contract.txt +7 -0
- package/lib/pairing.js +225 -0
- package/lib/vendor/qr-svg.cjs +58 -0
- package/lib/vendor/qrcode-core/LICENSE +10 -0
- package/lib/vendor/qrcode-core/NOTICE.md +21 -0
- package/lib/vendor/qrcode-core/alignment-pattern.js +83 -0
- package/lib/vendor/qrcode-core/alphanumeric-data.js +59 -0
- package/lib/vendor/qrcode-core/bit-buffer.js +37 -0
- package/lib/vendor/qrcode-core/bit-matrix.js +65 -0
- package/lib/vendor/qrcode-core/byte-data.js +30 -0
- package/lib/vendor/qrcode-core/dijkstrajs.LICENSE +19 -0
- package/lib/vendor/qrcode-core/dijkstrajs.js +165 -0
- package/lib/vendor/qrcode-core/error-correction-code.js +135 -0
- package/lib/vendor/qrcode-core/error-correction-level.js +50 -0
- package/lib/vendor/qrcode-core/finder-pattern.js +22 -0
- package/lib/vendor/qrcode-core/format-info.js +29 -0
- package/lib/vendor/qrcode-core/galois-field.js +69 -0
- package/lib/vendor/qrcode-core/kanji-data.js +54 -0
- package/lib/vendor/qrcode-core/mask-pattern.js +234 -0
- package/lib/vendor/qrcode-core/mode.js +167 -0
- package/lib/vendor/qrcode-core/numeric-data.js +43 -0
- package/lib/vendor/qrcode-core/package.json +8 -0
- package/lib/vendor/qrcode-core/polynomial.js +62 -0
- package/lib/vendor/qrcode-core/qrcode.js +495 -0
- package/lib/vendor/qrcode-core/reed-solomon-encoder.js +56 -0
- package/lib/vendor/qrcode-core/regex.js +31 -0
- package/lib/vendor/qrcode-core/segments.js +330 -0
- package/lib/vendor/qrcode-core/utils.js +63 -0
- package/lib/vendor/qrcode-core/version-check.js +9 -0
- package/lib/vendor/qrcode-core/version.js +163 -0
- package/package.json +40 -0
- package/scripts/build-client.mjs +35 -0
- package/src/client.js +427 -0
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat ⇄ DSH 桥 v0.3.1 · 安全加固版(零硬编码路径)
|
|
3
|
+
* P0-1 白名单(TOFU) / P0-2 共享token+防重放 / P0-3 子进程专用HOME(workspace-write)
|
|
4
|
+
* P0-4 状态原子写+校验 / P0-5 子进程只拿模型密钥 / P1-1 轮询与执行解耦
|
|
5
|
+
* P1-2 任务队列 / P1-3 进程树终止 / P1-5 独立任务日志 / P1-6 失败轮不可信
|
|
6
|
+
*
|
|
7
|
+
* 路径解析(全部可配置,无硬编码绝对路径):
|
|
8
|
+
* --data-dir / BRIDGE_DATA / WXBRIDGE_DATA / $DSH_HOME/wxbridge 数据目录
|
|
9
|
+
* --cwd / BRIDGE_CWD 默认工作区
|
|
10
|
+
* --contract / CONTRACT_FILE 身份契约文件
|
|
11
|
+
* DSH_BIN / PATH 上的 dsh / npm 全局安装位 DSH 入口
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs'
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { AcpHost } from './acp.mjs'
|
|
16
|
+
import { ensureAcpPresetSupport, acpPresetSupported, acpPackageFile } from './acp-preset-shim.mjs'
|
|
17
|
+
import { randomUUID, randomBytes, createHash } from 'node:crypto'
|
|
18
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { join, dirname, resolve, basename } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2)
|
|
24
|
+
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d }
|
|
25
|
+
const PROFILE = argOf('--profile', 'headless')
|
|
26
|
+
const DEBUG = args.includes('--debug')
|
|
27
|
+
const NL = String.fromCharCode(10)
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
|
|
30
|
+
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]
|
|
36
|
+
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'))
|
|
38
|
+
}
|
|
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 } }) || ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const STATE_DIR = resolve(argOf('--data-dir') || process.env.BRIDGE_DATA || process.env.WXBRIDGE_DATA
|
|
44
|
+
|| join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge'))
|
|
45
|
+
const CWD = resolve(argOf('--cwd') || process.env.BRIDGE_CWD || process.cwd())
|
|
46
|
+
const DSH_BIN = resolveDshBin()
|
|
47
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
48
|
+
const DESKTOP_HOME = process.env.OHDSH_HOME || join(homedir(), '.ohdsh')
|
|
49
|
+
|
|
50
|
+
const STATE_FILE = join(STATE_DIR, 'state.json')
|
|
51
|
+
const LOG_FILE = join(STATE_DIR, 'bridge.log')
|
|
52
|
+
const HEARTBEAT_FILE = join(STATE_DIR, 'heartbeat.json')
|
|
53
|
+
const LOCK_FILE = join(STATE_DIR, 'bridge.lock')
|
|
54
|
+
const CONTRACT_FILE = resolve(argOf('--contract') || process.env.CONTRACT_FILE || join(STATE_DIR, 'second-brain-contract.txt'))
|
|
55
|
+
function resolveVault(explicit) {
|
|
56
|
+
if (explicit) return explicit
|
|
57
|
+
if (process.env.BRAIN_VAULT) return process.env.BRAIN_VAULT
|
|
58
|
+
const cfgDir = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge')
|
|
59
|
+
for (const f of [process.env.WXBRIDGE_CONFIG, join(cfgDir, 'config.json'), join(process.cwd(), 'wxbridge.json')]) {
|
|
60
|
+
try { const v = JSON.parse(readFileSync(f, 'utf8')).vault; if (v) return v } catch {}
|
|
61
|
+
}
|
|
62
|
+
return join(homedir(), 'Brain', 'vault')
|
|
63
|
+
}
|
|
64
|
+
const VAULT_PATH = resolveVault(argOf('--vault'))
|
|
65
|
+
/**
|
|
66
|
+
* HOST_HOME:宿主(桌面应用/CLI web)的 DSH_HOME。给了它 → 子进程会话直接写进**宿主的会话仓库**,
|
|
67
|
+
* 于是手机对话会出现在桌面工作区里、可点开继续(同一个工作区按 identity.cwd 绑定)。
|
|
68
|
+
* 不给 → 保持旧行为:私有隔离 home(只拿模型密钥,会话不对外可见)。
|
|
69
|
+
*/
|
|
70
|
+
const HOST_HOME = argOf('--host-home') || process.env.BRIDGE_HOST_HOME || ''
|
|
71
|
+
/**
|
|
72
|
+
* OWNER_PID:宿主托管(lifecycle=host)时由宿主传入自己的 pid。
|
|
73
|
+
* 桥每 20s 检查一次「主人还在不在」,不在就自己退出——保证应用关闭后
|
|
74
|
+
* 不会有游离在外的常驻进程(即使应用是被强杀的、来不及优雅收尾)。
|
|
75
|
+
*/
|
|
76
|
+
const OWNER_PID = Number(argOf('--owner-pid') || process.env.BRIDGE_OWNER_PID || 0) || 0
|
|
77
|
+
const AUTH_FILE = join(STATE_DIR, 'auth-token.txt')
|
|
78
|
+
const TASKS_DIR = join(STATE_DIR, 'tasks')
|
|
79
|
+
const CHILD_HOME = join(STATE_DIR, 'dsh-home')
|
|
80
|
+
const DESKTOP_WS_FILE = join(DESKTOP_HOME, 'storages', 'workspace.json')
|
|
81
|
+
const CLI_WS_FILE = join(DSH_HOME, 'storages', 'workspace.json')
|
|
82
|
+
|
|
83
|
+
const API_BASE = process.env.ILINK_BASE || 'https://ilinkai.weixin.qq.com'
|
|
84
|
+
const PROTOCOL_VERSION = '2.4.6'
|
|
85
|
+
const BRIDGE_VERSION = '0.4.0'
|
|
86
|
+
const BOOT_ID = process.pid
|
|
87
|
+
const MESSAGE_TYPE_BOT = 2
|
|
88
|
+
const ITEM_TYPE_TEXT = 1
|
|
89
|
+
const MAX_CHUNK = 1500
|
|
90
|
+
const HEARTBEAT_MS = 30000
|
|
91
|
+
const TASK_TIMEOUT_MS = Number(process.env.BRIDGE_TASK_TIMEOUT_MS || 300000)
|
|
92
|
+
const MAX_CONCURRENT_TASKS = Number(process.env.BRIDGE_MAX_TASKS || 2)
|
|
93
|
+
const DEDUP_WINDOW_MS = 24 * 3600 * 1000
|
|
94
|
+
/**
|
|
95
|
+
* 授权策略:
|
|
96
|
+
* auto (默认)—— 任何给本 bot 发消息的联系人自动登记 + 记审计日志。
|
|
97
|
+
* 准入边界前移到「扫码拿到 bot 控制权」那一步:能扫码 = 本来就有权。
|
|
98
|
+
* 换号/多台手机都无需干预(2026-09-21 用户反馈后定为默认)。
|
|
99
|
+
* strict —— 只认白名单;未登记的一律丢弃(旧行为)。
|
|
100
|
+
* open —— 不做任何检查(不建议:任何能找到该 bot 的号都能驱动本机)。
|
|
101
|
+
*/
|
|
102
|
+
const ALLOWLIST = (argOf('--allowlist') || process.env.WXBRIDGE_ALLOWLIST || 'auto').toLowerCase()
|
|
103
|
+
/**
|
|
104
|
+
* 执行模式:
|
|
105
|
+
* host (默认,若宿主可达)—— 任务交给宿主进程执行:原生会话 + 原生上下文,
|
|
106
|
+
* 会话自动归属工作区、手机与桌面是同一条会话。
|
|
107
|
+
* headless (回落)—— 宿主不可达时仍用一次性 dsh 子进程(行为同旧版)。
|
|
108
|
+
* 配置项 execution 可强制指定;auto = 能用宿主就用宿主。
|
|
109
|
+
*/
|
|
110
|
+
const EXECUTION = (argOf('--execution') || process.env.WXBRIDGE_EXECUTION || 'auto').toLowerCase()
|
|
111
|
+
/**
|
|
112
|
+
* 桥的配置文件($DSH_HOME/wxbridge/config.json,面板/手写均可)。
|
|
113
|
+
*/
|
|
114
|
+
function loadBridgeConfig() {
|
|
115
|
+
for (const f of [process.env.WXBRIDGE_CONFIG, join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge', 'config.json')]) {
|
|
116
|
+
try { const j = JSON.parse(readFileSync(f, 'utf8')); if (j && typeof j === 'object') return j } catch {}
|
|
117
|
+
}
|
|
118
|
+
return {}
|
|
119
|
+
}
|
|
120
|
+
const BRIDGE_CONFIG = loadBridgeConfig()
|
|
121
|
+
const ACP_CFG = (BRIDGE_CONFIG && typeof BRIDGE_CONFIG.acp === 'object') ? BRIDGE_CONFIG.acp : {}
|
|
122
|
+
/**
|
|
123
|
+
* ACP 路径(首选执行方式):桥起一个常驻 `dsh --profile acp`,用 Agent Client Protocol
|
|
124
|
+
* 驱动 **DSH 原生会话** —— 上下文由 DSH 持久化持有(可 resume),桥不再把历史拼进提示词。
|
|
125
|
+
* 关掉:config.json 里 acp.enabled=false,或环境变量 WXBRIDGE_ACP=off。
|
|
126
|
+
*/
|
|
127
|
+
const ACP_ENABLED = !(argOf('--acp') === 'off' || process.env.WXBRIDGE_ACP === 'off' || ACP_CFG.enabled === false)
|
|
128
|
+
const ACP_HOME = ACP_CFG.home || HOST_HOME || CHILD_HOME
|
|
129
|
+
const ACP_PATCH = ACP_CFG.patch || join(HERE, 'acp-overlay.yml')
|
|
130
|
+
let ACP_PERM = ACP_CFG.permPolicy === 'reject' ? 'reject' : 'allow'
|
|
131
|
+
/**
|
|
132
|
+
* ACP 用哪个 dsh 入口:会话仓库归谁,就用谁的运行时(schema 必须对得上,
|
|
133
|
+
* 否则应用读不懂外部写的会话 —— 2026-09-21 实锤的 v0/v3 分裂)。
|
|
134
|
+
*/
|
|
135
|
+
function resolveAcpBin() {
|
|
136
|
+
const explicit = ACP_CFG.dshBin || process.env.BRIDGE_ACP_DSH_BIN
|
|
137
|
+
if (explicit && existsSync(explicit)) return explicit
|
|
138
|
+
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]
|
|
145
|
+
if (hit) return hit
|
|
146
|
+
}
|
|
147
|
+
return DSH_BIN
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const d of [STATE_DIR, TASKS_DIR, CHILD_HOME]) mkdirSync(d, { recursive: true })
|
|
151
|
+
|
|
152
|
+
const log = (m, extra) => {
|
|
153
|
+
const line = new Date().toISOString() + ' ' + m + (extra ? ' ' + JSON.stringify(extra) : '')
|
|
154
|
+
if (DEBUG) console.log(line)
|
|
155
|
+
try { appendFileSync(LOG_FILE, line + NL) } catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function acquireLock() {
|
|
159
|
+
try {
|
|
160
|
+
if (existsSync(LOCK_FILE)) {
|
|
161
|
+
const old = JSON.parse(readFileSync(LOCK_FILE, 'utf8'))
|
|
162
|
+
if (old && old.pid) {
|
|
163
|
+
let alive = false
|
|
164
|
+
try { process.kill(old.pid, 0); alive = true } catch {}
|
|
165
|
+
let fresh = false
|
|
166
|
+
try {
|
|
167
|
+
const hb = JSON.parse(readFileSync(HEARTBEAT_FILE, 'utf8'))
|
|
168
|
+
fresh = !!(hb && hb.pid === old.pid && (Date.now() - Date.parse(hb.at)) < 120000)
|
|
169
|
+
} catch {}
|
|
170
|
+
// 只有「pid 活着 **且** 心跳新鲜」才算真在跑;否则接管陈旧锁并说明原因。
|
|
171
|
+
//(Windows 上 pid 会复用:只看 pid 会让新实例**静默 exit 0**,表现为"启动不了"。)
|
|
172
|
+
if (alive && fresh) { console.log('[bridge] 已有实例 pid=' + old.pid + ',退出'); process.exit(0) }
|
|
173
|
+
console.log('[bridge] 接管陈旧锁 pid=' + old.pid + '(alive=' + alive + ' fresh=' + fresh + ')')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
|
|
178
|
+
const release = () => { try { writeFileSync(LOCK_FILE, '{}') } catch {} }
|
|
179
|
+
process.on('exit', release)
|
|
180
|
+
process.on('SIGINT', () => { release(); process.exit(0) })
|
|
181
|
+
process.on('SIGTERM', () => { release(); process.exit(0) })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function loadAuthToken() {
|
|
185
|
+
if (process.env.DSH_BRIDGE_TOKEN) return process.env.DSH_BRIDGE_TOKEN.trim()
|
|
186
|
+
try { const t = readFileSync(AUTH_FILE, 'utf8').trim(); if (t) return t } catch {}
|
|
187
|
+
const t = randomBytes(12).toString('hex')
|
|
188
|
+
writeFileSync(AUTH_FILE, t + NL)
|
|
189
|
+
try { spawn('cmd.exe', ['/c', 'icacls "' + AUTH_FILE + '" /inheritance:r /grant:r "' + process.env.USERNAME + ':R"'], { windowsHide: true, stdio: 'ignore' }).unref() } catch {}
|
|
190
|
+
return t
|
|
191
|
+
}
|
|
192
|
+
const AUTH_TOKEN = loadAuthToken()
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 注入给每条手机任务的提示词(原来固定是内置的 second-brain 契约)。
|
|
196
|
+
* 现在优先级:配置里的自定义提示词 > 内置契约模板文件 > 空。
|
|
197
|
+
* 空字符串表示"不注入任何身份提示词"——不想让提示词出现在微信里的用户选这个。
|
|
198
|
+
*/
|
|
199
|
+
function loadPromptText() {
|
|
200
|
+
const cfgFile = process.env.WXBRIDGE_CONFIG
|
|
201
|
+
|| join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge', 'config.json')
|
|
202
|
+
try {
|
|
203
|
+
const cfg = JSON.parse(readFileSync(cfgFile, 'utf8'))
|
|
204
|
+
if (typeof cfg.prompt === 'string') return cfg.prompt.trim()
|
|
205
|
+
} catch {}
|
|
206
|
+
try {
|
|
207
|
+
return readFileSync(CONTRACT_FILE, 'utf8').trim()
|
|
208
|
+
.replace(/\{\{vault\}\}/g, VAULT_PATH)
|
|
209
|
+
.replace(/\{\{cwd\}\}/g, CWD)
|
|
210
|
+
.replace(/\{\{model\}\}/g, process.env.BRIDGE_MODEL_LABEL || 'DeepSeek')
|
|
211
|
+
} catch { return '' }
|
|
212
|
+
}
|
|
213
|
+
let CONTRACT = loadPromptText()
|
|
214
|
+
let CONTRACT_HASH = CONTRACT ? createHash('sha256').update(CONTRACT).digest('hex').slice(0, 12) : 'none'
|
|
215
|
+
let PROMPT_PREFIX = CONTRACT ? CONTRACT.split(NL).join(' ') + ' 【以上为固定身份与铁律;下面是本轮对话】 ' : ''
|
|
216
|
+
|
|
217
|
+
function promptConfigPath() {
|
|
218
|
+
return process.env.WXBRIDGE_CONFIG
|
|
219
|
+
|| join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge', 'config.json')
|
|
220
|
+
}
|
|
221
|
+
let PROMPT_MTIME = 0
|
|
222
|
+
try { PROMPT_MTIME = statSync(promptConfigPath()).mtimeMs } catch {}
|
|
223
|
+
|
|
224
|
+
/** 提示词热重载:面板保存后下一条任务即生效,不必重启桥。 */
|
|
225
|
+
function reloadPromptIfChanged() {
|
|
226
|
+
const f = promptConfigPath()
|
|
227
|
+
let mt = 0
|
|
228
|
+
try { mt = statSync(f).mtimeMs } catch { return }
|
|
229
|
+
if (mt === PROMPT_MTIME) return
|
|
230
|
+
PROMPT_MTIME = mt
|
|
231
|
+
const next = loadPromptText()
|
|
232
|
+
if (next === CONTRACT) return
|
|
233
|
+
CONTRACT = next
|
|
234
|
+
CONTRACT_HASH = CONTRACT ? createHash('sha256').update(CONTRACT).digest('hex').slice(0, 12) : 'none'
|
|
235
|
+
PROMPT_PREFIX = CONTRACT ? CONTRACT.split(NL).join(' ') + ' 【以上为固定身份与铁律;下面是本轮对话】 ' : ''
|
|
236
|
+
log('prompt-hot-reloaded', { len: CONTRACT.length, hash: CONTRACT_HASH })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function loadRefsFrom(home, excludeWechat) {
|
|
240
|
+
const file = join(home, '.credentials.yaml')
|
|
241
|
+
if (!existsSync(file)) return {}
|
|
242
|
+
const out = {}; let inRefs = false
|
|
243
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
244
|
+
if (/^refs:\s*$/.test(line)) { inRefs = true; continue }
|
|
245
|
+
if (/^[A-Za-z_]/.test(line)) { inRefs = false; continue }
|
|
246
|
+
if (!inRefs) continue
|
|
247
|
+
const m = line.match(/^\s{2}([A-Za-z_][A-Za-z0-9_]*):\s*(.+)$/)
|
|
248
|
+
if (!m) continue
|
|
249
|
+
if (excludeWechat && m[1] === 'DSH_WEIXIN_BOT_TOKEN') continue
|
|
250
|
+
out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
function prepareChildHome() {
|
|
255
|
+
// 合并所有已知密钥来源:本机 DSH_HOME、宿主 home、已有子进程 home
|
|
256
|
+
const refs = {}
|
|
257
|
+
for (const src of [DSH_HOME, HOST_HOME, CHILD_HOME]) {
|
|
258
|
+
if (!src) continue
|
|
259
|
+
Object.assign(refs, loadRefsFrom(src, true))
|
|
260
|
+
}
|
|
261
|
+
for (const k of Object.keys(refs)) process.env[k] ||= refs[k]
|
|
262
|
+
|
|
263
|
+
if (HOST_HOME) {
|
|
264
|
+
// 共享模式:会话写进宿主仓库 → 手机对话在桌面工作区可见、可继续。
|
|
265
|
+
// 只补密钥,**不动**宿主自己的 settings.yaml(那是用户的配置)。
|
|
266
|
+
// 合并写入:保留宿主已有的密钥(ZHIPUAI_API_KEY / OPENCODE_GO_API_KEY / 微信 token 等)
|
|
267
|
+
const cred = join(HOST_HOME, '.credentials.yaml')
|
|
268
|
+
const existing = loadRefsFrom(HOST_HOME, false)
|
|
269
|
+
const merged = { ...existing, ...refs }
|
|
270
|
+
writeFileSync(cred, ['version: 1', 'refs:'].concat(Object.keys(merged).map((k) => ' ' + k + ': ' + merged[k])).join(NL) + NL)
|
|
271
|
+
if (!Object.keys(merged).some((k) => /API_KEY$/.test(k))) log('warn: 宿主 home 里没有找到任何 *_API_KEY,无头任务可能因缺密钥失败')
|
|
272
|
+
const prof = join(HOST_HOME, 'profiles', 'headless')
|
|
273
|
+
mkdirSync(join(prof, 'node_modules'), { recursive: true })
|
|
274
|
+
writeFileSync(join(prof, 'package.json'), JSON.stringify({
|
|
275
|
+
name: 'dsh-profile-headless', private: true, dependencies: {},
|
|
276
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], patchReload: 'startup' } },
|
|
277
|
+
}, null, 2) + NL)
|
|
278
|
+
writeFileSync(join(prof, 'cordis.yml'), '# dsh profile root (managed by wxbridge)' + NL + '[]' + NL)
|
|
279
|
+
writeFileSync(join(prof, 'cordis.patch.yml'),
|
|
280
|
+
'# wxbridge: keep headless permissions narrow without touching the host settings.yaml' + NL
|
|
281
|
+
+ '- id: permission' + NL + ' config:' + NL + ' defaultPreset: workspace-write' + NL)
|
|
282
|
+
return { refs: Object.keys(refs), home: HOST_HOME, shared: true }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const target = join(CHILD_HOME, '.credentials.yaml')
|
|
286
|
+
writeFileSync(target, ['version: 1', 'refs:'].concat(Object.keys(refs).map((k) => ' ' + k + ': ' + refs[k])).join(NL) + NL)
|
|
287
|
+
// 默认预设**不写死**:预设由用户自己的 DSH 决定(ACP 会话本就不加入预设;写死某个人的预设
|
|
288
|
+
// 等于把插件产品绑到某个人的环境上)。
|
|
289
|
+
writeFileSync(join(CHILD_HOME, 'settings.yaml'), 'permission:' + NL + ' defaultPreset: workspace-write' + NL)
|
|
290
|
+
try { spawn('cmd.exe', ['/c', 'icacls "' + target + '" /inheritance:r /grant:r "' + process.env.USERNAME + ':R"'], { windowsHide: true, stdio: 'ignore' }).unref() } catch {}
|
|
291
|
+
return { refs: Object.keys(refs), home: CHILD_HOME, shared: false }
|
|
292
|
+
}
|
|
293
|
+
let TOKEN_FILE = '' // 当前 token 的来源文件(用于热更新检测)
|
|
294
|
+
let TOKEN_MTIME = 0
|
|
295
|
+
function loadWechatToken() {
|
|
296
|
+
if (process.env.DSH_WEIXIN_BOT_TOKEN) { TOKEN_FILE = '@env'; return process.env.DSH_WEIXIN_BOT_TOKEN }
|
|
297
|
+
// 依次在 本机 DSH_HOME / 宿主 home / 子进程 home 里找微信 token:
|
|
298
|
+
// 桌面端的 token 存在宿主 home,而模型密钥可能存在另一个 home —— 两者都认才不会被部署形态卡死。
|
|
299
|
+
const tried = []
|
|
300
|
+
// 宿主 home 优先:扫码配对是在宿主进程里发生的,新 token 会写进宿主的 home;
|
|
301
|
+
// 若仍优先读旧 DSH_HOME,桥会拿着过期 token 轮询,表现为"消息收不到"(2026-09-21 实锤)。
|
|
302
|
+
for (const home of [HOST_HOME, DSH_HOME, CHILD_HOME]) {
|
|
303
|
+
if (!home) continue
|
|
304
|
+
const f = join(home, '.credentials.yaml')
|
|
305
|
+
tried.push(f)
|
|
306
|
+
try {
|
|
307
|
+
const m = readFileSync(f, 'utf8').match(/^\s*DSH_WEIXIN_BOT_TOKEN:\s*(.+)$/m)
|
|
308
|
+
if (m) { TOKEN_FILE = f; return m[1].trim().replace(/^["']|["']$/g, '') }
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
throw new Error('DSH_WEIXIN_BOT_TOKEN not found in: ' + tried.join(' ; '))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const emptyState = () => ({ syncBuf: '', processed: [], peers: {}, allowedUsers: [], tasks: {}, account: null })
|
|
315
|
+
const checksumOf = (o) => { const c = { ...o }; delete c.checksum; return createHash('sha256').update(JSON.stringify(c)).digest('hex') }
|
|
316
|
+
function loadState() {
|
|
317
|
+
if (!existsSync(STATE_FILE)) return emptyState()
|
|
318
|
+
try {
|
|
319
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
320
|
+
if (raw.checksum && raw.checksum !== checksumOf(raw)) {
|
|
321
|
+
try { renameSync(STATE_FILE, STATE_FILE + '.tampered-' + Date.now()) } catch {}
|
|
322
|
+
log('state-checksum-mismatch: quarantined')
|
|
323
|
+
return emptyState()
|
|
324
|
+
}
|
|
325
|
+
raw.peers ||= {}; raw.allowedUsers ||= []; raw.tasks ||= {}; raw.processed ||= []
|
|
326
|
+
return raw
|
|
327
|
+
} catch (e) { log('state-load-failed', { error: String(e?.message ?? e) }); return emptyState() }
|
|
328
|
+
}
|
|
329
|
+
let stateChain = Promise.resolve()
|
|
330
|
+
function saveState(s) {
|
|
331
|
+
s.processed = s.processed.slice(-500)
|
|
332
|
+
for (const k of Object.keys(s.peers)) s.peers[k].history = (s.peers[k].history || []).slice(-50)
|
|
333
|
+
for (const k of Object.keys(s.tasks)) { const t = s.tasks[k]; if (t.status !== 'running' && t.endedAt && Date.now() - Date.parse(t.endedAt) > 3600000) delete s.tasks[k] }
|
|
334
|
+
s.checksum = checksumOf(s)
|
|
335
|
+
const tmp = STATE_FILE + '.tmp'
|
|
336
|
+
stateChain = stateChain.then(() => { try { writeFileSync(tmp, JSON.stringify(s, null, 1)); renameSync(tmp, STATE_FILE); lastSelfSave = Date.now() } catch (e) { log('state-write-failed', { error: String(e?.message ?? e) }) } })
|
|
337
|
+
return stateChain
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let pollCount = 0, activeTasks = 0
|
|
341
|
+
function beat(phase) { try { writeFileSync(HEARTBEAT_FILE, JSON.stringify({ at: new Date().toISOString(), phase, polls: pollCount, tasks: activeTasks, pid: process.pid, boot: BOOT_ID })) } catch {} }
|
|
342
|
+
|
|
343
|
+
function readWorkspaces() {
|
|
344
|
+
const out = []
|
|
345
|
+
const add = (t, p) => { if (p && !out.some((w) => w.path.toLowerCase() === String(p).toLowerCase())) out.push({ title: t || p, path: String(p) }) }
|
|
346
|
+
for (const f of [DESKTOP_WS_FILE, CLI_WS_FILE]) { try { const d = JSON.parse(readFileSync(f, 'utf8')); const ws = (d?.tables?.workspaces) || {}; for (const k of Object.keys(ws)) add(ws[k].title, ws[k].path) } catch {} }
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
function workspaceListText(peer) {
|
|
350
|
+
const list = readWorkspaces()
|
|
351
|
+
if (!list.length) return '(未读到工作区列表)'
|
|
352
|
+
const lines = ['可用工作区(来自桌面端):']
|
|
353
|
+
list.forEach((w, i) => lines.push((i + 1) + '. ' + w.title + NL + ' ' + w.path + (peer.cwd && w.path.toLowerCase() === peer.cwd.toLowerCase() ? ' ← 当前' : '')))
|
|
354
|
+
lines.push(NL + '切换:/ws <序号> 或 /ws <完整路径>')
|
|
355
|
+
return lines.join(NL)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const BAD_CODES = [34, 38, 124, 60, 62, 94, 37, 33, 96, 45, 40, 41, 123, 125, 91, 93, 59, 58, 39, 92, 36, 35, 42, 43, 61, 126, 64, 63, 47]
|
|
359
|
+
const BAD_CHARS = BAD_CODES.map((c) => String.fromCharCode(c)).concat(['—', '–', ' ', NL, String.fromCharCode(13)])
|
|
360
|
+
const sanitize = (t) => { let o = String(t ?? ''); for (const c of BAD_CHARS) o = o.split(c).join(' '); while (o.indexOf(' ') >= 0) o = o.split(' ').join(' '); return o.trim() }
|
|
361
|
+
|
|
362
|
+
const randomUin = () => String(1000000000 + (randomBytes(4).readUInt32BE(0) % 1000000000))
|
|
363
|
+
const commonHeaders = () => ({ 'iLink-App-Id': 'bot', 'iLink-App-ClientVersion': String((2 << 16) | (4 << 8) | 6) })
|
|
364
|
+
const baseInfo = () => ({ channel_version: PROTOCOL_VERSION, bot_agent: 'DSH-bridge/' + BRIDGE_VERSION })
|
|
365
|
+
async function ilink(endpoint, { token, body, method = 'POST', timeoutMs = 15000 } = {}) {
|
|
366
|
+
const url = new URL(endpoint, API_BASE + '/')
|
|
367
|
+
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), timeoutMs)
|
|
368
|
+
try {
|
|
369
|
+
const res = await fetch(url, {
|
|
370
|
+
method,
|
|
371
|
+
headers: method === 'GET' ? commonHeaders() : { 'content-type': 'application/json', AuthorizationType: 'ilink_bot_token', 'X-WECHAT-UIN': randomUin(), ...commonHeaders(), ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
|
372
|
+
...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }),
|
|
373
|
+
signal: ctl.signal,
|
|
374
|
+
})
|
|
375
|
+
const raw = await res.text()
|
|
376
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + raw.slice(0, 300))
|
|
377
|
+
return JSON.parse(raw)
|
|
378
|
+
} finally { clearTimeout(t) }
|
|
379
|
+
}
|
|
380
|
+
const getUpdates = (token, syncBuf, timeoutMs) => ilink('ilink/bot/getupdates', { token, body: { get_updates_buf: syncBuf, base_info: baseInfo() }, timeoutMs })
|
|
381
|
+
.catch((e) => { if (e?.name === 'AbortError') return { ret: 0, msgs: [], get_updates_buf: syncBuf }; throw e })
|
|
382
|
+
const sendText = (token, to, text, contextToken) => ilink('ilink/bot/sendmessage', {
|
|
383
|
+
token,
|
|
384
|
+
body: { msg: { from_user_id: '', to_user_id: to, client_id: 'dsh-bridge-' + randomUUID(), message_type: MESSAGE_TYPE_BOT, message_state: 2, item_list: [{ type: ITEM_TYPE_TEXT, text_item: { text } }], ...(contextToken ? { context_token: contextToken } : {}) }, base_info: baseInfo() },
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
function inboundText(msg) { for (const it of msg?.item_list ?? []) if (it?.type === ITEM_TYPE_TEXT && typeof it.text_item?.text === 'string') return it.text_item.text; return '' }
|
|
388
|
+
const msgKey = (m) => 'id:' + (m?.msg_id ?? m?.message_id ?? m?.client_id ?? JSON.stringify(m).slice(0, 80))
|
|
389
|
+
|
|
390
|
+
const SECRET_PATTERNS = [/sk-[A-Za-z0-9]{16,}/g, /ghp_[A-Za-z0-9]{20,}/g, /AKIA[0-9A-Z]{16}/g, /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,4000}?-----END [A-Z ]*PRIVATE KEY-----/g]
|
|
391
|
+
const EXFIL_HINT = /(curl|wget|Invoke-WebRequest|nc\.exe|ncat)[^\n]{0,120}(https?:\/\/|\d{1,3}(\.\d{1,3}){3})/i
|
|
392
|
+
function auditOutput(text) {
|
|
393
|
+
let out = String(text ?? ''); const hits = []
|
|
394
|
+
for (const re of SECRET_PATTERNS) if (re.test(out)) { hits.push('secret-pattern'); out = out.replace(re, '[REDACTED]') }
|
|
395
|
+
if (EXFIL_HINT.test(out)) { hits.push('exfil-command'); out = '⚠️ 输出疑似包含外发命令,已阻断回传。' + NL + out.slice(0, 200) }
|
|
396
|
+
if (out.length > 20000) { hits.push('oversize'); out = out.slice(0, 20000) + NL + '…(已截断)' }
|
|
397
|
+
return { text: out, hits }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let hostPort = 0
|
|
401
|
+
let hostPortCheckedAt = 0
|
|
402
|
+
const HOST_HOSTS = ['127.0.0.1']
|
|
403
|
+
/** 发现宿主:优先 host-address.json,其次在已知端口上探 /wxbridge/status。 */
|
|
404
|
+
async function discoverHost() {
|
|
405
|
+
if (EXECUTION === 'headless') return 0
|
|
406
|
+
const now = Date.now()
|
|
407
|
+
if (hostPort && now - hostPortCheckedAt < 60000) return hostPort
|
|
408
|
+
hostPortCheckedAt = now
|
|
409
|
+
const cands = []
|
|
410
|
+
for (const f of [join(STATE_DIR, 'host-address.json')]) {
|
|
411
|
+
try { const j = JSON.parse(readFileSync(f, 'utf8')); if (j && j.port) cands.push(Number(j.port)) } catch {}
|
|
412
|
+
}
|
|
413
|
+
if (process.env.WXBRIDGE_HOST_PORT) cands.push(Number(process.env.WXBRIDGE_HOST_PORT))
|
|
414
|
+
// 从最近的 dsh web 启动日志里捞端口(桌面/CLI 宿主都会打印)
|
|
415
|
+
for (const f of [join(DESKTOP_HOME, 'logs', 'desktop.log')]) {
|
|
416
|
+
try {
|
|
417
|
+
const txt = readFileSync(f, 'utf8')
|
|
418
|
+
const all = [...txt.matchAll(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=/g)].map((m) => Number(m[1]))
|
|
419
|
+
for (const v of all.slice(-3)) cands.push(v)
|
|
420
|
+
} catch {}
|
|
421
|
+
}
|
|
422
|
+
for (const port of [...new Set(cands)].filter(Boolean)) {
|
|
423
|
+
for (const h of HOST_HOSTS) {
|
|
424
|
+
try {
|
|
425
|
+
const r = await fetch('http://' + h + ':' + port + '/wxbridge/status', { signal: AbortSignal.timeout(2500) })
|
|
426
|
+
const j = await r.json().catch(() => null)
|
|
427
|
+
if (r.ok && j && j.plugin === 'wxbridge') { hostPort = port; log('host-discovered', { port: hostPort }); return hostPort }
|
|
428
|
+
} catch {}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
hostPort = 0
|
|
432
|
+
return 0
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** 把一轮任务交给宿主执行:原生会话 + 原生上下文 + 自动归属工作区。 */
|
|
436
|
+
async function runViaHost(text, peer, taskId) {
|
|
437
|
+
const port = await discoverHost()
|
|
438
|
+
if (!port) return null
|
|
439
|
+
try {
|
|
440
|
+
const body = JSON.stringify({ text, cwd: peer.cwd || CWD, sessionId: peer.sessionId || '' })
|
|
441
|
+
const r = await fetch('http://127.0.0.1:' + port + '/wxbridge/task', {
|
|
442
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body,
|
|
443
|
+
signal: AbortSignal.timeout(TASK_TIMEOUT_MS),
|
|
444
|
+
})
|
|
445
|
+
const j = await r.json()
|
|
446
|
+
if (!j || j.ok !== true) return { ok: false, text: '', error: 'host task: ' + String((j && j.error) || 'unknown') }
|
|
447
|
+
// 宿主路径可用时才采用:宿主返回空文本(=那一轮没真正执行)则回落 headless,避免用户干等超时
|
|
448
|
+
const hostText = String(j.text || '').trim()
|
|
449
|
+
if (!hostText) {
|
|
450
|
+
log('host-task-empty', { session: j.sessionId, reason: j.reason || null, fallback: 'headless' })
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
if (j.sessionId && j.sessionId !== peer.sessionId) {
|
|
454
|
+
peer.sessionId = j.sessionId
|
|
455
|
+
saveState(state)
|
|
456
|
+
}
|
|
457
|
+
log('task-done-host', { id: taskId, session: j.sessionId, created: !!j.created, outLen: (j.text || '').length })
|
|
458
|
+
return { ok: true, text: String(j.text || '').trim() || '(宿主已执行,回复为空)', sessionId: j.sessionId, viaHost: true }
|
|
459
|
+
} catch (e) {
|
|
460
|
+
log('host-task-failed', { error: String(e?.message ?? e) })
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 每个 peer 一条 DSH 原生会话:有 id 就 resume,没有就新建。
|
|
467
|
+
* 会话 id 存在 state.peers[from].acpSession —— 桥重启后继续用同一条(上下文不丢)。
|
|
468
|
+
*/
|
|
469
|
+
/**
|
|
470
|
+
* 读宿主 settings.yaml 的 agent-default-model(桌面端默认模型/强度),
|
|
471
|
+
* 作为新会话的初始选择 —— 手机看到的「当前模型」与桌面端一致。
|
|
472
|
+
* YAML 只做定点解析(不引依赖);解析不到就返回 null。
|
|
473
|
+
*/
|
|
474
|
+
function readHostDefaultModel() {
|
|
475
|
+
try {
|
|
476
|
+
const t = readFileSync(join(ACP_HOME, 'settings.yaml'), 'utf8')
|
|
477
|
+
const seg = t.split(/^agent-default-model:/m)[1]
|
|
478
|
+
if (!seg) return null
|
|
479
|
+
const block = seg.split(/^[a-z][a-z0-9-]*:/m)[0]
|
|
480
|
+
const provider = (block.match(/provider:\s*([^\s#]+)/) || [])[1]
|
|
481
|
+
const model = (block.match(/model:\s*([^\s#]+)/) || [])[1]
|
|
482
|
+
const effort = (block.match(/reasoningEffort:\s*([^\s#]+)/) || [])[1]
|
|
483
|
+
if (!provider || !model) return null
|
|
484
|
+
return { value: JSON.stringify([provider, model]), effort: effort || '' }
|
|
485
|
+
} catch { return null }
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 模式 → 叠加层。默认「对话模式」:收窄注入面(无技能目录 + 对话人格),
|
|
489
|
+
* 避免手机随口一句话被当成第二大脑任务落盘;`/brain on` 切到第二大脑模式。
|
|
490
|
+
*/
|
|
491
|
+
/**
|
|
492
|
+
* 通道档位(= 预设档):'chat' 是插件自带的「普通对话」;其余值是 DSH 名册里的预设 id。
|
|
493
|
+
* 默认档:config.json 的 acp.preset → 宿主 settings 的 agent-presets.default → 'chat'。
|
|
494
|
+
*/
|
|
495
|
+
const ACP_DEFAULT_PRESET = ACP_CFG.preset || readHostDefaultPreset() || 'chat'
|
|
496
|
+
const acpHosts = new Map()
|
|
497
|
+
const overlayCache = new Map()
|
|
498
|
+
/** 兼容旧字段:peer.acpMode('brain'/'chat')仍认,新字段 peer.acpPreset 优先。 */
|
|
499
|
+
function presetKey(peer) {
|
|
500
|
+
const k = String(peer.acpPreset || '').trim()
|
|
501
|
+
if (k) return k
|
|
502
|
+
if (peer.acpMode === 'brain') return ACP_DEFAULT_PRESET
|
|
503
|
+
if (peer.acpMode === 'chat') return 'chat'
|
|
504
|
+
return ACP_DEFAULT_PRESET
|
|
505
|
+
}
|
|
506
|
+
function overlayFor(key) {
|
|
507
|
+
if (key === 'chat') return join(HERE, 'acp-overlay-chat.yml')
|
|
508
|
+
if (!overlayCache.has(key)) overlayCache.set(key, ensurePresetOverlay(key) || '')
|
|
509
|
+
return overlayCache.get(key) || ACP_PATCH
|
|
510
|
+
}
|
|
511
|
+
function peerMode(peer) { return presetKey(peer) }
|
|
512
|
+
/** ACP 会话配置目录(session/new 返回,供 /model、/effort 列选用)。 */
|
|
513
|
+
let acpModelCatalog = []
|
|
514
|
+
let acpEffortCatalog = []
|
|
515
|
+
function flattenConfigOptions(opts) {
|
|
516
|
+
for (const o of opts || []) {
|
|
517
|
+
if (o.id === 'model') {
|
|
518
|
+
const list = []
|
|
519
|
+
for (const g of o.options || []) for (const it of g.options || []) list.push({ group: g.name || g.group || '', label: it.name || it.value, value: it.value })
|
|
520
|
+
if (list.length) acpModelCatalog = list
|
|
521
|
+
}
|
|
522
|
+
if (o.id === 'reasoning_effort') {
|
|
523
|
+
const list = []
|
|
524
|
+
for (const it of o.options || []) list.push({ label: it.name || it.value, value: it.value })
|
|
525
|
+
if (list.length) acpEffortCatalog = list
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function getAcpHost(key) {
|
|
530
|
+
const k = String(key || ACP_DEFAULT_PRESET).trim() || ACP_DEFAULT_PRESET
|
|
531
|
+
if (!acpHosts.has(k)) acpHosts.set(k, new AcpHost({
|
|
532
|
+
bin: resolveAcpBin(), home: ACP_HOME, cwd: CWD, patch: overlayFor(k),
|
|
533
|
+
permPolicy: ACP_PERM, promptTimeoutMs: TASK_TIMEOUT_MS, log,
|
|
534
|
+
}))
|
|
535
|
+
return acpHosts.get(k)
|
|
536
|
+
}
|
|
537
|
+
/** 预设名册:用户预设($ACP_HOME/.agent-presets)优先,其次运行时的出厂树与包内预设。 */
|
|
538
|
+
function presetRoots() {
|
|
539
|
+
const roots = [join(ACP_HOME, '.agent-presets')]
|
|
540
|
+
try {
|
|
541
|
+
const rt = dirname(dirname(resolveAcpBin()))
|
|
542
|
+
roots.push(join(rt, 'config', 'agent-presets'))
|
|
543
|
+
roots.push(join(rt, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets'))
|
|
544
|
+
} catch {}
|
|
545
|
+
return roots
|
|
546
|
+
}
|
|
547
|
+
/** 各根的可读性诊断(排障用;也进自检输出)。 */
|
|
548
|
+
function presetRootsDiag() {
|
|
549
|
+
return presetRoots().map((r) => { let n = -1; try { n = readdirSync(r).length } catch {} return { path: r, entries: n } })
|
|
550
|
+
}
|
|
551
|
+
function listPresets() {
|
|
552
|
+
const roots = presetRoots()
|
|
553
|
+
const seen = new Set(); const out = []
|
|
554
|
+
for (const root of roots) {
|
|
555
|
+
let ids = []
|
|
556
|
+
try { ids = readdirSync(root) } catch { continue }
|
|
557
|
+
for (const id of ids) {
|
|
558
|
+
if (String(id).startsWith('.') || /\.bak/.test(id) || seen.has(id)) continue
|
|
559
|
+
const dir = join(root, id)
|
|
560
|
+
if (!existsSync(join(dir, 'agent.cordis.yml'))) continue
|
|
561
|
+
let meta = ''
|
|
562
|
+
try { meta = readFileSync(join(dir, 'preset.yml'), 'utf8') } catch { continue }
|
|
563
|
+
const pick = (k) => { const m = meta.match(new RegExp('^' + k + ':[ \t]*(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : '' }
|
|
564
|
+
seen.add(id)
|
|
565
|
+
out.push({ id, name: pick('name') || id, description: pick('description'), order: Number(pick('order') || 999),
|
|
566
|
+
src: root === roots[0] ? 'user' : 'shipped' })
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// 用户预设排前面(同 order 时),再按 order 与 id 排
|
|
570
|
+
return out.sort((a, b) => (a.src === b.src ? 0 : (a.src === 'user' ? -1 : 1)) || (a.order - b.order) || String(a.id).localeCompare(String(b.id)))
|
|
571
|
+
}
|
|
572
|
+
/** peer × 模式 的会话槽;升级兼容:旧 peer.acpSession 归入第二大脑模式槽。 */
|
|
573
|
+
function sessionSlot(peer, mode) {
|
|
574
|
+
if (!peer.acpSessions) {
|
|
575
|
+
peer.acpSessions = {}
|
|
576
|
+
if (peer.acpSession) peer.acpSessions.brain = { id: peer.acpSession, cwd: peer.acpCwd || CWD }
|
|
577
|
+
}
|
|
578
|
+
if (!peer.acpSessions[mode]) peer.acpSessions[mode] = { id: '', cwd: '' }
|
|
579
|
+
return peer.acpSessions[mode]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 逐帧解 DSH 会话文件(多帧 zstd;Node 公开 API 只解第一帧,必须逐帧)。 */
|
|
583
|
+
function readSessionEvents(file) {
|
|
584
|
+
let raw
|
|
585
|
+
try { raw = readFileSync(file) } catch { return null }
|
|
586
|
+
const offs = []
|
|
587
|
+
for (let i = 0; i + 4 <= raw.length; i++) {
|
|
588
|
+
if (raw[i] === 0x28 && raw[i + 1] === 0xb5 && raw[i + 2] === 0x2f && raw[i + 3] === 0xfd) offs.push(i)
|
|
589
|
+
}
|
|
590
|
+
let text = ''
|
|
591
|
+
for (const off of offs) { try { text += zstdDecompressSync(raw.subarray(off)).toString('utf8') } catch {} }
|
|
592
|
+
const out = []
|
|
593
|
+
for (const line of text.split(NL)) {
|
|
594
|
+
const t = line.trim(); if (!t) continue
|
|
595
|
+
try { out.push(JSON.parse(t)) } catch {}
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
/** 找会话文件:只需在 $DSH_HOME/sessions/<工作区槽>/<sessionId>/ 一层里找,不依赖槽名规则。 */
|
|
600
|
+
function findSessionFile(sessionId) {
|
|
601
|
+
const root = join(ACP_HOME, 'sessions')
|
|
602
|
+
let dirs = []
|
|
603
|
+
try { dirs = readdirSync(root) } catch { return '' }
|
|
604
|
+
for (const d of dirs) {
|
|
605
|
+
const f = join(root, d, sessionId, 'session.jsonl.zstd')
|
|
606
|
+
if (existsSync(f)) return f
|
|
607
|
+
}
|
|
608
|
+
return ''
|
|
609
|
+
}
|
|
610
|
+
/** 关键标记:能据此判断会话到底按哪一档组装的。 */
|
|
611
|
+
const PRESET_MARKS = [
|
|
612
|
+
['DSH.md', '契约手册'],
|
|
613
|
+
['唯一记忆真源', '记忆契约'],
|
|
614
|
+
['四查', '四查检索'],
|
|
615
|
+
['落盘仪式', '落盘仪式'],
|
|
616
|
+
['手机微信通道', '插件对话人格'],
|
|
617
|
+
]
|
|
618
|
+
/**
|
|
619
|
+
* 预设自检:读该档当前会话的日志,回报「system 长度 + 命中标记 + 会话头 agentPreset + 所用叠层」。
|
|
620
|
+
* 没有 request/header(还没跑过一轮)时如实说明。
|
|
621
|
+
*/
|
|
622
|
+
async function presetSelfCheck(peer) {
|
|
623
|
+
const key = presetKey(peer)
|
|
624
|
+
const sid = await ensurePeerSession(peer)
|
|
625
|
+
const file = findSessionFile(sid)
|
|
626
|
+
const evs = file ? readSessionEvents(file) : null
|
|
627
|
+
const head = evs ? evs.find((e) => e.type === 'session') : null
|
|
628
|
+
const req = evs ? evs.find((e) => e.type === 'request/header') : null
|
|
629
|
+
const sys = String((req && req.data && req.data.header && req.data.header.system) || '')
|
|
630
|
+
return {
|
|
631
|
+
preset: key, default: ACP_DEFAULT_PRESET, session: sid,
|
|
632
|
+
ran: !!req, systemChars: sys.length,
|
|
633
|
+
marks: sys ? PRESET_MARKS.filter(([m]) => sys.includes(m)).map(([, label]) => label) : [],
|
|
634
|
+
agentPreset: (head && head.agentPreset) || null,
|
|
635
|
+
patch: basename(overlayFor(key) || '') || null,
|
|
636
|
+
acpPid: getAcpHost(key).pid || 0,
|
|
637
|
+
file: file || '',
|
|
638
|
+
acpHome: ACP_HOME,
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 改桥对 ACP 审批请求的应答策略(allow / reject):作用于**已在跑的** ACP 子进程,
|
|
644
|
+
* 并落进 config.json 的 acp.permPolicy(重启后仍生效)。
|
|
645
|
+
* 注:通道权限预设已是 danger-full-access,通常不会产生审批;这个开关用于"临时收紧/放开"。
|
|
646
|
+
*/
|
|
647
|
+
function setPermPolicy(want) {
|
|
648
|
+
ACP_PERM = want === 'reject' ? 'reject' : 'allow'
|
|
649
|
+
for (const [, h] of acpHosts) { try { h.permPolicy = ACP_PERM } catch {} }
|
|
650
|
+
try {
|
|
651
|
+
const f = process.env.WXBRIDGE_CONFIG || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge', 'config.json')
|
|
652
|
+
let keep = {}
|
|
653
|
+
try { keep = JSON.parse(readFileSync(f, 'utf8')) || {} } catch {}
|
|
654
|
+
keep.acp = Object.assign({}, (keep.acp && typeof keep.acp === 'object') ? keep.acp : {}, { permPolicy: ACP_PERM })
|
|
655
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
656
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
657
|
+
log('perm-policy-set', { value: ACP_PERM, file: f })
|
|
658
|
+
} catch (e) { log('perm-policy-write-failed', { error: String(e?.message ?? e) }) }
|
|
659
|
+
return ACP_PERM
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function ensurePeerSession(peer) {
|
|
663
|
+
const mode = peerMode(peer)
|
|
664
|
+
const host = getAcpHost(mode)
|
|
665
|
+
const slot = sessionSlot(peer, mode)
|
|
666
|
+
const cwd = peer.cwd || CWD
|
|
667
|
+
if (slot.id && slot.cwd === cwd) {
|
|
668
|
+
// 本进程已挂载 → 直接用。**不能 resume**:ACP 的 resume 只接「非激活」的持久会话,
|
|
669
|
+
// 对已激活的会报 "session is already active"(2026-09-21 实测:导致每问一句都新建会话、看起来没上下文)。
|
|
670
|
+
if (host.has(slot.id)) {
|
|
671
|
+
await applyPeerConfig(peer, slot.id)
|
|
672
|
+
return slot.id
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
await host.resumeSession(slot.id, cwd)
|
|
676
|
+
await applyPeerConfig(peer, slot.id)
|
|
677
|
+
return slot.id
|
|
678
|
+
} catch (e) {
|
|
679
|
+
const msg = String(e?.message ?? e)
|
|
680
|
+
log('acp-resume-failed', { mode, session: slot.id, error: msg })
|
|
681
|
+
if (/already active/i.test(msg)) peer.acpNotice = '⚠️ 这条会话正被其它端占用(桌面端开着?),本轮另起了一条新会话继续。'
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const r = await host.newSession(cwd)
|
|
685
|
+
slot.id = r?.sessionId || ''
|
|
686
|
+
slot.cwd = cwd
|
|
687
|
+
flattenConfigOptions(r?.configOptions)
|
|
688
|
+
if (Array.isArray(r?.configOptions)) peer.acpConfig = r.configOptions.map((o) => o.id).join(',')
|
|
689
|
+
await applyPeerConfig(peer, slot.id)
|
|
690
|
+
await saveState(state)
|
|
691
|
+
log('acp-session-ensured', { mode, session: slot.id })
|
|
692
|
+
return slot.id
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* 把 peer 选的模型/推理强度施加到当前会话。
|
|
697
|
+
* **必须每次 new/resume 后都做**:实测 resume 后会话会掉回 acp profile 的默认模型
|
|
698
|
+
* (配置不随会话日志持久化),所以不能只在“选的时候”设一次。
|
|
699
|
+
*/
|
|
700
|
+
/** 读宿主 settings.yaml 的 agent-presets.default(用户默认预设);解析不到返回 ''。 */
|
|
701
|
+
function readHostDefaultPreset() {
|
|
702
|
+
try {
|
|
703
|
+
const t = readFileSync(join(ACP_HOME, 'settings.yaml'), 'utf8')
|
|
704
|
+
const seg = t.split(/^agent-presets:/m)[1]
|
|
705
|
+
if (!seg) return ''
|
|
706
|
+
const block = seg.split(/^[a-z][a-z0-9-]*:/m)[0]
|
|
707
|
+
return (block.match(/default:\s*([^\s#]+)/) || [])[1] || ''
|
|
708
|
+
} catch { return '' }
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 生成「跟随预设」档位用的叠加层:挂预设插件 + 把选定的预设钉给本进程的 ACP 会话。
|
|
712
|
+
* 预设 id 来自 config.acp.preset 或宿主默认(不写死),文件写在数据目录,可人工查看/修改。
|
|
713
|
+
*/
|
|
714
|
+
function ensurePresetOverlay(presetId) {
|
|
715
|
+
if (!presetId) return ''
|
|
716
|
+
// 上游兼容层:已装的 dsh-acp 还不支持 Config.preset 时,幂等打一个小补丁
|
|
717
|
+
//(应用升级会覆盖它,所以每次启动都校验一次;config.acp.patchAcp=false 可关闭)
|
|
718
|
+
const bin = resolveAcpBin()
|
|
719
|
+
if (ACP_CFG.patchAcp !== false) {
|
|
720
|
+
try {
|
|
721
|
+
const r = ensureAcpPresetSupport({ runtimeBin: bin, log })
|
|
722
|
+
log('acp-preset-shim', { status: r.status, detail: r.detail || null, backup: r.backup ? basename(r.backup) : null })
|
|
723
|
+
} catch (e) { log('acp-preset-shim-failed', { error: String(e?.message ?? e) }) }
|
|
724
|
+
}
|
|
725
|
+
// 护栏:仍不支持 → 退回宿主组合(不写 preset/预设插件,避免未知配置项把整条通道带崩)
|
|
726
|
+
if (!acpPresetSupported(acpPackageFile(bin))) {
|
|
727
|
+
log('acp-preset-unsupported', { note: 'dsh-acp 无 Config.preset(上游未支持 / 形状不符 / 补丁被冲掉)→ 退回宿主组合' })
|
|
728
|
+
return ''
|
|
729
|
+
}
|
|
730
|
+
const f = join(STATE_DIR, 'acp-preset-' + String(presetId).replace(/[^A-Za-z0-9_-]/g, '_') + '.yml')
|
|
731
|
+
const roots = join(ACP_HOME, '.agent-presets').split(String.fromCharCode(92)).join('/')
|
|
732
|
+
const yml = [
|
|
733
|
+
'# 由 wxbridge 生成:ACP 会话加入用户预设(config.acp.preset 或宿主 settings 的 agent-presets.default)',
|
|
734
|
+
'# 依赖 dsh-acp 支持 Config.preset(上游未支持时该行会被忽略,行为退回宿主组合)。',
|
|
735
|
+
'- id: dsh-computer-use',
|
|
736
|
+
' disabled: true',
|
|
737
|
+
'# 手机通道不需要审批(手机无法应答):权限预设放到最宽',
|
|
738
|
+
'- id: permission',
|
|
739
|
+
' config:',
|
|
740
|
+
' defaultPreset: danger-full-access',
|
|
741
|
+
'- id: acp',
|
|
742
|
+
' config:',
|
|
743
|
+
' provider: ' + (ACP_CFG.provider || 'deepseek-official'),
|
|
744
|
+
' model: ' + (ACP_CFG.model || 'deepseek-v4-flash'),
|
|
745
|
+
' preset: ' + presetId,
|
|
746
|
+
'- insert:',
|
|
747
|
+
' - id: agent-presets',
|
|
748
|
+
" name: '@deepseek-ai/dsh-agent-presets'",
|
|
749
|
+
' config:',
|
|
750
|
+
' default: ' + presetId,
|
|
751
|
+
' roots:',
|
|
752
|
+
' - path: ' + roots,
|
|
753
|
+
].join(NL) + NL
|
|
754
|
+
try { writeFileSync(f, yml); return f } catch (e) {
|
|
755
|
+
log('preset-overlay-write-failed', { error: String(e?.message ?? e) })
|
|
756
|
+
return ''
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function applyPeerConfig(peer, sessionId) {
|
|
761
|
+
const sid = sessionId || sessionSlot(peer, peerMode(peer)).id
|
|
762
|
+
if (!sid) return
|
|
763
|
+
// 还没自己选过 → 跟随宿主默认(桌面端 settings.yaml 的 agent-default-model)
|
|
764
|
+
if (!peer.acpModel) {
|
|
765
|
+
const d = readHostDefaultModel()
|
|
766
|
+
if (d) { peer.acpModel = d.value; if (!peer.acpEffort && d.effort) peer.acpEffort = d.effort }
|
|
767
|
+
}
|
|
768
|
+
const host = getAcpHost(peerMode(peer))
|
|
769
|
+
try {
|
|
770
|
+
if (peer.acpModel) await host.setConfigOption(sid, 'model', peer.acpModel)
|
|
771
|
+
if (peer.acpEffort) await host.setConfigOption(sid, 'reasoning_effort', peer.acpEffort)
|
|
772
|
+
} catch (e) { log('acp-config-apply-failed', { error: String(e?.message ?? e) }) }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 一轮任务走 ACP:把用户原话交给 DSH 会话(**不拼接历史**),上下文由 DSH 维护。
|
|
777
|
+
* 失败返回 null,由调用方回落宿主/headless 路径。
|
|
778
|
+
*/
|
|
779
|
+
async function runViaAcp(text, peer, taskId) {
|
|
780
|
+
if (!ACP_ENABLED) return null
|
|
781
|
+
try {
|
|
782
|
+
const sessionId = await ensurePeerSession(peer)
|
|
783
|
+
if (!sessionId) return null
|
|
784
|
+
const rec = state.tasks[taskId]
|
|
785
|
+
if (rec) { rec.session = sessionId; rec.mode = 'acp' }
|
|
786
|
+
const r = await getAcpHost(peerMode(peer)).ask(sessionId, text, (u) => {
|
|
787
|
+
if (u.sessionUpdate === 'tool_call') {
|
|
788
|
+
log('acp-tool', { id: taskId, title: u.title, status: u.status })
|
|
789
|
+
const rec = state.tasks[taskId]
|
|
790
|
+
if (rec) { rec.lastTool = String(u.title || 'tool'); rec.lastToolAt = Date.now() } // /task 能看到"最近在干什么"
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
log('task-done-acp', { id: taskId, session: sessionId, outLen: (r.text || '').length, tools: r.toolCalls, stop: r.stopReason })
|
|
794
|
+
const notice = peer.acpNotice || ''
|
|
795
|
+
if (notice) peer.acpNotice = ''
|
|
796
|
+
return { ok: true, text: r.text, sessionId, viaAcp: true, stopReason: r.stopReason, notice }
|
|
797
|
+
} catch (e) {
|
|
798
|
+
log('task-acp-failed', { id: taskId, error: String(e?.message ?? e) })
|
|
799
|
+
return null
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function killTree(pid) { try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch {} }
|
|
804
|
+
function runDsh(task, cwd, taskId) {
|
|
805
|
+
return new Promise((resolve) => {
|
|
806
|
+
const taskLog = join(TASKS_DIR, Date.now() + '-' + taskId + '.log')
|
|
807
|
+
const argv = DSH_BIN ? [DSH_BIN, '--profile', PROFILE, task] : ['--profile', PROFILE, task]
|
|
808
|
+
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' })
|
|
810
|
+
const childPid = child.pid
|
|
811
|
+
const rec = state.tasks[taskId]
|
|
812
|
+
if (rec) { rec.pid = childPid; saveState(state) }
|
|
813
|
+
let out = '', err = '', timedOut = false
|
|
814
|
+
try { appendFileSync(taskLog, '=== ' + new Date().toISOString() + ' task=' + taskId + ' cwd=' + cwd + NL) } catch {}
|
|
815
|
+
const beatTimer = setInterval(() => beat('busy'), 15000)
|
|
816
|
+
const t = setTimeout(() => { timedOut = true; killTree(childPid); resolve({ ok: false, text: out.trim(), error: 'timeout ' + TASK_TIMEOUT_MS + 'ms', log: taskLog, timedOut: true }) }, TASK_TIMEOUT_MS)
|
|
817
|
+
child.stdout.on('data', (d) => { out += d.toString(); try { appendFileSync(taskLog, d) } catch {} })
|
|
818
|
+
child.stderr.on('data', (d) => { err += d.toString(); try { appendFileSync(taskLog, d) } catch {} })
|
|
819
|
+
child.on('close', (code) => {
|
|
820
|
+
clearTimeout(t); clearInterval(beatTimer)
|
|
821
|
+
if (timedOut) return
|
|
822
|
+
try { appendFileSync(taskLog, NL + '=== exit=' + code + NL) } catch {}
|
|
823
|
+
if (code === 0 && out.trim()) resolve({ ok: true, text: out.trim(), log: taskLog })
|
|
824
|
+
else resolve({ ok: false, text: out.trim(), error: 'exit=' + code + ' ' + err.trim().slice(-800), log: taskLog })
|
|
825
|
+
})
|
|
826
|
+
child.on('error', (e) => { clearTimeout(t); clearInterval(beatTimer); resolve({ ok: false, text: '', error: e.message, log: taskLog }) })
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function reply(token, to, text, ctx) {
|
|
831
|
+
const body = String(text ?? '').trim() || '(空回复)'
|
|
832
|
+
for (let i = 0; i < body.length; i += MAX_CHUNK) {
|
|
833
|
+
const c = body.slice(i, i + MAX_CHUNK)
|
|
834
|
+
try { const r = await sendText(token, to, c, ctx); log('sent', { len: c.length, ret: r?.ret }) } catch (e) { log('send-failed', { error: String(e?.message ?? e) }) }
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const HELP = [
|
|
839
|
+
'手机 ↔ 本机 DSH。发任意文本=给电脑派活;下面是指令:',
|
|
840
|
+
'',
|
|
841
|
+
'【看状态】',
|
|
842
|
+
'/status — 桥状态+当前档位+原生会话 id+模型。出问题先发这个。',
|
|
843
|
+
'/task — 任务列表+进度:已跑多久、最近在用什么工具、卡在哪一步。',
|
|
844
|
+
'/ping — 探活(只回 pong)。',
|
|
845
|
+
'',
|
|
846
|
+
'【管任务】',
|
|
847
|
+
'/cancel — 取消正在跑的(ACP 轮次走协议取消;一次性执行连子进程树一起杀)。',
|
|
848
|
+
'',
|
|
849
|
+
'【会话/上下文】',
|
|
850
|
+
'/new — 开新会话(当前档位,下一条从零开始)。',
|
|
851
|
+
'/sessions — 列本工作区的 DSH 会话;/sessions 3 接上第 3 条(可接桌面端开着的)。',
|
|
852
|
+
'',
|
|
853
|
+
'【档位/模型】',
|
|
854
|
+
'/预设 — 列可选预设(含自带「普通对话」);/预设 2 切换;每档一条独立会话。',
|
|
855
|
+
'/预设 自检 — 验当前档是否真按该预设组装:回报 system 字数、命中标记、会话头 agentPreset。',
|
|
856
|
+
'/model — 列模型;/model 2 切换(按会话记忆)。',
|
|
857
|
+
'/effort — 推理强度:/effort off|low|high|max。',
|
|
858
|
+
'',
|
|
859
|
+
'【工作区】',
|
|
860
|
+
'/ws — 列工作区;/ws 2 或 /ws D:\\proj 切换(换工作区=换会话)。',
|
|
861
|
+
'',
|
|
862
|
+
'【权限】',
|
|
863
|
+
'/权限 — 看通道权限(默认 danger-full-access:不需要审批)与桥的应答策略。',
|
|
864
|
+
'/approve — 把桥对审批的应答策略设为「自动批准」。',
|
|
865
|
+
'/reject — 设为「一律拒绝」(临时收紧用;需要审批的操作会失败)。',
|
|
866
|
+
'/help — 本帮助。',
|
|
867
|
+
].join(NL)
|
|
868
|
+
|
|
869
|
+
const peerChains = new Map()
|
|
870
|
+
const runningCount = () => Object.values(state.tasks).filter((t) => t.status === 'running').length
|
|
871
|
+
|
|
872
|
+
async function processTask(token, peerKey, from, taskId, text, contextToken) {
|
|
873
|
+
const peer = state.peers[peerKey]
|
|
874
|
+
const t = state.tasks[taskId]
|
|
875
|
+
t.status = 'running'; t.startedAt = new Date().toISOString(); activeTasks = runningCount()
|
|
876
|
+
await saveState(state)
|
|
877
|
+
const history = (peer.history || []).filter((h) => h.ok !== false).slice(-6)
|
|
878
|
+
const prompt = PROMPT_PREFIX + sanitize(history.length
|
|
879
|
+
? '以下是本会话此前的对话(供参考): ' + history.map((h, i) => '[' + (i + 1) + '] 用户: ' + h.u + ' ;你: ' + String(h.a).slice(0, 300)).join(' 。 ') + ' 。 本轮用户任务: ' + text
|
|
880
|
+
: text)
|
|
881
|
+
let r = null
|
|
882
|
+
// ① 首选 ACP:DSH 原生会话 + 原生上下文(桥不拼接历史)
|
|
883
|
+
r = await runViaAcp(text, peer, taskId)
|
|
884
|
+
// ② 其次宿主内执行(历史行为;不产生 turn 时返回空 → 继续回落)
|
|
885
|
+
if (!r) { const viaHost = await runViaHost(text, peer, taskId); if (viaHost && String(viaHost.text || '').trim()) r = viaHost }
|
|
886
|
+
// ③ 最后回落:一次性 headless(沿用拼接提示词,仅作兜底)
|
|
887
|
+
if (!r) r = await runDsh(prompt, peer.cwd, taskId)
|
|
888
|
+
t.status = r.timedOut ? 'timeout' : (r.ok ? 'done' : 'failed')
|
|
889
|
+
t.endedAt = new Date().toISOString(); t.log = r.log; activeTasks = runningCount()
|
|
890
|
+
peer.history = ((peer.history || []).concat([{ u: text, a: r.ok ? r.text : (r.error || '失败'), ok: !!r.ok, at: t.endedAt }])).slice(-50)
|
|
891
|
+
log('task-done', { id: taskId, ok: r.ok, timeout: !!r.timedOut, outLen: r.text?.length ?? 0 })
|
|
892
|
+
const audited = auditOutput((r.notice ? r.notice + NL : '') + (r.ok ? r.text : '执行失败:' + (r.error ?? '未知错误')))
|
|
893
|
+
if (audited.hits.length) log('output-audit', { id: taskId, hits: audited.hits })
|
|
894
|
+
await reply(token, from, audited.text, contextToken)
|
|
895
|
+
await saveState(state)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function enqueueTask(token, peerKey, from, text, contextToken) {
|
|
899
|
+
const taskId = randomUUID().slice(0, 8)
|
|
900
|
+
state.tasks[taskId] = { id: taskId, peer: peerKey, text: text.slice(0, 200), status: 'queued', queuedAt: new Date().toISOString(), cwd: state.peers[peerKey].cwd }
|
|
901
|
+
log('task-queued', { id: taskId })
|
|
902
|
+
const prev = peerChains.get(peerKey) || Promise.resolve()
|
|
903
|
+
const cur = prev
|
|
904
|
+
.then(async () => { while (runningCount() >= MAX_CONCURRENT_TASKS) await new Promise((r) => setTimeout(r, 1000)); await processTask(token, peerKey, from, taskId, text, contextToken) })
|
|
905
|
+
.catch((e) => log('task-error', { id: taskId, error: String(e?.message ?? e) }))
|
|
906
|
+
.finally(() => { if (peerChains.get(peerKey) === cur) peerChains.delete(peerKey) })
|
|
907
|
+
peerChains.set(peerKey, cur)
|
|
908
|
+
return taskId
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let state = loadState()
|
|
912
|
+
let lastSelfSave = 0
|
|
913
|
+
/**
|
|
914
|
+
* 外部(宿主半的扫码登记、面板动作)会改同一份 state.json;桥只在启动时读一次的话,
|
|
915
|
+
* 「扫码登记了新主人」要等到下次重启才生效 —— 这里在每轮轮询时比较 mtime,
|
|
916
|
+
* 只把「外部拥有的字段」合并进来,绝不用旧快照覆盖桥自己的游标与去重表。
|
|
917
|
+
*/
|
|
918
|
+
/**
|
|
919
|
+
* 重新读微信 token 并重置游标。场景:用户换微信重新扫码 → 宿主把新 token 写进凭据文件,
|
|
920
|
+
* 运行中的桥若不察觉,就会继续用旧 bot 身份轮询(表现为"扫码成功但收不到消息",2026-09-21 实锤)。
|
|
921
|
+
* 由调用方比对 token 是否真的变了,再重置 syncBuf(新 bot 的消息序列不能沿用旧游标)。
|
|
922
|
+
*/
|
|
923
|
+
function rereadWechatToken() {
|
|
924
|
+
if (TOKEN_FILE === '@env') return null
|
|
925
|
+
for (const home of [HOST_HOME, DSH_HOME, CHILD_HOME]) {
|
|
926
|
+
if (!home) continue
|
|
927
|
+
const f = join(home, '.credentials.yaml')
|
|
928
|
+
try {
|
|
929
|
+
const m = readFileSync(f, 'utf8').match(/^\s*DSH_WEIXIN_BOT_TOKEN:\s*(.+)$/m)
|
|
930
|
+
if (!m) continue
|
|
931
|
+
const t = m[1].trim().replace(/^["']|["']$/g, '')
|
|
932
|
+
TOKEN_FILE = f
|
|
933
|
+
return t
|
|
934
|
+
} catch {}
|
|
935
|
+
}
|
|
936
|
+
return null
|
|
937
|
+
}
|
|
938
|
+
function tokenFileMtime() {
|
|
939
|
+
try { return TOKEN_FILE && TOKEN_FILE !== '@env' ? statSync(TOKEN_FILE).mtimeMs : 0 } catch { return 0 }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function reloadStateIfChanged() {
|
|
943
|
+
try {
|
|
944
|
+
const st = statSync(STATE_FILE)
|
|
945
|
+
if (st.mtimeMs <= lastSelfSave) return false
|
|
946
|
+
const disk = JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
947
|
+
const before = JSON.stringify([state.allowedUsers, Object.keys(state.peers), state.account])
|
|
948
|
+
state.allowedUsers = Array.isArray(disk.allowedUsers) ? disk.allowedUsers : state.allowedUsers
|
|
949
|
+
if (disk.peers && typeof disk.peers === 'object') {
|
|
950
|
+
for (const [k, v] of Object.entries(disk.peers)) {
|
|
951
|
+
if (!state.peers[k]) state.peers[k] = v
|
|
952
|
+
else if (!(state.peers[k].history || []).length && (v.history || []).length) state.peers[k].history = v.history
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (disk.account) state.account = disk.account
|
|
956
|
+
if (disk.manualStop !== undefined) state.manualStop = disk.manualStop
|
|
957
|
+
lastSelfSave = Date.now()
|
|
958
|
+
const after = JSON.stringify([state.allowedUsers, Object.keys(state.peers), state.account])
|
|
959
|
+
if (after !== before) {
|
|
960
|
+
log('state-reloaded-from-disk', { allowedUsers: state.allowedUsers.length, peers: Object.keys(state.peers).length })
|
|
961
|
+
return true
|
|
962
|
+
}
|
|
963
|
+
return false
|
|
964
|
+
} catch { return false }
|
|
965
|
+
}
|
|
966
|
+
// 顺序很关键:先准备子 home(写入合并后的密钥),再读微信 token ——
|
|
967
|
+
// 否则隔离模式下 loadWechatToken 会读不到刚生成的子 home 凭据。
|
|
968
|
+
const refsInfo = prepareChildHome()
|
|
969
|
+
// 首次使用还没有微信凭据是**正常状态**,不是错误:桥照常起来,进 wait-credentials 等配对,
|
|
970
|
+
// 配对写入凭据后由轮询循环自动接手(2026-09-21:其他用户点「启动」时这里直接抛错 = "启动不了")。
|
|
971
|
+
let wechatToken = ''
|
|
972
|
+
try {
|
|
973
|
+
wechatToken = loadWechatToken()
|
|
974
|
+
} catch (e) {
|
|
975
|
+
console.log('[bridge] 还没有微信凭据(等待配对)')
|
|
976
|
+
console.log('[bridge] 请在「设置 → 微信连接」里扫码配对;配对完成后桥会自动接手,无需重启。')
|
|
977
|
+
log('no-wechat-credentials', { note: '等待配对写入凭据', tried: String(e?.message ?? e).slice(0, 200) })
|
|
978
|
+
}
|
|
979
|
+
TOKEN_MTIME = tokenFileMtime()
|
|
980
|
+
|
|
981
|
+
async function handleMessage(token, msg) {
|
|
982
|
+
const from = String(msg?.from_user_id ?? '')
|
|
983
|
+
const text = inboundText(msg)
|
|
984
|
+
const ctx = msg?.context_token
|
|
985
|
+
if (!from || !text) return
|
|
986
|
+
const trimmed = text.trim()
|
|
987
|
+
const firstToken = trimmed.split(/\s+/)[0] || ''
|
|
988
|
+
const authed = firstToken === AUTH_TOKEN
|
|
989
|
+
let isAllowed = state.allowedUsers.includes(from)
|
|
990
|
+
|
|
991
|
+
if (!isAllowed && !authed) {
|
|
992
|
+
if (ALLOWLIST === 'open') {
|
|
993
|
+
isAllowed = true
|
|
994
|
+
} else if (ALLOWLIST === 'auto') {
|
|
995
|
+
// 自动登记:能发消息给本 bot 的号本来就在它的会话里,准入边界在扫码那一步
|
|
996
|
+
state.allowedUsers.push(from)
|
|
997
|
+
state.peers[from] ||= { cwd: CWD, history: [], approvals: [] }
|
|
998
|
+
await saveState(state)
|
|
999
|
+
log('auto-enrolled', { from, total: state.allowedUsers.length })
|
|
1000
|
+
isAllowed = true
|
|
1001
|
+
try { await reply(token, from, '✅ 已登记本机授权(多台手机/换号都无需再设置)。发 /help 看指令。', ctx) } catch {}
|
|
1002
|
+
} else {
|
|
1003
|
+
// strict:记录完整 from(本地日志,仅本人可读)
|
|
1004
|
+
log('rejected-unauthenticated', { from, fromShort: from.slice(0, 12) })
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (!isAllowed && authed) {
|
|
1009
|
+
state.allowedUsers.push(from); await saveState(state); log('owner-claimed', { from: from.slice(0, 12) })
|
|
1010
|
+
await reply(token, from, '✅ 已登记为所有者(以后无需再带 token)。发 /help 查看指令。', ctx)
|
|
1011
|
+
}
|
|
1012
|
+
const body = authed ? trimmed.slice(firstToken.length).trim() : trimmed
|
|
1013
|
+
if (!body) { await reply(token, from, '已认证。发送 /help 查看指令。', ctx); return }
|
|
1014
|
+
const peerKey = from
|
|
1015
|
+
const peer = (state.peers[peerKey] ||= { cwd: CWD, history: [], approvals: [] })
|
|
1016
|
+
const cmd = body.toLowerCase()
|
|
1017
|
+
|
|
1018
|
+
if (cmd === '/ping') return reply(token, from, 'pong', ctx)
|
|
1019
|
+
if (cmd === '/help') return reply(token, from, HELP, ctx)
|
|
1020
|
+
if (cmd === '/status') return reply(token, from, ['桥状态 v' + BRIDGE_VERSION, 'profile=' + PROFILE, '工作区=' + peer.cwd, '会话轮数=' + (peer.history || []).length, '契约=' + CONTRACT_HASH, '运行中=' + runningCount() + '/' + MAX_CONCURRENT_TASKS, '已处理消息=' + state.processed.length, '允许用户=' + state.allowedUsers.length,
|
|
1021
|
+
'执行=' + (ACP_ENABLED ? 'ACP 原生会话' : 'headless 一次性') + '|DSH_HOME=' + ACP_HOME,
|
|
1022
|
+
'预设档=' + presetKey(peer) + '|默认档=' + ACP_DEFAULT_PRESET,
|
|
1023
|
+
'原生会话=' + (sessionSlot(peer, peerMode(peer)).id ? String(sessionSlot(peer, peerMode(peer)).id).slice(0, 12) + '…' : '(未建立)')
|
|
1024
|
+
+ '|acp pid=' + (getAcpHost(peerMode(peer)).pid || 0),
|
|
1025
|
+
'模型=' + (peer.acpModel || '(profile 默认)') + '|推理强度=' + (peer.acpEffort || '(默认)')].join(NL), ctx)
|
|
1026
|
+
if (cmd === '/task') {
|
|
1027
|
+
const list = Object.values(state.tasks).slice(-8)
|
|
1028
|
+
if (!list.length) return reply(token, from, '当前无任务。发任意文本即可派活。', ctx)
|
|
1029
|
+
const ago = (t) => {
|
|
1030
|
+
if (!t) return ''
|
|
1031
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000))
|
|
1032
|
+
return s < 60 ? s + 's' : (s < 3600 ? Math.round(s / 60) + 'm' + (s % 60) + 's' : Math.round(s / 3600) + 'h')
|
|
1033
|
+
}
|
|
1034
|
+
const rows = list.map((t) => {
|
|
1035
|
+
const el = t.startedAt ? '|已跑 ' + ago(Date.parse(t.startedAt)) : ''
|
|
1036
|
+
const last = t.lastTool ? '|最近:' + t.lastTool + (t.lastToolAt ? '(' + ago(t.lastToolAt) + '前)' : '') : ''
|
|
1037
|
+
return t.id + ' [' + t.status + (t.mode === 'acp' ? '·acp' : '') + ']' + el + last + NL + ' ' + String(t.text || '').slice(0, 60)
|
|
1038
|
+
})
|
|
1039
|
+
const running = list.filter((t) => t.status === 'running').length
|
|
1040
|
+
return reply(token, from, ['任务 ' + list.length + ' 条' + (running ? '(' + running + ' 条在跑)' : '') + ':'].concat(rows)
|
|
1041
|
+
.concat(['取消运行中的:/cancel']).join(NL), ctx)
|
|
1042
|
+
}
|
|
1043
|
+
if (cmd === '/cancel') {
|
|
1044
|
+
const running = Object.values(state.tasks).filter((t) => t.status === 'running')
|
|
1045
|
+
let acpCancelled = 0
|
|
1046
|
+
for (const t of running) {
|
|
1047
|
+
if (t.mode === 'acp' && t.session) {
|
|
1048
|
+
try {
|
|
1049
|
+
let done = false
|
|
1050
|
+
for (const [, h] of acpHosts) { if (h.has(t.session)) { await h.cancel(t.session); done = true; break } }
|
|
1051
|
+
if (!done) await getAcpHost(t.mode).cancel(t.session)
|
|
1052
|
+
acpCancelled++
|
|
1053
|
+
} catch (e) { log('acp-cancel-failed', { error: String(e?.message ?? e) }) }
|
|
1054
|
+
} else if (t.pid) killTree(t.pid)
|
|
1055
|
+
t.status = 'cancelled'; t.endedAt = new Date().toISOString()
|
|
1056
|
+
}
|
|
1057
|
+
await saveState(state)
|
|
1058
|
+
return reply(token, from, running.length
|
|
1059
|
+
? '已请求取消 ' + running.length + ' 个运行中任务' + (acpCancelled ? '(其中 ' + acpCancelled + ' 个走 DSH 原生会话:已发 session/cancel)' : '(连同子进程树)') + '。'
|
|
1060
|
+
: '当前没有运行中任务。', ctx)
|
|
1061
|
+
}
|
|
1062
|
+
if (cmd === '/new') {
|
|
1063
|
+
const slot = sessionSlot(peer, peerMode(peer))
|
|
1064
|
+
slot.id = ''; slot.cwd = ''
|
|
1065
|
+
await saveState(state)
|
|
1066
|
+
return reply(token, from, '已开启新会话(' + (peerMode(peer) === 'brain' ? '第二大脑' : '对话') + '模式,下一条消息由 DSH 新建)。', ctx)
|
|
1067
|
+
}
|
|
1068
|
+
if (cmd === '/权限' || cmd === '/perm') {
|
|
1069
|
+
return reply(token, from, [
|
|
1070
|
+
'通道权限:完全访问(danger-full-access)——手机端没有审批界面,所以通道不需要审批',
|
|
1071
|
+
'桥对 ACP 审批请求的应答策略:' + ACP_PERM + '(config.json 的 acp.permPolicy 可改)',
|
|
1072
|
+
'要收紧:改 config.json 的 acp.patch 叠层里 permission.defaultPreset 为 workspace-write 或 read-only',
|
|
1073
|
+
].join(NL), ctx)
|
|
1074
|
+
}
|
|
1075
|
+
if (cmd === '/预设' || cmd === '/preset' || cmd.startsWith('/预设 ') || cmd.startsWith('/preset ')
|
|
1076
|
+
|| cmd === '/brain' || cmd.startsWith('/brain ') || cmd === '/模式' || cmd.startsWith('/模式 ')) {
|
|
1077
|
+
if (!ACP_ENABLED) return reply(token, from, 'ACP 路径已关闭(config.json 的 acp.enabled=false)。', ctx)
|
|
1078
|
+
const argRaw = body.trim().replace(/^\/(预设|preset|brain|模式)\s*/, '').trim()
|
|
1079
|
+
const cur = presetKey(peer)
|
|
1080
|
+
const roster = listPresets()
|
|
1081
|
+
const entries = [{ id: 'chat', name: '普通对话(插件自带:只聊天,不动文件/库)' }].concat(roster)
|
|
1082
|
+
if (!argRaw) {
|
|
1083
|
+
const rows = entries.map((e, i) => (i + 1) + '. ' + e.name + '(' + e.id + ')' + (e.id === cur ? ' ← 当前' : ''))
|
|
1084
|
+
return reply(token, from, ['通道预设(/预设 <序号|id> 切换):'].concat(rows)
|
|
1085
|
+
.concat(['默认档:' + ACP_DEFAULT_PRESET + '|每个预设一条独立会话(切档不串上下文)']).join(NL), ctx)
|
|
1086
|
+
}
|
|
1087
|
+
if (argRaw === 'check' || argRaw === '自检' || argRaw === '验证') {
|
|
1088
|
+
try {
|
|
1089
|
+
const r = await presetSelfCheck(peer)
|
|
1090
|
+
return reply(token, from, [
|
|
1091
|
+
'预设自检 · 档位 ' + r.preset + (r.preset === r.default ? '(默认档)' : ''),
|
|
1092
|
+
'会话 ' + String(r.session).slice(0, 12) + '…|acp pid=' + r.acpPid,
|
|
1093
|
+
'叠层 ' + (r.patch || '(宿主组合 —— 未走预设,等价于宿主自己的组合)'),
|
|
1094
|
+
r.file
|
|
1095
|
+
? (r.ran
|
|
1096
|
+
? ('system ' + r.systemChars + ' 字|命中标记:' + (r.marks.length ? r.marks.join('、') : '无(=宿主默认组合)'))
|
|
1097
|
+
: '该会话还没跑过一轮 —— 发一条任务后再自检,就能看到 system 字数与命中标记')
|
|
1098
|
+
: ('找不到该会话的日志文件(ACP home=' + r.acpHome + ')—— 通常说明这一档还没建过会话,随便发一条任务即可'),
|
|
1099
|
+
r.agentPreset ? ('会话头 agentPreset=' + r.agentPreset) : '会话头未记录预设(=宿主组合)',
|
|
1100
|
+
].join(NL), ctx)
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
return reply(token, from, '预设自检失败:' + String(e?.message ?? e), ctx)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const want = (/^(on|off)$/i.test(argRaw)) ? (argRaw.toLowerCase() === 'off' ? 'chat' : ACP_DEFAULT_PRESET) : argRaw
|
|
1106
|
+
const n = Number(want)
|
|
1107
|
+
const pick = (Number.isFinite(n) && n >= 1) ? entries[n - 1] : entries.find((e) => e.id === want)
|
|
1108
|
+
if (!pick) return reply(token, from, '没这个预设(发 /预设 看清单;序号或 id 都行)。', ctx)
|
|
1109
|
+
peer.acpPreset = pick.id
|
|
1110
|
+
await saveState(state)
|
|
1111
|
+
log('preset-switched', { id: pick.id })
|
|
1112
|
+
return reply(token, from, '已切到预设:' + pick.name + '(' + pick.id + ')' + NL
|
|
1113
|
+
+ '下一条消息在该预设自己的会话里继续;上下文按预设分开(/sessions 可找回别的会话)。', ctx)
|
|
1114
|
+
}
|
|
1115
|
+
if (cmd === '/model' || cmd.startsWith('/model ')) {
|
|
1116
|
+
if (!ACP_ENABLED) return reply(token, from, 'ACP 路径已关闭(config.json 的 acp.enabled=false)。', ctx)
|
|
1117
|
+
try { await ensurePeerSession(peer) } catch (e) { return reply(token, from, 'ACP 会话不可用:' + String(e?.message ?? e), ctx) }
|
|
1118
|
+
const pickArg = body.trim().slice(6).trim()
|
|
1119
|
+
if (!pickArg) {
|
|
1120
|
+
if (!acpModelCatalog.length) return reply(token, from, '模型目录暂时不可用(先发一条任务建会话)。', ctx)
|
|
1121
|
+
const cur = peer.acpModel || ''
|
|
1122
|
+
const label = (m) => { try { const [pv, md] = JSON.parse(m.value); return pv + '/' + md } catch { return m.value } }
|
|
1123
|
+
const rows = acpModelCatalog.map((m, i) => (i + 1) + '. ' + m.label + '(' + m.group + '|' + label(m) + ')' + (m.value === cur ? ' ← 当前' : ''))
|
|
1124
|
+
return reply(token, from, ['可选模型(/model <序号> 或 /model provider/model 切换):'].concat(rows).join(NL), ctx)
|
|
1125
|
+
}
|
|
1126
|
+
const n = Number(pickArg)
|
|
1127
|
+
const byText = (m) => {
|
|
1128
|
+
try { const [pv, md] = JSON.parse(m.value); if (pv + '/' + md === pickArg || md === pickArg) return true } catch {}
|
|
1129
|
+
return m.value === pickArg || m.label === pickArg
|
|
1130
|
+
}
|
|
1131
|
+
const pick = Number.isFinite(n) && n >= 1 ? acpModelCatalog[n - 1] : acpModelCatalog.find(byText)
|
|
1132
|
+
if (!pick) return reply(token, from, '序号/名称不在目录里(发 /model 看清单)。', ctx)
|
|
1133
|
+
peer.acpModel = pick.value
|
|
1134
|
+
await applyPeerConfig(peer)
|
|
1135
|
+
await saveState(state)
|
|
1136
|
+
return reply(token, from, '模型已切换:' + pick.label + '(' + pick.group + ')' + NL + '下一条消息起生效,本会话与手机/桌面共用。', ctx)
|
|
1137
|
+
}
|
|
1138
|
+
if (cmd === '/effort' || cmd.startsWith('/effort ')) {
|
|
1139
|
+
if (!ACP_ENABLED) return reply(token, from, 'ACP 路径已关闭。', ctx)
|
|
1140
|
+
try { await ensurePeerSession(peer) } catch (e) { return reply(token, from, 'ACP 会话不可用:' + String(e?.message ?? e), ctx) }
|
|
1141
|
+
const pickArg = body.trim().slice(7).trim()
|
|
1142
|
+
if (!pickArg) {
|
|
1143
|
+
const cur = peer.acpEffort || ''
|
|
1144
|
+
const rows = (acpEffortCatalog.length ? acpEffortCatalog : [{ label: 'off', value: 'off' }, { label: 'low', value: 'low' }, { label: 'high', value: 'high' }, { label: 'max', value: 'max' }])
|
|
1145
|
+
.map((e, i) => (i + 1) + '. ' + e.label + (e.value === cur ? ' ← 当前' : ''))
|
|
1146
|
+
return reply(token, from, ['推理强度(/effort <序号|值> 切换):'].concat(rows).join(NL), ctx)
|
|
1147
|
+
}
|
|
1148
|
+
const n = Number(pickArg)
|
|
1149
|
+
const list = acpEffortCatalog.length ? acpEffortCatalog : [{ label: 'off', value: 'off' }, { label: 'low', value: 'low' }, { label: 'high', value: 'high' }, { label: 'max', value: 'max' }]
|
|
1150
|
+
const pick = Number.isFinite(n) && n >= 1 ? list[n - 1] : list.find((e) => e.value === pickArg)
|
|
1151
|
+
if (!pick) return reply(token, from, '没这个强度(发 /effort 看清单)。', ctx)
|
|
1152
|
+
peer.acpEffort = pick.value
|
|
1153
|
+
await applyPeerConfig(peer)
|
|
1154
|
+
await saveState(state)
|
|
1155
|
+
return reply(token, from, '推理强度已切换:' + pick.label, ctx)
|
|
1156
|
+
}
|
|
1157
|
+
if (cmd === '/sessions' || cmd.startsWith('/sessions ')) {
|
|
1158
|
+
if (!ACP_ENABLED) return reply(token, from, 'ACP 路径已关闭(config.json 的 acp.enabled=false)。', ctx)
|
|
1159
|
+
const pickArg = body.trim().slice(9).trim()
|
|
1160
|
+
const host = getAcpHost(peerMode(peer))
|
|
1161
|
+
let list = null
|
|
1162
|
+
try { list = await host.listSessions(peer.cwd || CWD) } catch (e) { return reply(token, from, '取会话列表失败:' + String(e?.message ?? e), ctx) }
|
|
1163
|
+
if (!pickArg) {
|
|
1164
|
+
const rows = (list || []).slice(0, 10).map((s, i) => (i + 1) + '. ' + String(s.sessionId).slice(0, 12) + '…' + (s.title ? ' ' + String(s.title).slice(0, 18) : ''))
|
|
1165
|
+
return reply(token, from, ['工作区 ' + (peer.cwd || CWD) + ' 的会话(新→旧):'].concat(rows.length ? rows : ['(无)'])
|
|
1166
|
+
.concat(['当前会话:' + (sessionSlot(peer, peerMode(peer)).id ? String(sessionSlot(peer, peerMode(peer)).id).slice(0, 12) + '…' : '(未建立)'), '接上某条:/sessions <序号>']).join(NL), ctx)
|
|
1167
|
+
}
|
|
1168
|
+
const n = Number(pickArg)
|
|
1169
|
+
const pick = Number.isFinite(n) && n >= 1 ? (list || [])[n - 1] : null
|
|
1170
|
+
if (!pick) return reply(token, from, '序号超出范围。', ctx)
|
|
1171
|
+
const slot = sessionSlot(peer, peerMode(peer))
|
|
1172
|
+
slot.id = pick.sessionId; slot.cwd = peer.cwd || CWD
|
|
1173
|
+
await saveState(state)
|
|
1174
|
+
return reply(token, from, '已接上 DSH 会话 ' + String(pick.sessionId).slice(0, 12) + '…(下一条消息在该会话继续,上下文由 DSH 保存)', ctx)
|
|
1175
|
+
}
|
|
1176
|
+
if (cmd === '/ws' || cmd.startsWith('/ws ')) {
|
|
1177
|
+
const target = body.trim().slice(3).trim().replace(/^"|"$/g, '')
|
|
1178
|
+
if (!target) return reply(token, from, workspaceListText(peer), ctx)
|
|
1179
|
+
if (/^[0-9]+$/.test(target)) {
|
|
1180
|
+
const pick = readWorkspaces()[Number(target) - 1]
|
|
1181
|
+
if (!pick) return reply(token, from, '序号超出范围。' + NL + workspaceListText(peer), ctx)
|
|
1182
|
+
peer.cwd = pick.path; sessionSlot(peer, peerMode(peer)).id = ''; sessionSlot(peer, peerMode(peer)).cwd = ''; await saveState(state)
|
|
1183
|
+
return reply(token, from, '工作区已切换为:' + NL + pick.path + NL + '(' + pick.title + ')' + NL + '(已切新会话,原会话可用 /sessions 找回)', ctx)
|
|
1184
|
+
}
|
|
1185
|
+
if (!existsSync(target)) return reply(token, from, '路径不存在:' + target, ctx)
|
|
1186
|
+
peer.cwd = target; sessionSlot(peer, peerMode(peer)).id = ''; sessionSlot(peer, peerMode(peer)).cwd = ''; await saveState(state)
|
|
1187
|
+
return reply(token, from, '工作区已切换为:' + NL + target + NL + '(已切新会话,原会话可用 /sessions 找回)', ctx)
|
|
1188
|
+
}
|
|
1189
|
+
if (cmd === '/approve' || cmd === '/reject') {
|
|
1190
|
+
peer.approvals.push({ at: new Date().toISOString(), decision: cmd === '/approve' ? 'approved' : 'rejected' })
|
|
1191
|
+
const v = setPermPolicy(cmd === '/approve' ? 'allow' : 'reject')
|
|
1192
|
+
await saveState(state)
|
|
1193
|
+
return reply(token, from, [
|
|
1194
|
+
'桥对审批请求的应答策略已设为:' + (v === 'allow' ? '自动批准' : '一律拒绝'),
|
|
1195
|
+
v === 'allow'
|
|
1196
|
+
? '通道权限预设是 danger-full-access(通常不产生审批);这一项是"临时收紧后再放开"用的。'
|
|
1197
|
+
: '⚠️ 现在起 ACP 的每一项审批都会被拒,需要审批的操作会失败。放开:发 /approve',
|
|
1198
|
+
].join(NL), ctx)
|
|
1199
|
+
}
|
|
1200
|
+
const taskId = enqueueTask(token, peerKey, from, body, ctx)
|
|
1201
|
+
return reply(token, from, '已收到(任务 ' + taskId + '),开始执行:' + body.slice(0, 50) + NL + '/task 查进度,/cancel 取消。', ctx)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function main() {
|
|
1205
|
+
process.on('exit', () => { for (const [, h] of acpHosts) { try { h.stop() } catch {} } })
|
|
1206
|
+
if (args.includes('--selftest-preset-check')) {
|
|
1207
|
+
const peer = { cwd: CWD }
|
|
1208
|
+
const sp = argOf('--selftest-preset-check', '')
|
|
1209
|
+
if (sp) peer.acpPreset = sp
|
|
1210
|
+
await runViaAcp('只回复:ok', peer, 'SELFCHECK')
|
|
1211
|
+
const r = await presetSelfCheck(peer)
|
|
1212
|
+
for (const [, h] of acpHosts) h.stop()
|
|
1213
|
+
console.log('[selftest-preset-check] ' + JSON.stringify(r))
|
|
1214
|
+
process.exit(0)
|
|
1215
|
+
}
|
|
1216
|
+
if (args.includes('--selftest-presets')) {
|
|
1217
|
+
console.log('[selftest-presets] ' + JSON.stringify({ default: ACP_DEFAULT_PRESET,
|
|
1218
|
+
bin: resolveAcpBin(), acpHome: ACP_HOME, roots: presetRootsDiag(),
|
|
1219
|
+
roster: listPresets().map((x) => x.id + ':' + x.name) }))
|
|
1220
|
+
process.exit(0)
|
|
1221
|
+
}
|
|
1222
|
+
if (args.includes('--selftest-acp')) {
|
|
1223
|
+
const peer = { cwd: CWD }
|
|
1224
|
+
const sp = argOf('--selftest-preset', '')
|
|
1225
|
+
if (sp) peer.acpPreset = sp
|
|
1226
|
+
else if (argOf('--selftest-mode', '') === 'brain') peer.acpPreset = ACP_DEFAULT_PRESET
|
|
1227
|
+
else if (argOf('--selftest-mode', '') === 'chat') peer.acpPreset = 'chat'
|
|
1228
|
+
if (argOf('--selftest-model', '')) peer.acpModel = argOf('--selftest-model', '')
|
|
1229
|
+
// 走生产路径:每轮任务都过 runViaAcp → ensurePeerSession
|
|
1230
|
+
const r1 = await runViaAcp('记住一个词:紫色独角兽。只回复:已记住', peer, 'SELFTEST1')
|
|
1231
|
+
const r2 = await runViaAcp('我刚才让你记的词是什么?只回复那个词。', peer, 'SELFTEST2')
|
|
1232
|
+
const r3 = await runViaAcp('再一次:我刚才让你记的是什么词?只回复那个词。', peer, 'SELFTEST3')
|
|
1233
|
+
for (const [, h] of acpHosts) h.stop()
|
|
1234
|
+
console.log('[selftest-acp] ' + JSON.stringify({
|
|
1235
|
+
bin: resolveAcpBin(), home: ACP_HOME, preset: presetKey(peer), patch: overlayFor(presetKey(peer)),
|
|
1236
|
+
session: sessionSlot(peer, peer.acpMode).id,
|
|
1237
|
+
turn1: r1?.text, turn2: r2?.text, turn3: r3?.text, notice: r2?.notice || null,
|
|
1238
|
+
}))
|
|
1239
|
+
process.exit(0)
|
|
1240
|
+
}
|
|
1241
|
+
if (args.includes('--selftest-resume')) {
|
|
1242
|
+
const id = argOf('--selftest-resume', '')
|
|
1243
|
+
const host = getAcpHost()
|
|
1244
|
+
await host.resumeSession(id, CWD)
|
|
1245
|
+
const r = await host.ask(id, '我刚才让你记的词是什么?只回复那个词。')
|
|
1246
|
+
host.stop()
|
|
1247
|
+
console.log('[selftest-resume] ' + JSON.stringify({ session: id, answer: r.text }))
|
|
1248
|
+
process.exit(0)
|
|
1249
|
+
}
|
|
1250
|
+
acquireLock()
|
|
1251
|
+
// 上次进程遗留的运行态任务:本轮一启动就标成 interrupted。
|
|
1252
|
+
// 否则 runningCount() 永远虚高,`while (runningCount() >= MAX_CONCURRENT_TASKS)` 会把新任务永久挡住。
|
|
1253
|
+
{
|
|
1254
|
+
let swept = 0
|
|
1255
|
+
for (const t of Object.values(state.tasks || {})) {
|
|
1256
|
+
if (t.status === 'running' || t.status === 'queued') { t.status = 'interrupted'; t.endedAt = new Date().toISOString(); swept++ }
|
|
1257
|
+
}
|
|
1258
|
+
if (swept) { await saveState(state); log('stale-tasks-swept', { count: swept }) }
|
|
1259
|
+
}
|
|
1260
|
+
// 所有者种子:若历史状态里已有单一 peer,视为已登记所有者(避免重复认证)
|
|
1261
|
+
if (!state.allowedUsers.length && Object.keys(state.peers).length === 1) {
|
|
1262
|
+
state.allowedUsers = Object.keys(state.peers)
|
|
1263
|
+
log('owner-seeded-from-state', { count: 1 })
|
|
1264
|
+
}
|
|
1265
|
+
const refs = refsInfo.refs
|
|
1266
|
+
log('bridge-start', { version: BRIDGE_VERSION, profile: PROFILE, cwd: CWD, contract: CONTRACT_HASH, childRefs: refs })
|
|
1267
|
+
console.log('[bridge] v' + BRIDGE_VERSION + ' 已启动|profile=' + PROFILE + '|默认工作区=' + CWD)
|
|
1268
|
+
console.log('[bridge] 契约=' + CONTRACT_HASH + '|子进程HOME=' + refsInfo.home + '(workspace-write)' + (refsInfo.shared ? ' ← 与宿主共享会话仓库' : ''))
|
|
1269
|
+
console.log('[bridge] 子进程凭据: ' + (refs.join(', ') || '(空)') + ' ← 不含微信 token')
|
|
1270
|
+
console.log('[bridge] 授权策略=' + ALLOWLIST + '|已登记=' + (state.allowedUsers.length ? state.allowedUsers.length + ' 人' : '无'))
|
|
1271
|
+
if (!state.allowedUsers.length) console.log('[bridge] ⚠️ 首次使用:微信发送 "' + AUTH_TOKEN + ' /help" 以登记所有者')
|
|
1272
|
+
console.log('[bridge] 等待微信消息…')
|
|
1273
|
+
|
|
1274
|
+
if (OWNER_PID) {
|
|
1275
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
1276
|
+
setInterval(() => {
|
|
1277
|
+
if (!alive(OWNER_PID)) { log('owner-exit: 宿主 pid=' + OWNER_PID + ' 已不在,桥自行退出'); process.exit(0) }
|
|
1278
|
+
}, 20000).unref()
|
|
1279
|
+
console.log('[bridge] 宿主托管模式:主人 pid=' + OWNER_PID + '(主人消失后自动退出)')
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
let timeoutMs = 35000
|
|
1283
|
+
beat('start')
|
|
1284
|
+
setInterval(() => beat(activeTasks > 0 ? 'busy' : 'idle'), HEARTBEAT_MS)
|
|
1285
|
+
|
|
1286
|
+
for (;;) {
|
|
1287
|
+
try {
|
|
1288
|
+
// ① token 热更新(换号扫码):变了就换 token 并重置游标
|
|
1289
|
+
const mt = tokenFileMtime()
|
|
1290
|
+
if (mt !== TOKEN_MTIME) {
|
|
1291
|
+
TOKEN_MTIME = mt
|
|
1292
|
+
const t2 = rereadWechatToken()
|
|
1293
|
+
if (t2 && t2 !== wechatToken) {
|
|
1294
|
+
wechatToken = t2
|
|
1295
|
+
state.syncBuf = ''
|
|
1296
|
+
log('token-hot-reloaded', { file: TOKEN_FILE, note: '换号扫码:已切新 token 并重置游标' })
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// ①-b 还没凭据(首次使用):每 5 秒试读一次,配对写入后自动接手,不刷错误日志
|
|
1300
|
+
if (!wechatToken) {
|
|
1301
|
+
try {
|
|
1302
|
+
const t = loadWechatToken()
|
|
1303
|
+
if (t) {
|
|
1304
|
+
wechatToken = t
|
|
1305
|
+
TOKEN_MTIME = tokenFileMtime()
|
|
1306
|
+
console.log('[bridge] 已读到微信凭据,开始轮询')
|
|
1307
|
+
log('token-loaded-late', { file: TOKEN_FILE })
|
|
1308
|
+
}
|
|
1309
|
+
} catch {}
|
|
1310
|
+
if (!wechatToken) { beat('wait-credentials'); await new Promise((r) => setTimeout(r, 5000)); continue }
|
|
1311
|
+
}
|
|
1312
|
+
// ② 面板改了预设提示词
|
|
1313
|
+
reloadPromptIfChanged()
|
|
1314
|
+
// ③ 外部改了 state(扫码登记新主人/面板操作)
|
|
1315
|
+
reloadStateIfChanged()
|
|
1316
|
+
const res = await getUpdates(wechatToken, state.syncBuf, timeoutMs)
|
|
1317
|
+
pollCount++
|
|
1318
|
+
if (Number.isFinite(res?.longpolling_timeout_ms) && res.longpolling_timeout_ms > 0) timeoutMs = Math.max(5000, Math.min(res.longpolling_timeout_ms, 120000))
|
|
1319
|
+
if (typeof res?.get_updates_buf === 'string' && res.get_updates_buf) state.syncBuf = res.get_updates_buf
|
|
1320
|
+
if (res?.account) state.account = res.account
|
|
1321
|
+
beat('poll')
|
|
1322
|
+
const now = Date.now()
|
|
1323
|
+
state.processed = state.processed.filter((k) => { const ts = Number(String(k).split(':')[1]); return !Number.isFinite(ts) || now - ts < DEDUP_WINDOW_MS })
|
|
1324
|
+
const seen = new Set(state.processed)
|
|
1325
|
+
for (const msg of res?.msgs ?? []) {
|
|
1326
|
+
const key = msgKey(msg)
|
|
1327
|
+
if (seen.has(key)) continue
|
|
1328
|
+
seen.add(key); state.processed.push(key)
|
|
1329
|
+
log('inbound', { text: inboundText(msg).slice(0, 80) })
|
|
1330
|
+
try { await handleMessage(wechatToken, msg) } catch (e) { log('handle-error', { error: String(e?.message ?? e) }) }
|
|
1331
|
+
}
|
|
1332
|
+
await saveState(state)
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
log('poll-error', { error: String(e?.message ?? e) })
|
|
1335
|
+
beat('poll-error')
|
|
1336
|
+
await new Promise((r) => setTimeout(r, 5000))
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
main().catch((e) => { console.error('[bridge] 启动失败:', e?.message ?? e); process.exit(1) })
|