@zmainer/dsh-wx-bridge 1.1.5 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +434 -421
- package/lib/kernel/acp.mjs +52 -6
- package/lib/kernel/bridge.mjs +110 -14
- package/package.json +52 -52
package/lib/kernel/acp.mjs
CHANGED
|
@@ -128,6 +128,11 @@ export class AcpHost {
|
|
|
128
128
|
const p = this.pending.get(f.id)
|
|
129
129
|
if (!p) return
|
|
130
130
|
this.pending.delete(f.id)
|
|
131
|
+
if (p.late) {
|
|
132
|
+
// 迟到帧:调用方早已按超时返回,但这一轮其实跑完了 → 交回给它补发结果
|
|
133
|
+
try { p.late(f.error ? null : f.result, f.error ? new Error(String(f.error.message || 'rpc error')) : null) } catch (e) { this.log('acp-late-handler-failed', { error: String(e?.message ?? e) }) }
|
|
134
|
+
return
|
|
135
|
+
}
|
|
131
136
|
if (f.error) p.reject(Object.assign(new Error(String(f.error.message || 'rpc error')), { rpc: f.error }))
|
|
132
137
|
else p.resolve(f.result)
|
|
133
138
|
return
|
|
@@ -181,10 +186,23 @@ export class AcpHost {
|
|
|
181
186
|
try { this.child.stdin.write(JSON.stringify(frame) + NL) } catch (e) { this.log('acp-write-failed', { error: String(e?.message ?? e) }) }
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
_request(method, params, timeoutMs) {
|
|
189
|
+
_request(method, params, timeoutMs, opts) {
|
|
190
|
+
const o = opts || {}
|
|
185
191
|
return new Promise((resolve, reject) => {
|
|
186
192
|
const id = this.nextId++
|
|
187
|
-
const t = setTimeout(() => {
|
|
193
|
+
const t = setTimeout(() => {
|
|
194
|
+
const err = Object.assign(new Error('ACP 超时:' + method), { code: 'ACP_TIMEOUT', method })
|
|
195
|
+
// 超时 ≠ 失败:`session/prompt` 的一方仍在跑,服务端最后一定会回这一帧。
|
|
196
|
+
// keepAliveOnTimeout 时**保留 pending 条目**,让它落地时走 onLate(避免"以为失败了"→ 兜底重跑一遍)。
|
|
197
|
+
if (o.keepAliveOnTimeout) {
|
|
198
|
+
const p = this.pending.get(id)
|
|
199
|
+
if (p) { p.late = o.onLate || null; p.timedOutAt = Date.now() }
|
|
200
|
+
this.log('acp-request-timeout-kept-alive', { id, method, timeoutMs: Number(timeoutMs || this.promptTimeoutMs) })
|
|
201
|
+
} else {
|
|
202
|
+
this.pending.delete(id)
|
|
203
|
+
}
|
|
204
|
+
reject(err)
|
|
205
|
+
}, Number(timeoutMs || this.promptTimeoutMs))
|
|
188
206
|
this.pending.set(id, { resolve: (v) => { clearTimeout(t); resolve(v) }, reject: (e) => { clearTimeout(t); reject(e) } })
|
|
189
207
|
this._raw({ jsonrpc: '2.0', id, method, params })
|
|
190
208
|
})
|
|
@@ -230,10 +248,16 @@ export class AcpHost {
|
|
|
230
248
|
|
|
231
249
|
/**
|
|
232
250
|
* 发一轮提示词,收集助手正文(思考块与工具生命周期走 onUpdate,便于回传进度)。
|
|
251
|
+
*
|
|
252
|
+
* @param {object} [opts]
|
|
253
|
+
* @param {number} [opts.timeoutMs] 本轮等待上限(默认 promptTimeoutMs)
|
|
254
|
+
* @param {boolean} [opts.keepAliveOnTimeout] 超时后**不要丢弃**这一轮:迟到帧交给 onLate
|
|
255
|
+
* @param {(r:{text:string,stopReason:string|null,toolCalls:number,error:Error|null})=>void} [opts.onLate]
|
|
233
256
|
* @returns {Promise<{text:string, stopReason:string, toolCalls:number}>}
|
|
234
257
|
*/
|
|
235
|
-
async ask(sessionId, text, onUpdate) {
|
|
258
|
+
async ask(sessionId, text, onUpdate, opts) {
|
|
236
259
|
await this.ensure()
|
|
260
|
+
const o = opts || {}
|
|
237
261
|
let out = ''
|
|
238
262
|
let tools = 0
|
|
239
263
|
const sink = (u) => {
|
|
@@ -241,12 +265,24 @@ export class AcpHost {
|
|
|
241
265
|
else if (u.sessionUpdate === 'tool_call') tools++
|
|
242
266
|
if (onUpdate) { try { onUpdate(u) } catch {} }
|
|
243
267
|
}
|
|
268
|
+
const dropSink = () => { if (this.inflight.get(sessionId) === sink) this.inflight.delete(sessionId) }
|
|
244
269
|
this.inflight.set(sessionId, sink)
|
|
270
|
+
const late = o.onLate
|
|
271
|
+
? (result, error) => {
|
|
272
|
+
dropSink()
|
|
273
|
+
try { o.onLate({ text: out.trim(), stopReason: result?.stopReason ?? null, toolCalls: tools, error: error || null }) }
|
|
274
|
+
catch (e) { this.log('acp-late-callback-failed', { error: String(e?.message ?? e) }) }
|
|
275
|
+
}
|
|
276
|
+
: null
|
|
245
277
|
try {
|
|
246
|
-
const r = await this._request('session/prompt', { sessionId, prompt: [{ type: 'text', text: String(text) }] }
|
|
278
|
+
const r = await this._request('session/prompt', { sessionId, prompt: [{ type: 'text', text: String(text) }] },
|
|
279
|
+
o.timeoutMs, { keepAliveOnTimeout: !!o.keepAliveOnTimeout, onLate: late })
|
|
280
|
+
dropSink()
|
|
247
281
|
return { text: out.trim(), stopReason: r?.stopReason ?? null, toolCalls: tools }
|
|
248
|
-
}
|
|
249
|
-
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// 超时且已保留:**留着 sink**——这一轮还在跑,正文要靠后续 session/update 累积
|
|
284
|
+
if (!(o.keepAliveOnTimeout && isAcpTimeout(e))) dropSink()
|
|
285
|
+
throw e
|
|
250
286
|
}
|
|
251
287
|
}
|
|
252
288
|
|
|
@@ -272,3 +308,13 @@ export class AcpHost {
|
|
|
272
308
|
this.ready = null
|
|
273
309
|
}
|
|
274
310
|
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 这个错误是不是"本轮超时"(而不是"通道不可用")?
|
|
314
|
+
* 超时意味着 **ACP 那一轮还在跑**,调用方不该把它当失败去走兜底(会重复执行同一件事)。
|
|
315
|
+
*/
|
|
316
|
+
export function isAcpTimeout(err) {
|
|
317
|
+
if (!err) return false
|
|
318
|
+
if (err.code === 'ACP_TIMEOUT') return true
|
|
319
|
+
return /ACP 超时|ACP_TIMEOUT/.test(String(err.message || ''))
|
|
320
|
+
}
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync, copyFileSync, rmSync } from 'node:fs'
|
|
15
15
|
import { spawn, execFileSync } from 'node:child_process'
|
|
16
|
-
import { AcpHost } from './acp.mjs'
|
|
16
|
+
import { AcpHost, isAcpTimeout } from './acp.mjs'
|
|
17
17
|
import { ensureAcpPresetSupport, acpPresetSupported, acpPackageFile, acpPackageCandidates } from './acp-preset-shim.mjs'
|
|
18
18
|
import { parseApprovalReply, pickOptionByDecision, formatApprovalCard, decisionAck, DECISION_ALLOW, DECISION_REJECT } from './approval-reply.mjs'
|
|
19
19
|
import { parsePushEntry, pickRecipient } from './outbox.mjs'
|
|
@@ -183,7 +183,7 @@ function ensureDshBin() {
|
|
|
183
183
|
|
|
184
184
|
const API_BASE = process.env.ILINK_BASE || 'https://ilinkai.weixin.qq.com'
|
|
185
185
|
const PROTOCOL_VERSION = '2.4.6'
|
|
186
|
-
const BRIDGE_VERSION = '0.5.
|
|
186
|
+
const BRIDGE_VERSION = '0.5.1'
|
|
187
187
|
const BOOT_ID = process.pid
|
|
188
188
|
const MESSAGE_TYPE_BOT = 2
|
|
189
189
|
const ITEM_TYPE_TEXT = 1
|
|
@@ -236,6 +236,10 @@ const PERM_MODES = ['ask', 'allow', 'reject']
|
|
|
236
236
|
function normPermMode(v, d = 'ask') { const s = String(v ?? '').toLowerCase(); return PERM_MODES.includes(s) ? s : d }
|
|
237
237
|
let ACP_PERM = normPermMode(ACP_CFG.permPolicy, 'ask')
|
|
238
238
|
const APPROVAL_TIMEOUT_MS = Number(process.env.WXBRIDGE_APPROVAL_TIMEOUT_MS || ACP_CFG.approvalTimeoutMs || 120000)
|
|
239
|
+
// 一轮 ACP 提示词的等待上限(与"任务超时"分开):
|
|
240
|
+
// 任务超时(TASK_TIMEOUT_MS,5 分钟)用在**兜底路径**上——那里超时是真杀子进程;
|
|
241
|
+
// 而 ACP 超时只意味着"我们不再等",那一轮**还在跑**,所以给足 15 分钟,且超时不回落(见 runViaAcp)。
|
|
242
|
+
const PROMPT_TIMEOUT_MS = Number(process.env.BRIDGE_PROMPT_TIMEOUT_MS || ACP_CFG.promptTimeoutMs || 900000)
|
|
239
243
|
// 手机通道的权限预设(ACP 叠层里 permission.defaultPreset)。默认 workspace-write:
|
|
240
244
|
// 越界(工作区之外)的写与提权会变成审批卡推到微信,回「批准 / 拒绝」即可 —— 手机可应答后,
|
|
241
245
|
// 不必再用"最宽权限"绕开审批。要回到旧行为:config.json 的 acp.permPreset 设 danger-full-access;
|
|
@@ -863,6 +867,8 @@ function ensurePushHelper() {
|
|
|
863
867
|
* 规则:`defaultPreset:` 一行**无论写的是什么值**都换成当前 `acp.permPreset`;
|
|
864
868
|
* 再按模板最后的缩进追加提示行(推送命令要给手机侧的 agent 看)。
|
|
865
869
|
*/
|
|
870
|
+
let chatOverlayPath = ''
|
|
871
|
+
|
|
866
872
|
export function buildChatOverlayText(text, permPreset, hintLines) {
|
|
867
873
|
const lines = String(text ?? '').split(NL)
|
|
868
874
|
while (lines.length && !String(lines[lines.length - 1]).trim()) lines.pop()
|
|
@@ -888,7 +894,6 @@ function ensureChatOverlay() {
|
|
|
888
894
|
try {
|
|
889
895
|
const f = join(STATE_DIR, 'acp-overlay-chat.gen.yml')
|
|
890
896
|
writeFileSync(f, out)
|
|
891
|
-
// 只在内容变了时记一行,避免每次 spawn 都刷日志
|
|
892
897
|
if (!chatOverlayPath) log('chat-overlay-written', { file: f, permPreset: ACP_PERM_PRESET })
|
|
893
898
|
chatOverlayPath = f
|
|
894
899
|
return f
|
|
@@ -993,7 +998,7 @@ function getAcpHost(key) {
|
|
|
993
998
|
if (!acpHosts.has(k)) acpHosts.set(k, new AcpHost({
|
|
994
999
|
bin: resolveAcpBin(), node: NODE_BIN, home: ACP_HOME, cwd: CWD, patch: overlayFor(k),
|
|
995
1000
|
permPolicy: ACP_PERM, permFallback: 'reject', onPermission: (req) => handlePermissionRequest(req),
|
|
996
|
-
promptTimeoutMs:
|
|
1001
|
+
promptTimeoutMs: PROMPT_TIMEOUT_MS, log,
|
|
997
1002
|
}))
|
|
998
1003
|
// 策略是热切换的:已有宿主每次取用都同步一次(改 /审批 后不必重启桥)
|
|
999
1004
|
const host = acpHosts.get(k)
|
|
@@ -1355,12 +1360,14 @@ async function applyPeerConfig(peer, sessionId) {
|
|
|
1355
1360
|
|
|
1356
1361
|
/**
|
|
1357
1362
|
* 一轮任务走 ACP:把用户原话交给 DSH 会话(**不拼接历史**),上下文由 DSH 维护。
|
|
1358
|
-
*
|
|
1363
|
+
* 返回 null 才意味着"ACP 不可用",由调用方回落宿主/headless 路径;
|
|
1364
|
+
* **超时不算不可用**(那一轮还在跑)→ 返回 `{pending:true}` 并等 onLate 补发结果。
|
|
1359
1365
|
*/
|
|
1360
|
-
async function runViaAcp(text, peer, taskId) {
|
|
1366
|
+
async function runViaAcp(text, peer, taskId, peerKey) {
|
|
1361
1367
|
if (!ACP_ENABLED) return null
|
|
1368
|
+
let sessionId = ''
|
|
1362
1369
|
try {
|
|
1363
|
-
|
|
1370
|
+
sessionId = await ensurePeerSession(peer)
|
|
1364
1371
|
if (!sessionId) return null
|
|
1365
1372
|
const rec = state.tasks[taskId]
|
|
1366
1373
|
if (rec) { rec.session = sessionId; rec.mode = 'acp' }
|
|
@@ -1370,17 +1377,47 @@ async function runViaAcp(text, peer, taskId) {
|
|
|
1370
1377
|
const rec = state.tasks[taskId]
|
|
1371
1378
|
if (rec) { rec.lastTool = String(u.title || 'tool'); rec.lastToolAt = Date.now() } // /task 能看到"最近在干什么"
|
|
1372
1379
|
}
|
|
1380
|
+
}, {
|
|
1381
|
+
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
1382
|
+
keepAliveOnTimeout: true,
|
|
1383
|
+
onLate: (late) => deliverLateAcpResult(peer, peerKey, taskId, late),
|
|
1373
1384
|
})
|
|
1374
1385
|
log('task-done-acp', { id: taskId, session: sessionId, outLen: (r.text || '').length, tools: r.toolCalls, stop: r.stopReason })
|
|
1375
1386
|
const notice = peer.acpNotice || ''
|
|
1376
1387
|
if (notice) peer.acpNotice = ''
|
|
1377
1388
|
return { ok: true, text: r.text, sessionId, viaAcp: true, stopReason: r.stopReason, notice }
|
|
1378
1389
|
} catch (e) {
|
|
1390
|
+
if (isAcpTimeout(e)) {
|
|
1391
|
+
// **超时 ≠ 失败**:这一轮还在 ACP 侧跑,服务端最后一定会回帧(onLate 负责补发)。
|
|
1392
|
+
// 这里绝不能返回 null —— 那会让 processTask 去走兜底路径,把同一件事**再干一遍**(而且是拼接提示词的旧形态)。
|
|
1393
|
+
log('task-acp-still-running', { id: taskId, session: sessionId, waitedMs: PROMPT_TIMEOUT_MS, error: String(e?.message ?? e) })
|
|
1394
|
+
return { ok: false, pending: true, sessionId, error: '这一轮超过 ' + Math.round(PROMPT_TIMEOUT_MS / 60000) + ' 分钟仍在跑' }
|
|
1395
|
+
}
|
|
1396
|
+
// 真·通道不可用:回落之前先取消这一轮,避免"兜底路径与 ACP 同时跑"重复执行
|
|
1397
|
+
if (sessionId) {
|
|
1398
|
+
try { await getAcpHost(peerMode(peer)).cancel(sessionId); log('acp-fallback-cancel', { id: taskId, session: sessionId }) } catch {}
|
|
1399
|
+
}
|
|
1379
1400
|
log('task-acp-failed', { id: taskId, error: String(e?.message ?? e) })
|
|
1380
1401
|
return null
|
|
1381
1402
|
}
|
|
1382
1403
|
}
|
|
1383
1404
|
|
|
1405
|
+
/** 迟到的那一轮跑完了:把结果补发给用户(手机侧不必重发原话)。 */
|
|
1406
|
+
function deliverLateAcpResult(peer, peerKey, taskId, late) {
|
|
1407
|
+
const t = state.tasks[taskId] || {}
|
|
1408
|
+
const audited = auditOutput('⏳(这一轮跑完了)' + NL + (late.error ? '执行失败:' + late.error.message : late.text))
|
|
1409
|
+
if (audited.hits.length) log('output-audit', { id: taskId, hits: audited.hits })
|
|
1410
|
+
t.status = late.error ? 'failed' : 'done'
|
|
1411
|
+
t.endedAt = new Date().toISOString()
|
|
1412
|
+
t.lateDelivered = true
|
|
1413
|
+
peer.history = ((peer.history || []).concat([{ u: t.u || '', a: late.text || String(late.error?.message || '失败'), ok: !late.error, at: t.endedAt, late: true }])).slice(-50)
|
|
1414
|
+
log('task-late-done', { id: taskId, ok: !late.error, outLen: (late.text || '').length, tools: late.toolCalls })
|
|
1415
|
+
saveState(state).catch(() => {})
|
|
1416
|
+
const key = peerKey || Object.keys(state.peers).find((k) => state.peers[k] === peer) || ''
|
|
1417
|
+
if (!key) return
|
|
1418
|
+
reply(wechatToken, key, audited.text, peer.lastContextToken).catch((e) => log('late-reply-failed', { error: String(e?.message ?? e) }))
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1384
1421
|
function killTree(pid) { try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch {} }
|
|
1385
1422
|
function runDsh(task, cwd, taskId) {
|
|
1386
1423
|
return new Promise((resolve) => {
|
|
@@ -1470,7 +1507,7 @@ const runningCount = () => Object.values(state.tasks).filter((t) => t.status ===
|
|
|
1470
1507
|
async function processTask(token, peerKey, from, taskId, text, contextToken) {
|
|
1471
1508
|
const peer = state.peers[peerKey]
|
|
1472
1509
|
const t = state.tasks[taskId]
|
|
1473
|
-
t.status = 'running'; t.startedAt = new Date().toISOString(); activeTasks = runningCount()
|
|
1510
|
+
t.status = 'running'; t.u = text; t.startedAt = new Date().toISOString(); activeTasks = runningCount()
|
|
1474
1511
|
await saveState(state)
|
|
1475
1512
|
const history = (peer.history || []).filter((h) => h.ok !== false).slice(-6)
|
|
1476
1513
|
const prompt = PROMPT_PREFIX + sanitize(history.length
|
|
@@ -1478,16 +1515,28 @@ async function processTask(token, peerKey, from, taskId, text, contextToken) {
|
|
|
1478
1515
|
: text)
|
|
1479
1516
|
let r = null
|
|
1480
1517
|
// ① 首选 ACP:DSH 原生会话 + 原生上下文(桥不拼接历史)
|
|
1481
|
-
r = await runViaAcp(text, peer, taskId)
|
|
1518
|
+
r = await runViaAcp(text, peer, taskId, peerKey)
|
|
1519
|
+
// ①-b 超时 ≠ 失败:这一轮还在 ACP 侧跑,结果由 onLate 补发;**不走兜底**(否则同一件事被重跑一遍)
|
|
1520
|
+
if (r && r.pending) {
|
|
1521
|
+
t.pendingSince = new Date().toISOString(); activeTasks = runningCount()
|
|
1522
|
+
await saveState(state)
|
|
1523
|
+
return reply(token, from, '⏳ 这一轮已跑 ' + Math.round(PROMPT_TIMEOUT_MS / 60000) + ' 分钟仍在继续(会话 '
|
|
1524
|
+
+ String(r.sessionId || '').slice(0, 12) + '…)。' + NL
|
|
1525
|
+
+ '我**不会重复执行**它;跑完直接把结果发到这里(/task 看进度,/cancel 可中止)。', contextToken)
|
|
1526
|
+
}
|
|
1527
|
+
let viaFallback = ''
|
|
1482
1528
|
// ② 其次宿主内执行(历史行为;不产生 turn 时返回空 → 继续回落)
|
|
1483
|
-
if (!r) { const viaHost = await runViaHost(text, peer, taskId); if (viaHost && String(viaHost.text || '').trim()) r = viaHost }
|
|
1529
|
+
if (!r) { const viaHost = await runViaHost(text, peer, taskId); if (viaHost && String(viaHost.text || '').trim()) { r = viaHost; viaFallback = 'host' } }
|
|
1484
1530
|
// ③ 最后回落:一次性 headless(沿用拼接提示词,仅作兜底)
|
|
1485
|
-
if (!r) r = await runDsh(prompt, peer.cwd, taskId)
|
|
1531
|
+
if (!r) { r = await runDsh(prompt, peer.cwd, taskId); viaFallback = 'headless' }
|
|
1486
1532
|
t.status = r.timedOut ? 'timeout' : (r.ok ? 'done' : 'failed')
|
|
1487
1533
|
t.endedAt = new Date().toISOString(); t.log = r.log; activeTasks = runningCount()
|
|
1488
1534
|
peer.history = ((peer.history || []).concat([{ u: text, a: r.ok ? r.text : (r.error || '失败'), ok: !!r.ok, at: t.endedAt }])).slice(-50)
|
|
1489
|
-
log('task-done', { id: taskId, ok: r.ok, timeout: !!r.timedOut, outLen: r.text?.length ?? 0 })
|
|
1490
|
-
const
|
|
1535
|
+
log('task-done', { id: taskId, ok: r.ok, timeout: !!r.timedOut, viaFallback: viaFallback || 'acp', outLen: r.text?.length ?? 0 })
|
|
1536
|
+
const fallbackNotice = viaFallback
|
|
1537
|
+
? '⚠️ 本轮 ACP 路径不可用,走了兜底(' + viaFallback + ');兜底路径会把历史拼接进提示词(不是上下文回退)。' + NL
|
|
1538
|
+
: ''
|
|
1539
|
+
const audited = auditOutput((r.notice ? r.notice + NL : '') + fallbackNotice + (r.ok ? r.text : '执行失败:' + (r.error ?? '未知错误')))
|
|
1491
1540
|
if (audited.hits.length) log('output-audit', { id: taskId, hits: audited.hits })
|
|
1492
1541
|
await reply(token, from, audited.text, contextToken)
|
|
1493
1542
|
await saveState(state)
|
|
@@ -2214,13 +2263,60 @@ async function main() {
|
|
|
2214
2263
|
hostHome: HOST_HOME || '(未指定)', acpHome: ACP_HOME, childHome: CHILD_HOME,
|
|
2215
2264
|
vault: VAULT_PATH || '(未指定)', contract: CONTRACT_FILE,
|
|
2216
2265
|
acp: { enabled: ACP_ENABLED, preset: ACP_CFG.preset || '(跟随宿主默认)', permPolicy: ACP_PERM,
|
|
2217
|
-
permPreset: ACP_PERM_PRESET, approvalTimeoutMs: APPROVAL_TIMEOUT_MS,
|
|
2266
|
+
permPreset: ACP_PERM_PRESET, approvalTimeoutMs: APPROVAL_TIMEOUT_MS, promptTimeoutMs: PROMPT_TIMEOUT_MS,
|
|
2267
|
+
taskTimeoutMs: TASK_TIMEOUT_MS, patchAcp: ACP_CFG.patchAcp !== false },
|
|
2218
2268
|
overlays: { presetOverlay: overlayFor(ACP_DEFAULT_PRESET), chatOverlay: ensureChatOverlay() },
|
|
2219
2269
|
push: { cli: PUSH_CLI, outbox: OUTBOX_DIR },
|
|
2220
2270
|
}, null, 1))
|
|
2221
2271
|
process.exit(0)
|
|
2222
2272
|
}
|
|
2223
2273
|
|
|
2274
|
+
if (args.includes('--selftest-acp-timeout')) {
|
|
2275
|
+
// 离线自测:**超时 ≠ 失败** 这条规则的三段行为(不连模型、不起子进程)
|
|
2276
|
+
const rows = []
|
|
2277
|
+
const mk = (timeoutMs) => {
|
|
2278
|
+
const h = new AcpHost({ promptTimeoutMs: timeoutMs, permPolicy: 'ask', log: () => {} })
|
|
2279
|
+
h.ready = Promise.resolve({}) // 跳过真实握手
|
|
2280
|
+
h.child = { stdin: { write: () => {} }, exitCode: null }
|
|
2281
|
+
return h
|
|
2282
|
+
}
|
|
2283
|
+
const frame = (id, text, stop) => JSON.stringify({ jsonrpc: '2.0', id, result: { stopReason: stop, _text: text } }) + NL
|
|
2284
|
+
// ① 分类:超时 vs 其它
|
|
2285
|
+
const timeoutErr = Object.assign(new Error('ACP 超时:session/prompt'), { code: 'ACP_TIMEOUT' })
|
|
2286
|
+
rows.push({ name: 'isAcpTimeout(tagged)', ok: isAcpTimeout(timeoutErr) === true })
|
|
2287
|
+
rows.push({ name: 'isAcpTimeout(plain message)', ok: isAcpTimeout(new Error('ACP 超时:session/prompt')) === true })
|
|
2288
|
+
rows.push({ name: 'isAcpTimeout(其它错误)', ok: isAcpTimeout(new Error('ACP 进程已退出(code=1)')) === false })
|
|
2289
|
+
// ② keepAlive:超时后迟到帧交给 onLate(正文靠 session/update 累积)
|
|
2290
|
+
{
|
|
2291
|
+
const h = mk(120)
|
|
2292
|
+
let late = null
|
|
2293
|
+
const id = h.nextId
|
|
2294
|
+
let code = ''
|
|
2295
|
+
try { await h.ask('s-late', 'hi', null, { timeoutMs: 120, keepAliveOnTimeout: true, onLate: (r) => { late = r } }) } catch (e) { code = e.code || '' }
|
|
2296
|
+
const keptAfterTimeout = h.pending.has(id)
|
|
2297
|
+
h._onData(Buffer.from(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 's-late', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '迟到正文' } } } }) + NL))
|
|
2298
|
+
h._onData(Buffer.from(frame(id, '', 'end_turn')))
|
|
2299
|
+
rows.push({ name: '超时错误带 code=ACP_TIMEOUT', ok: code === 'ACP_TIMEOUT' })
|
|
2300
|
+
rows.push({ name: '超时后 pending 条目被保留', ok: keptAfterTimeout === true })
|
|
2301
|
+
rows.push({ name: '迟到帧触发 onLate 且正文完整', ok: !!late && late.text === '迟到正文' && late.stopReason === 'end_turn' })
|
|
2302
|
+
rows.push({ name: '迟到后 pending 清空 + sink 摘除', ok: !h.pending.has(id) && !h.inflight.has('s-late') })
|
|
2303
|
+
}
|
|
2304
|
+
// ③ 对照:不 keepAlive 时超时即丢弃,迟到帧不会触发任何回调(旧行为)
|
|
2305
|
+
{
|
|
2306
|
+
const h = mk(120)
|
|
2307
|
+
let late = null
|
|
2308
|
+
const id = h.nextId
|
|
2309
|
+
let code = ''
|
|
2310
|
+
try { await h.ask('s-drop', 'hi', null, { timeoutMs: 120, onLate: (r) => { late = r } }) } catch (e) { code = e.code || '' }
|
|
2311
|
+
h._onData(Buffer.from(frame(id, '', 'end_turn')))
|
|
2312
|
+
rows.push({ name: '未开 keepAlive:超时即丢弃且无回调', ok: code === 'ACP_TIMEOUT' && late === null && !h.pending.has(id) && !h.inflight.has('s-drop') })
|
|
2313
|
+
}
|
|
2314
|
+
console.log('[selftest-acp-timeout] ' + JSON.stringify({
|
|
2315
|
+
rows, failed: rows.filter((r) => !r.ok).map((r) => r.name),
|
|
2316
|
+
promptTimeoutMs: PROMPT_TIMEOUT_MS, taskTimeoutMs: TASK_TIMEOUT_MS,
|
|
2317
|
+
}, null, 1))
|
|
2318
|
+
process.exit(rows.every((r) => r.ok) ? 0 : 1)
|
|
2319
|
+
}
|
|
2224
2320
|
acquireLock()
|
|
2225
2321
|
// 上次进程遗留的运行态任务:本轮一启动就标成 interrupted。
|
|
2226
2322
|
// 否则 runningCount() 永远虚高,`while (runningCount() >= MAX_CONCURRENT_TASKS)` 会把新任务永久挡住。
|
package/package.json
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@zmainer/dsh-wx-bridge",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"main": "./lib/index.js",
|
|
6
|
-
"dsh": {
|
|
7
|
-
"bundle": {
|
|
8
|
-
"patch": "./cordis.patch.yml"
|
|
9
|
-
},
|
|
10
|
-
"client": {
|
|
11
|
-
"platform": "web"
|
|
12
|
-
}
|
|
13
|
-
},
|
|
14
|
-
"exports": {
|
|
15
|
-
".": "./lib/index.js",
|
|
16
|
-
"./client": "./lib/client.js",
|
|
17
|
-
"./package.json": "./package.json"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "node scripts/build-client.mjs"
|
|
21
|
-
},
|
|
22
|
-
"files": [
|
|
23
|
-
"lib",
|
|
24
|
-
"src",
|
|
25
|
-
"scripts",
|
|
26
|
-
"cordis.patch.yml",
|
|
27
|
-
"README.md",
|
|
28
|
-
"package.json"
|
|
29
|
-
],
|
|
30
|
-
"description": "DSH plugin: connect WeChat (official iLink/ClawBot) to DeepSeek Harness — settings page with live bridge status, QR device pairing, in-WeChat tool approvals, proactive push, and the bundled bridge + keeper kernel.",
|
|
31
|
-
"license": "MIT",
|
|
32
|
-
"keywords": [
|
|
33
|
-
"deepseek-harness",
|
|
34
|
-
"dsh-plugin",
|
|
35
|
-
"wechat",
|
|
36
|
-
"weixin",
|
|
37
|
-
"ilink",
|
|
38
|
-
"clawbot"
|
|
39
|
-
],
|
|
40
|
-
"repository": {
|
|
41
|
-
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/zhy5/dsh-wx-bridge.git"
|
|
43
|
-
},
|
|
44
|
-
"homepage": "https://github.com/zhy5/dsh-wx-bridge#readme",
|
|
45
|
-
"bugs": {
|
|
46
|
-
"url": "https://github.com/zhy5/dsh-wx-bridge/issues"
|
|
47
|
-
},
|
|
48
|
-
"author": "zmainer",
|
|
49
|
-
"engines": {
|
|
50
|
-
"node": ">=22.19.0"
|
|
51
|
-
}
|
|
52
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@zmainer/dsh-wx-bridge",
|
|
3
|
+
"version": "1.1.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.js",
|
|
6
|
+
"dsh": {
|
|
7
|
+
"bundle": {
|
|
8
|
+
"patch": "./cordis.patch.yml"
|
|
9
|
+
},
|
|
10
|
+
"client": {
|
|
11
|
+
"platform": "web"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./lib/index.js",
|
|
16
|
+
"./client": "./lib/client.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node scripts/build-client.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"src",
|
|
25
|
+
"scripts",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"README.md",
|
|
28
|
+
"package.json"
|
|
29
|
+
],
|
|
30
|
+
"description": "DSH plugin: connect WeChat (official iLink/ClawBot) to DeepSeek Harness — settings page with live bridge status, QR device pairing, in-WeChat tool approvals, proactive push, and the bundled bridge + keeper kernel.",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"dsh-plugin",
|
|
35
|
+
"wechat",
|
|
36
|
+
"weixin",
|
|
37
|
+
"ilink",
|
|
38
|
+
"clawbot"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/zhy5/dsh-wx-bridge.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/zhy5/dsh-wx-bridge#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/zhy5/dsh-wx-bridge/issues"
|
|
47
|
+
},
|
|
48
|
+
"author": "zmainer",
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.19.0"
|
|
51
|
+
}
|
|
52
|
+
}
|