@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,26 @@
|
|
|
1
|
+
# wxbridge ACP 叠加层 · **对话模式**(手机默认档)
|
|
2
|
+
# 与 acp-overlay.yml 的差别:收窄注入面,避免「随口一句话」被当成第二大脑任务落盘。
|
|
3
|
+
# 1) 仍停用 dsh-computer-use(否则 profile 整树加载失败,原因见 acp-overlay.yml);
|
|
4
|
+
# 2) 停用技能目录(skill-filesystem 提供 ~/.ohdsh/skills,tool-skill 提供加载工具)——
|
|
5
|
+
# 手机提示里不再出现 second-brain-distill 这类"沉淀知识"的技能;
|
|
6
|
+
# 3) 覆盖 system-prompt 人格为「对话助手」:从本会话上下文回答,不主动读写文件/知识库。
|
|
7
|
+
# 需要真正的第二大脑行为时,手机发 `/brain on` 切到第二大脑模式(另一份叠加层)。
|
|
8
|
+
- id: dsh-computer-use
|
|
9
|
+
disabled: true
|
|
10
|
+
|
|
11
|
+
# 手机通道**不需要审批**:手机端没有可应答审批的界面(ACP 只应答带 callId 的工具审批,
|
|
12
|
+
# 沙箱升级类审批会转给桌面端弹窗 → 手机无从应答 → fail-closed 执行失败)。
|
|
13
|
+
# 所以这里把权限预设放到最宽。改窄:改成 read-only / workspace-write 即可。
|
|
14
|
+
- id: permission
|
|
15
|
+
config:
|
|
16
|
+
defaultPreset: danger-full-access
|
|
17
|
+
- id: skill-filesystem
|
|
18
|
+
disabled: true
|
|
19
|
+
- id: tool-skill
|
|
20
|
+
disabled: true
|
|
21
|
+
- id: system-prompt
|
|
22
|
+
config:
|
|
23
|
+
persona: >-
|
|
24
|
+
你是「手机微信通道」里的对话助手,用中文简洁回答。直接用当前会话已有的上下文回答,
|
|
25
|
+
不要为了"记住"去读写工作区文件或知识库;只有当用户明确要求(例如"记到库里""归档""落盘")
|
|
26
|
+
时才动文件。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# wxbridge ACP 叠加层(保持最小:只做已实证必要的一件事)
|
|
2
|
+
# 让 `dsh --profile acp` 在**宿主 home**(~/.ohdsh)里能起得来:
|
|
3
|
+
# ~/.ohdsh/cordis.patch.yml(根用户补丁层)引用 dsh-computer-use,该包只装在
|
|
4
|
+
# profiles/desktop/node_modules 里 → 新建的 profiles/acp 解析不到 → 整树加载失败
|
|
5
|
+
# (症状:initialize 正常、session/new 报 "the ACP bridge has been disposed")。
|
|
6
|
+
# ACP 是手机用的自动化通道,不需要桌面专属的 computer-use 插件。
|
|
7
|
+
# 注:停用一个不存在的 id 是无害空操作,因此本文件可无条件带上。
|
|
8
|
+
- id: dsh-computer-use
|
|
9
|
+
disabled: true
|
|
10
|
+
|
|
11
|
+
# 手机通道**不需要审批**:手机端没有可应答审批的界面(ACP 只应答带 callId 的工具审批,
|
|
12
|
+
# 沙箱升级类审批会转给桌面端弹窗 → 手机无从应答 → fail-closed 执行失败)。
|
|
13
|
+
# 所以这里把权限预设放到最宽。改窄:改成 read-only / workspace-write 即可。
|
|
14
|
+
- id: permission
|
|
15
|
+
config:
|
|
16
|
+
defaultPreset: danger-full-access
|
|
17
|
+
#
|
|
18
|
+
# 备注(2026-09-21 实测,别重复踩):**模型目录不随行配置走**。
|
|
19
|
+
# 本文件曾试在两处放 llm-deepseek 目录(改行 config;停用 base 行 + 换行 id 插副本),
|
|
20
|
+
# 配置树(--dump-config)里都生效了,但 `session/new` 返回的目录**始终不变** ——
|
|
21
|
+
# 运行时的模型目录来自 settings(`llm-deepseek` / `llm-pi-ai` 段)而非行 config。
|
|
22
|
+
# 所以「让手机清单 = 桌面端清单」这件事,只能改 settings,改不成(见 L1 §9.6)。
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acp-preset-shim.mjs — 「让 ACP 会话加入预设」的**上游兼容层**(compat shim)
|
|
3
|
+
*
|
|
4
|
+
* 背景:`@deepseek-ai/dsh-acp` 的 `session/new` / `session/resume` 的 setup 里只装模型选择与 MCP,
|
|
5
|
+
* **没有** `agentPresets.mount()`,所以 ACP 会话永远拿不到用户预设(详见知识库笔记
|
|
6
|
+
* 「DSH ACP 原生会话通道」的「预设」一节)。上游落地前,由插件在启动时给**已装**的 dsh-acp
|
|
7
|
+
* 打一个**幂等**的小补丁(6 处),让 `Config.preset` 生效。
|
|
8
|
+
*
|
|
9
|
+
* 设计原则(产品化要求):
|
|
10
|
+
* 1. **幂等**:已支持 / 已打过 → 直接返回 'supported' / 'already',绝不重复写;
|
|
11
|
+
* 2. **只在该功能被用到时才动手**:调用方只在「用户选了预设」且未禁用(config.acp.patchAcp === false)时调用;
|
|
12
|
+
* 3. **形状不符就拒绝**:任何一处锚点对不上(上游改过 / 已原生支持)→ 'shape-mismatch',一个字节都不写;
|
|
13
|
+
* 4. **写前先字节级备份**,写后先过 `node --check` 才落盘(补丁坏了等于把用户的运行时搞坏);
|
|
14
|
+
* 5. **如实记录**:每个结果都返回 status + detail,由调用方写日志,不静默。
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
|
|
21
|
+
/** 已打过补丁的标记(写进文件注释,便于肉眼/程序双重识别)。 */
|
|
22
|
+
export const SHIM_MARK = 'wxbridge:acp-preset-shim'
|
|
23
|
+
|
|
24
|
+
/** 6 处补丁:每处都必须**恰好命中一次**,否则整体拒绝。 */
|
|
25
|
+
const EDITS = [
|
|
26
|
+
['config', `const Config = Schema.object({
|
|
27
|
+
provider: Schema.string(),
|
|
28
|
+
model: Schema.string(),
|
|
29
|
+
sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE)
|
|
30
|
+
});`, `const Config = Schema.object({
|
|
31
|
+
provider: Schema.string(),
|
|
32
|
+
model: Schema.string(),
|
|
33
|
+
preset: Schema.string(),
|
|
34
|
+
sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE)
|
|
35
|
+
});`],
|
|
36
|
+
|
|
37
|
+
['new-session', ` record = await AcpSession.create(ctx, {
|
|
38
|
+
sessionId,
|
|
39
|
+
cwd: params.cwd,
|
|
40
|
+
mcpServers: params.mcpServers,
|
|
41
|
+
agentOptions: agentOptions(config),
|
|
42
|
+
fallbackSelection: initialSelection(config),
|
|
43
|
+
signal,
|
|
44
|
+
notify
|
|
45
|
+
});`, ` record = await AcpSession.create(ctx, {
|
|
46
|
+
sessionId,
|
|
47
|
+
cwd: params.cwd,
|
|
48
|
+
mcpServers: params.mcpServers,
|
|
49
|
+
agentOptions: agentOptions(config),
|
|
50
|
+
preset: config.preset,
|
|
51
|
+
fallbackSelection: initialSelection(config),
|
|
52
|
+
signal,
|
|
53
|
+
notify
|
|
54
|
+
});`],
|
|
55
|
+
|
|
56
|
+
['resume-session', ` record = await AcpSession.resume(ctx, {
|
|
57
|
+
sessionId,
|
|
58
|
+
cwd: params.cwd,
|
|
59
|
+
mcpServers: params.mcpServers ?? [],
|
|
60
|
+
agentOptions: agentOptions(config),
|
|
61
|
+
fallbackSelection: initialSelection(config),
|
|
62
|
+
signal,
|
|
63
|
+
notify
|
|
64
|
+
});`, ` record = await AcpSession.resume(ctx, {
|
|
65
|
+
sessionId,
|
|
66
|
+
cwd: params.cwd,
|
|
67
|
+
mcpServers: params.mcpServers ?? [],
|
|
68
|
+
agentOptions: agentOptions(config),
|
|
69
|
+
preset: config.preset,
|
|
70
|
+
fallbackSelection: initialSelection(config),
|
|
71
|
+
signal,
|
|
72
|
+
notify
|
|
73
|
+
});`],
|
|
74
|
+
|
|
75
|
+
['create-setup', ` sessionId: options.sessionId,
|
|
76
|
+
meta: { cwd: options.cwd },
|
|
77
|
+
agentOptions: options.agentOptions,
|
|
78
|
+
signal: options.signal,
|
|
79
|
+
setup: async (agentCtx) => {
|
|
80
|
+
modelControl.install(agentCtx);
|
|
81
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);
|
|
82
|
+
}`, ` sessionId: options.sessionId,
|
|
83
|
+
meta: { cwd: options.cwd, agentPreset: options.preset },
|
|
84
|
+
agentOptions: options.agentOptions,
|
|
85
|
+
signal: options.signal,
|
|
86
|
+
setup: async (agentCtx) => {
|
|
87
|
+
modelControl.install(agentCtx);
|
|
88
|
+
const presets = agentCtx.get("agentPresets");
|
|
89
|
+
if (presets !== void 0 && options.preset !== void 0) await presets.mount(agentCtx, options.preset);
|
|
90
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);
|
|
91
|
+
}`],
|
|
92
|
+
|
|
93
|
+
['resume-setup', ` modelControl = new AcpModelControl(ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection));
|
|
94
|
+
modelControl.install(agentCtx);
|
|
95
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);`, ` modelControl = new AcpModelControl(ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection));
|
|
96
|
+
modelControl.install(agentCtx);
|
|
97
|
+
const presets = agentCtx.get("agentPresets");
|
|
98
|
+
if (presets !== void 0 && options.preset !== void 0) await presets.mount(agentCtx, options.preset);
|
|
99
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);`],
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
/** 由 dsh 入口(<runtime>/lib/bin.js)推出已装 dsh-acp 的文件路径。 */
|
|
103
|
+
export function acpPackageFile(runtimeBin) {
|
|
104
|
+
if (!runtimeBin) return ''
|
|
105
|
+
return resolve(join(dirname(runtimeBin), '..', 'node_modules', '@deepseek-ai', 'dsh-acp', 'lib', 'index.js'))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 该文件是否已经支持预设(原生支持或已打过补丁)。 */
|
|
109
|
+
export function acpPresetSupported(file) {
|
|
110
|
+
try {
|
|
111
|
+
const s = readFileSync(file, 'utf8')
|
|
112
|
+
return /preset:\s*Schema\.string\(\)/.test(s) && /agentPresets/.test(s)
|
|
113
|
+
} catch { return false }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 确保已装的 dsh-acp 支持 `Config.preset`。幂等、失败即弃、写前备份、写后语法检查。
|
|
118
|
+
* @param {{runtimeBin:string, log?:(m:string,e?:object)=>void, dryRun?:boolean}} o
|
|
119
|
+
* @returns {{status:'supported'|'already'|'patched'|'shape-mismatch'|'missing'|'syntax-failed'|'error', file:string, detail?:string, backup?:string}}
|
|
120
|
+
*/
|
|
121
|
+
export function ensureAcpPresetSupport(o) {
|
|
122
|
+
const log = o.log || (() => {})
|
|
123
|
+
const file = acpPackageFile(o.runtimeBin)
|
|
124
|
+
if (!file || !existsSync(file)) return { status: 'missing', file, detail: '未找到已装的 dsh-acp(可能不是桌面运行时)' }
|
|
125
|
+
|
|
126
|
+
let src
|
|
127
|
+
try { src = readFileSync(file, 'utf8') } catch (e) { return { status: 'error', file, detail: String(e?.message ?? e) } }
|
|
128
|
+
|
|
129
|
+
if (src.includes(SHIM_MARK)) return { status: 'already', file, detail: '已打过 wxbridge 预设补丁' }
|
|
130
|
+
if (/preset:\s*Schema\.string\(\)/.test(src)) return { status: 'supported', file, detail: '上游已支持 Config.preset,无需补丁' }
|
|
131
|
+
|
|
132
|
+
// 形状检查:所有锚点都必须恰好命中一次,否则整体拒绝
|
|
133
|
+
let out = src
|
|
134
|
+
for (const [name, old, next] of EDITS) {
|
|
135
|
+
const n = out.split(old).length - 1
|
|
136
|
+
if (n !== 1) return { status: 'shape-mismatch', file, detail: `锚点 ${name} 命中 ${n} 次(期望 1)——上游可能已改动,拒绝打补丁` }
|
|
137
|
+
out = out.replace(old, next)
|
|
138
|
+
}
|
|
139
|
+
out = `/* ${SHIM_MARK} · 由 wxbridge 插件在启动时写入;上游支持 Config.preset 后本补丁可移除 */\n` + out
|
|
140
|
+
|
|
141
|
+
if (o.dryRun) return { status: 'patched', file, detail: 'dryRun:未写盘' }
|
|
142
|
+
|
|
143
|
+
// 写后先过语法检查,再落盘(补丁坏了等于把用户的运行时搞坏)
|
|
144
|
+
try {
|
|
145
|
+
const tmp = join(tmpdir(), 'dsh-acp-check-' + process.pid + '.mjs')
|
|
146
|
+
writeFileSync(tmp, out)
|
|
147
|
+
const r = spawnSync(process.execPath, ['--check', tmp], { timeout: 30000, windowsHide: true })
|
|
148
|
+
if (r.status !== 0) return { status: 'syntax-failed', file, detail: String(r.stderr || '').slice(0, 300) || 'node --check 未通过' }
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { status: 'syntax-failed', file, detail: String(e?.message ?? e) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const backup = file + '.wxbridge-bak-' + new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
154
|
+
try {
|
|
155
|
+
copyFileSync(file, backup)
|
|
156
|
+
writeFileSync(file, out)
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { status: 'error', file, detail: '写入失败:' + String(e?.message ?? e) }
|
|
159
|
+
}
|
|
160
|
+
log('acp-shim-patched', { file, backup })
|
|
161
|
+
return { status: 'patched', file, backup, detail: '已打 6 处预设补丁(含备份)' }
|
|
162
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acp.mjs — DSH ACP 客户端(内核组件)
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:旧路径把「契约 + 历史 + 本轮」拼成一个提示词交给一次性 headless 子进程,
|
|
5
|
+
* 上下文由**桥**维护(拼接),因此每轮都是新会话、也没有会话可续。
|
|
6
|
+
* 新路径:由桥起一个常驻的 `dsh --profile acp` 进程,用 ACP(Agent Client Protocol v1)
|
|
7
|
+
* 驱动 **DSH 自己的会话**——会话由 DSH 持久化,上下文归 DSH,桥只负责传话。
|
|
8
|
+
*
|
|
9
|
+
* 协议要点(源自 @deepseek-ai/dsh-acp 的 README 与实现):
|
|
10
|
+
* initialize / session/new / session/list / session/resume / session/close /
|
|
11
|
+
* session/set_config_option / session/prompt / session/cancel(通知)
|
|
12
|
+
* 服务端→客户端:session/update(通知)、session/request_permission(请求,必须应答)
|
|
13
|
+
* v1 不再有 session/load(已声明不支持)。
|
|
14
|
+
*
|
|
15
|
+
* 传输:stdio 上的 NDJSON(一行一个 JSON-RPC)。stdout 只有协议流量,日志在 stderr。
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
|
|
19
|
+
const PROTOCOL_VERSION = 1
|
|
20
|
+
const NL = '\n'
|
|
21
|
+
|
|
22
|
+
export class AcpHost {
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} o
|
|
25
|
+
* @param {string} o.bin dsh 入口(bin.js 绝对路径)
|
|
26
|
+
* @param {string} [o.home] 子进程 DSH_HOME(决定会话写进哪个仓库)
|
|
27
|
+
* @param {string} [o.cwd] 子进程工作目录(默认进程 cwd)
|
|
28
|
+
* @param {string} [o.patch] --patch 叠加层文件(如停用宿主专属插件)
|
|
29
|
+
* @param {string} [o.permPolicy] 权限策略:'allow'(默认)| 'reject'
|
|
30
|
+
* @param {number} [o.promptTimeoutMs]
|
|
31
|
+
* @param {(m:string, e?:object)=>void} [o.log]
|
|
32
|
+
*/
|
|
33
|
+
constructor(o) {
|
|
34
|
+
this.bin = o.bin
|
|
35
|
+
this.home = o.home || ''
|
|
36
|
+
this.cwd = o.cwd || process.cwd()
|
|
37
|
+
this.patch = o.patch || ''
|
|
38
|
+
this.permPolicy = o.permPolicy === 'reject' ? 'reject' : 'allow'
|
|
39
|
+
this.promptTimeoutMs = Number(o.promptTimeoutMs || 300000)
|
|
40
|
+
this.log = o.log || (() => {})
|
|
41
|
+
this.child = null
|
|
42
|
+
this.ready = null
|
|
43
|
+
this.nextId = 1
|
|
44
|
+
this.pending = new Map()
|
|
45
|
+
this.inflight = new Map()
|
|
46
|
+
/** 本进程内已挂载(激活)的会话:这些不能 resume(resume 只接非激活的持久会话)。 */
|
|
47
|
+
this.sessions = new Set()
|
|
48
|
+
this.buf = ''
|
|
49
|
+
this.errTail = ''
|
|
50
|
+
this.notes = []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get pid() { return this.child?.pid ?? 0 }
|
|
54
|
+
/** 该会话是否已由本进程挂载(激活)。 */
|
|
55
|
+
has(sessionId) { return this.sessions.has(sessionId) }
|
|
56
|
+
get alive() { return !!this.child && this.child.exitCode === null }
|
|
57
|
+
|
|
58
|
+
/** 惰性启动 + 握手(并发调用共享同一个 promise)。 */
|
|
59
|
+
ensure() {
|
|
60
|
+
if (this.ready) return this.ready
|
|
61
|
+
this.ready = this._start().catch((e) => { this.ready = null; throw e })
|
|
62
|
+
return this.ready
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async _start() {
|
|
66
|
+
const args = [this.bin, '--profile', 'acp']
|
|
67
|
+
if (this.patch) args.push('--patch', this.patch)
|
|
68
|
+
const env = { ...process.env }
|
|
69
|
+
if (this.home) env.DSH_HOME = this.home
|
|
70
|
+
const child = spawn(process.execPath, args, { cwd: this.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env })
|
|
71
|
+
this.child = child
|
|
72
|
+
this.log('acp-spawn', { pid: child.pid, home: this.home || '(inherit)', patch: this.patch || null })
|
|
73
|
+
child.stdout.on('data', (b) => this._onData(b))
|
|
74
|
+
child.stderr.on('data', (b) => {
|
|
75
|
+
const s = String(b)
|
|
76
|
+
this.errTail = (this.errTail + s).slice(-2000)
|
|
77
|
+
this.log('acp-stderr', { s: s.slice(0, 300) })
|
|
78
|
+
})
|
|
79
|
+
child.on('exit', (code, signal) => {
|
|
80
|
+
this.log('acp-exit', { code, signal, stderr: this.errTail.slice(-400) })
|
|
81
|
+
this.ready = null
|
|
82
|
+
this.sessions.clear()
|
|
83
|
+
for (const [, p] of this.pending) p.reject(new Error('ACP 进程已退出(code=' + code + ')'))
|
|
84
|
+
this.pending.clear()
|
|
85
|
+
})
|
|
86
|
+
if (child.stdin.on) child.stdin.on('error', () => {})
|
|
87
|
+
const init = await this._request('initialize', {
|
|
88
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
89
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
90
|
+
})
|
|
91
|
+
this.notes.push(init?.agentInfo?.name || 'acp')
|
|
92
|
+
return init
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_onData(b) {
|
|
96
|
+
this.buf += String(b)
|
|
97
|
+
let i
|
|
98
|
+
while ((i = this.buf.indexOf(NL)) >= 0) {
|
|
99
|
+
const line = this.buf.slice(0, i).trim()
|
|
100
|
+
this.buf = this.buf.slice(i + 1)
|
|
101
|
+
if (!line) continue
|
|
102
|
+
let f
|
|
103
|
+
try { f = JSON.parse(line) } catch { continue }
|
|
104
|
+
this._onFrame(f)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_onFrame(f) {
|
|
109
|
+
if (f.id !== undefined && f.method === undefined) {
|
|
110
|
+
const p = this.pending.get(f.id)
|
|
111
|
+
if (!p) return
|
|
112
|
+
this.pending.delete(f.id)
|
|
113
|
+
if (f.error) p.reject(Object.assign(new Error(String(f.error.message || 'rpc error')), { rpc: f.error }))
|
|
114
|
+
else p.resolve(f.result)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (f.method === 'session/update') {
|
|
118
|
+
const sid = f.params?.sessionId
|
|
119
|
+
const u = f.params?.update ?? {}
|
|
120
|
+
const sink = this.inflight.get(sid)
|
|
121
|
+
if (sink) sink(u)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (f.method === 'session/request_permission') {
|
|
125
|
+
const opts = f.params?.options ?? []
|
|
126
|
+
const pick = this.permPolicy === 'allow'
|
|
127
|
+
? opts.find((o) => /allow/.test(String(o.kind))) || opts.find((o) => /allow/.test(String(o.optionId)))
|
|
128
|
+
: opts.find((o) => /reject/.test(String(o.kind))) || opts.find((o) => /reject/.test(String(o.optionId)))
|
|
129
|
+
this.log('acp-permission', { tool: f.params?.toolCall?.title, policy: this.permPolicy, picked: pick?.optionId ?? null })
|
|
130
|
+
this._raw({ jsonrpc: '2.0', id: f.id, result: pick ? { outcome: { outcome: 'selected', optionId: pick.optionId } } : { outcome: { outcome: 'cancelled' } } })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (f.id !== undefined && f.method) {
|
|
134
|
+
this._raw({ jsonrpc: '2.0', id: f.id, error: { code: -32601, message: 'unsupported by wxbridge client: ' + f.method } })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_raw(frame) {
|
|
139
|
+
try { this.child.stdin.write(JSON.stringify(frame) + NL) } catch (e) { this.log('acp-write-failed', { error: String(e?.message ?? e) }) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_request(method, params, timeoutMs) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const id = this.nextId++
|
|
145
|
+
const t = setTimeout(() => { this.pending.delete(id); reject(new Error('ACP 超时:' + method)) }, Number(timeoutMs || this.promptTimeoutMs))
|
|
146
|
+
this.pending.set(id, { resolve: (v) => { clearTimeout(t); resolve(v) }, reject: (e) => { clearTimeout(t); reject(e) } })
|
|
147
|
+
this._raw({ jsonrpc: '2.0', id, method, params })
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async newSession(cwd) {
|
|
152
|
+
await this.ensure()
|
|
153
|
+
const r = await this._request('session/new', { cwd: String(cwd || this.cwd), mcpServers: [] })
|
|
154
|
+
if (r?.sessionId) this.sessions.add(r.sessionId)
|
|
155
|
+
this.log('acp-session-new', { sessionId: r?.sessionId, cwd })
|
|
156
|
+
return r
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async resumeSession(sessionId, cwd) {
|
|
160
|
+
await this.ensure()
|
|
161
|
+
if (this.sessions.has(sessionId)) {
|
|
162
|
+
this.log('acp-session-already-mounted', { sessionId })
|
|
163
|
+
return { sessionId }
|
|
164
|
+
}
|
|
165
|
+
const r = await this._request('session/resume', { sessionId, cwd: String(cwd || this.cwd) })
|
|
166
|
+
this.sessions.add(sessionId)
|
|
167
|
+
this.log('acp-session-resume', { sessionId })
|
|
168
|
+
return r
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 列出可续接的持久会话(可选按 cwd 过滤)。 */
|
|
172
|
+
async listSessions(cwd) {
|
|
173
|
+
await this.ensure()
|
|
174
|
+
const r = await this._request('session/list', cwd ? { cwd: String(cwd) } : {})
|
|
175
|
+
return r?.sessions ?? []
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 建立/接续一条会话:先试 resume,失败则新建。 */
|
|
179
|
+
async attach(sessionId, cwd) {
|
|
180
|
+
if (sessionId) {
|
|
181
|
+
try { await this.resumeSession(sessionId, cwd); return { sessionId, resumed: true } } catch (e) {
|
|
182
|
+
this.log('acp-resume-failed', { sessionId, error: String(e?.message ?? e) })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const r = await this.newSession(cwd)
|
|
186
|
+
return { sessionId: r?.sessionId, resumed: false, configOptions: r?.configOptions ?? null }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 发一轮提示词,收集助手正文(思考块与工具生命周期走 onUpdate,便于回传进度)。
|
|
191
|
+
* @returns {Promise<{text:string, stopReason:string, toolCalls:number}>}
|
|
192
|
+
*/
|
|
193
|
+
async ask(sessionId, text, onUpdate) {
|
|
194
|
+
await this.ensure()
|
|
195
|
+
let out = ''
|
|
196
|
+
let tools = 0
|
|
197
|
+
const sink = (u) => {
|
|
198
|
+
if (u.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text') out += u.content.text
|
|
199
|
+
else if (u.sessionUpdate === 'tool_call') tools++
|
|
200
|
+
if (onUpdate) { try { onUpdate(u) } catch {} }
|
|
201
|
+
}
|
|
202
|
+
this.inflight.set(sessionId, sink)
|
|
203
|
+
try {
|
|
204
|
+
const r = await this._request('session/prompt', { sessionId, prompt: [{ type: 'text', text: String(text) }] })
|
|
205
|
+
return { text: out.trim(), stopReason: r?.stopReason ?? null, toolCalls: tools }
|
|
206
|
+
} finally {
|
|
207
|
+
this.inflight.delete(sessionId)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 改会话配置(model / reasoning_effort)。value 形如 '["provider","model"]' 或 'high'。 */
|
|
212
|
+
async setConfigOption(sessionId, configId, value) {
|
|
213
|
+
await this.ensure()
|
|
214
|
+
const r = await this._request('session/set_config_option', { sessionId, configId, value })
|
|
215
|
+
this.log('acp-set-config', { sessionId, configId, value })
|
|
216
|
+
return r
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async cancel(sessionId) {
|
|
220
|
+
this._raw({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async closeSession(sessionId) {
|
|
224
|
+
try { await this._request('session/close', { sessionId }); this.sessions.delete(sessionId) } catch (e) { this.log('acp-close-failed', { error: String(e?.message ?? e) }) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
stop() {
|
|
228
|
+
try { if (this.child && this.child.exitCode === null) this.child.kill() } catch {}
|
|
229
|
+
this.child = null
|
|
230
|
+
this.ready = null
|
|
231
|
+
}
|
|
232
|
+
}
|