@zmainer/dsh-wx-bridge 1.1.6 → 1.1.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/README.md +440 -425
- package/lib/kernel/acp.mjs +52 -6
- package/lib/kernel/bridge.mjs +145 -19
- 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;
|
|
@@ -509,10 +513,21 @@ async function ilink(endpoint, { token, body, method = 'POST', timeoutMs = 15000
|
|
|
509
513
|
}
|
|
510
514
|
const getUpdates = (token, syncBuf, timeoutMs) => ilink('ilink/bot/getupdates', { token, body: { get_updates_buf: syncBuf, base_info: baseInfo() }, timeoutMs })
|
|
511
515
|
.catch((e) => { if (e?.name === 'AbortError') return { ret: 0, msgs: [], get_updates_buf: syncBuf }; throw e })
|
|
516
|
+
/** 发送回执判定:业务码(errcode / ret)非 0 一律抛错。
|
|
517
|
+
* iLink 在"HTTP 200 但没投递"时回的正是 errcode——2026-09-27 实测:会话窗口失效 → {"errcode":-14,"errmsg":"session timeout"},
|
|
518
|
+
* 而旧判据只看 ret(此时该字段不存在)⇒ 被当成成功。成功回执带 message_id,缺了只记一条告警(不判失败)。 */
|
|
519
|
+
function assertSendReceipt(r, len) {
|
|
520
|
+
const code = r?.errcode ?? r?.ret
|
|
521
|
+
if (code !== undefined && code !== null && Number(code) !== 0) {
|
|
522
|
+
throw new Error('sendmessage 被拒: errcode=' + code + (r?.errmsg ? ' ' + r.errmsg : ''))
|
|
523
|
+
}
|
|
524
|
+
if (r?.message_id == null) log('send-no-receipt', { len, keys: Object.keys(r ?? {}).join(',') })
|
|
525
|
+
return r
|
|
526
|
+
}
|
|
512
527
|
const sendText = (token, to, text, contextToken) => ilink('ilink/bot/sendmessage', {
|
|
513
528
|
token,
|
|
514
529
|
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() },
|
|
515
|
-
})
|
|
530
|
+
}).then((r) => assertSendReceipt(r, String(text).length))
|
|
516
531
|
|
|
517
532
|
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 '' }
|
|
518
533
|
/** 入站消息里出现过的条目类型(排障 + 「只认文字」提示用)。 */
|
|
@@ -822,7 +837,7 @@ function presetKey(peer) {
|
|
|
822
837
|
if (peer.acpMode === 'chat') return 'chat'
|
|
823
838
|
return ACP_DEFAULT_PRESET
|
|
824
839
|
}
|
|
825
|
-
function overlayFor(key) {
|
|
840
|
+
function overlayFor(key) {
|
|
826
841
|
if (key === 'chat') return ensureChatOverlay()
|
|
827
842
|
if (!overlayCache.has(key)) overlayCache.set(key, ensurePresetOverlay(key) || '')
|
|
828
843
|
return overlayCache.get(key) || ACP_PATCH
|
|
@@ -881,7 +896,7 @@ export function buildChatOverlayText(text, permPreset, hintLines) {
|
|
|
881
896
|
* 必须重启桥才会重写,否则老生成文件会把新预设**静默顶掉**(2026-09-24 实测踩过)。
|
|
882
897
|
* 这里**每次都重写**(一次几毫秒):宁可多写一次,也不要让"磁盘上的旧生成物"成为隐形真源。
|
|
883
898
|
*/
|
|
884
|
-
function ensureChatOverlay() {
|
|
899
|
+
function ensureChatOverlay() {
|
|
885
900
|
const src = join(HERE, 'acp-overlay-chat.yml')
|
|
886
901
|
let text = ''
|
|
887
902
|
try { text = readFileSync(src, 'utf8') } catch { return src }
|
|
@@ -907,8 +922,7 @@ function moveOutboxFile(file, dir, name, reason) {
|
|
|
907
922
|
/** 严格发送:任一分段失败都如实抛错(与 reply() 的"尽力而为"不同,推送要能判成败)。 */
|
|
908
923
|
async function pushSend(token, to, text, ctx) {
|
|
909
924
|
for (let i = 0; i < text.length; i += MAX_CHUNK) {
|
|
910
|
-
|
|
911
|
-
if (r && r.ret !== undefined && Number(r.ret) !== 0) throw new Error('ret=' + r.ret)
|
|
925
|
+
await sendText(token, to, text.slice(i, i + MAX_CHUNK), ctx)
|
|
912
926
|
}
|
|
913
927
|
return true
|
|
914
928
|
}
|
|
@@ -994,7 +1008,7 @@ function getAcpHost(key) {
|
|
|
994
1008
|
if (!acpHosts.has(k)) acpHosts.set(k, new AcpHost({
|
|
995
1009
|
bin: resolveAcpBin(), node: NODE_BIN, home: ACP_HOME, cwd: CWD, patch: overlayFor(k),
|
|
996
1010
|
permPolicy: ACP_PERM, permFallback: 'reject', onPermission: (req) => handlePermissionRequest(req),
|
|
997
|
-
promptTimeoutMs:
|
|
1011
|
+
promptTimeoutMs: PROMPT_TIMEOUT_MS, log,
|
|
998
1012
|
}))
|
|
999
1013
|
// 策略是热切换的:已有宿主每次取用都同步一次(改 /审批 后不必重启桥)
|
|
1000
1014
|
const host = acpHosts.get(k)
|
|
@@ -1356,12 +1370,14 @@ async function applyPeerConfig(peer, sessionId) {
|
|
|
1356
1370
|
|
|
1357
1371
|
/**
|
|
1358
1372
|
* 一轮任务走 ACP:把用户原话交给 DSH 会话(**不拼接历史**),上下文由 DSH 维护。
|
|
1359
|
-
*
|
|
1373
|
+
* 返回 null 才意味着"ACP 不可用",由调用方回落宿主/headless 路径;
|
|
1374
|
+
* **超时不算不可用**(那一轮还在跑)→ 返回 `{pending:true}` 并等 onLate 补发结果。
|
|
1360
1375
|
*/
|
|
1361
|
-
async function runViaAcp(text, peer, taskId) {
|
|
1376
|
+
async function runViaAcp(text, peer, taskId, peerKey) {
|
|
1362
1377
|
if (!ACP_ENABLED) return null
|
|
1378
|
+
let sessionId = ''
|
|
1363
1379
|
try {
|
|
1364
|
-
|
|
1380
|
+
sessionId = await ensurePeerSession(peer)
|
|
1365
1381
|
if (!sessionId) return null
|
|
1366
1382
|
const rec = state.tasks[taskId]
|
|
1367
1383
|
if (rec) { rec.session = sessionId; rec.mode = 'acp' }
|
|
@@ -1371,17 +1387,47 @@ async function runViaAcp(text, peer, taskId) {
|
|
|
1371
1387
|
const rec = state.tasks[taskId]
|
|
1372
1388
|
if (rec) { rec.lastTool = String(u.title || 'tool'); rec.lastToolAt = Date.now() } // /task 能看到"最近在干什么"
|
|
1373
1389
|
}
|
|
1390
|
+
}, {
|
|
1391
|
+
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
1392
|
+
keepAliveOnTimeout: true,
|
|
1393
|
+
onLate: (late) => deliverLateAcpResult(peer, peerKey, taskId, late),
|
|
1374
1394
|
})
|
|
1375
1395
|
log('task-done-acp', { id: taskId, session: sessionId, outLen: (r.text || '').length, tools: r.toolCalls, stop: r.stopReason })
|
|
1376
1396
|
const notice = peer.acpNotice || ''
|
|
1377
1397
|
if (notice) peer.acpNotice = ''
|
|
1378
1398
|
return { ok: true, text: r.text, sessionId, viaAcp: true, stopReason: r.stopReason, notice }
|
|
1379
1399
|
} catch (e) {
|
|
1400
|
+
if (isAcpTimeout(e)) {
|
|
1401
|
+
// **超时 ≠ 失败**:这一轮还在 ACP 侧跑,服务端最后一定会回帧(onLate 负责补发)。
|
|
1402
|
+
// 这里绝不能返回 null —— 那会让 processTask 去走兜底路径,把同一件事**再干一遍**(而且是拼接提示词的旧形态)。
|
|
1403
|
+
log('task-acp-still-running', { id: taskId, session: sessionId, waitedMs: PROMPT_TIMEOUT_MS, error: String(e?.message ?? e) })
|
|
1404
|
+
return { ok: false, pending: true, sessionId, error: '这一轮超过 ' + Math.round(PROMPT_TIMEOUT_MS / 60000) + ' 分钟仍在跑' }
|
|
1405
|
+
}
|
|
1406
|
+
// 真·通道不可用:回落之前先取消这一轮,避免"兜底路径与 ACP 同时跑"重复执行
|
|
1407
|
+
if (sessionId) {
|
|
1408
|
+
try { await getAcpHost(peerMode(peer)).cancel(sessionId); log('acp-fallback-cancel', { id: taskId, session: sessionId }) } catch {}
|
|
1409
|
+
}
|
|
1380
1410
|
log('task-acp-failed', { id: taskId, error: String(e?.message ?? e) })
|
|
1381
1411
|
return null
|
|
1382
1412
|
}
|
|
1383
1413
|
}
|
|
1384
1414
|
|
|
1415
|
+
/** 迟到的那一轮跑完了:把结果补发给用户(手机侧不必重发原话)。 */
|
|
1416
|
+
function deliverLateAcpResult(peer, peerKey, taskId, late) {
|
|
1417
|
+
const t = state.tasks[taskId] || {}
|
|
1418
|
+
const audited = auditOutput('⏳(这一轮跑完了)' + NL + (late.error ? '执行失败:' + late.error.message : late.text))
|
|
1419
|
+
if (audited.hits.length) log('output-audit', { id: taskId, hits: audited.hits })
|
|
1420
|
+
t.status = late.error ? 'failed' : 'done'
|
|
1421
|
+
t.endedAt = new Date().toISOString()
|
|
1422
|
+
t.lateDelivered = true
|
|
1423
|
+
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)
|
|
1424
|
+
log('task-late-done', { id: taskId, ok: !late.error, outLen: (late.text || '').length, tools: late.toolCalls })
|
|
1425
|
+
saveState(state).catch(() => {})
|
|
1426
|
+
const key = peerKey || Object.keys(state.peers).find((k) => state.peers[k] === peer) || ''
|
|
1427
|
+
if (!key) return
|
|
1428
|
+
reply(wechatToken, key, audited.text, peer.lastContextToken).catch((e) => log('late-reply-failed', { error: String(e?.message ?? e) }))
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1385
1431
|
function killTree(pid) { try { spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch {} }
|
|
1386
1432
|
function runDsh(task, cwd, taskId) {
|
|
1387
1433
|
return new Promise((resolve) => {
|
|
@@ -1421,7 +1467,7 @@ async function reply(token, to, text, ctx) {
|
|
|
1421
1467
|
const body = String(text ?? '').trim() || '(空回复)'
|
|
1422
1468
|
for (let i = 0; i < body.length; i += MAX_CHUNK) {
|
|
1423
1469
|
const c = body.slice(i, i + MAX_CHUNK)
|
|
1424
|
-
try { const r = await sendText(token, to, c, ctx); log('sent', { len: c.length,
|
|
1470
|
+
try { const r = await sendText(token, to, c, ctx); log('sent', { len: c.length, message_id: r?.message_id ?? null }) } catch (e) { log('send-failed', { len: c.length, error: String(e?.message ?? e) }) }
|
|
1425
1471
|
}
|
|
1426
1472
|
}
|
|
1427
1473
|
|
|
@@ -1471,7 +1517,7 @@ const runningCount = () => Object.values(state.tasks).filter((t) => t.status ===
|
|
|
1471
1517
|
async function processTask(token, peerKey, from, taskId, text, contextToken) {
|
|
1472
1518
|
const peer = state.peers[peerKey]
|
|
1473
1519
|
const t = state.tasks[taskId]
|
|
1474
|
-
t.status = 'running'; t.startedAt = new Date().toISOString(); activeTasks = runningCount()
|
|
1520
|
+
t.status = 'running'; t.u = text; t.startedAt = new Date().toISOString(); activeTasks = runningCount()
|
|
1475
1521
|
await saveState(state)
|
|
1476
1522
|
const history = (peer.history || []).filter((h) => h.ok !== false).slice(-6)
|
|
1477
1523
|
const prompt = PROMPT_PREFIX + sanitize(history.length
|
|
@@ -1479,16 +1525,28 @@ async function processTask(token, peerKey, from, taskId, text, contextToken) {
|
|
|
1479
1525
|
: text)
|
|
1480
1526
|
let r = null
|
|
1481
1527
|
// ① 首选 ACP:DSH 原生会话 + 原生上下文(桥不拼接历史)
|
|
1482
|
-
r = await runViaAcp(text, peer, taskId)
|
|
1528
|
+
r = await runViaAcp(text, peer, taskId, peerKey)
|
|
1529
|
+
// ①-b 超时 ≠ 失败:这一轮还在 ACP 侧跑,结果由 onLate 补发;**不走兜底**(否则同一件事被重跑一遍)
|
|
1530
|
+
if (r && r.pending) {
|
|
1531
|
+
t.pendingSince = new Date().toISOString(); activeTasks = runningCount()
|
|
1532
|
+
await saveState(state)
|
|
1533
|
+
return reply(token, from, '⏳ 这一轮已跑 ' + Math.round(PROMPT_TIMEOUT_MS / 60000) + ' 分钟仍在继续(会话 '
|
|
1534
|
+
+ String(r.sessionId || '').slice(0, 12) + '…)。' + NL
|
|
1535
|
+
+ '我**不会重复执行**它;跑完直接把结果发到这里(/task 看进度,/cancel 可中止)。', contextToken)
|
|
1536
|
+
}
|
|
1537
|
+
let viaFallback = ''
|
|
1483
1538
|
// ② 其次宿主内执行(历史行为;不产生 turn 时返回空 → 继续回落)
|
|
1484
|
-
if (!r) { const viaHost = await runViaHost(text, peer, taskId); if (viaHost && String(viaHost.text || '').trim()) r = viaHost }
|
|
1539
|
+
if (!r) { const viaHost = await runViaHost(text, peer, taskId); if (viaHost && String(viaHost.text || '').trim()) { r = viaHost; viaFallback = 'host' } }
|
|
1485
1540
|
// ③ 最后回落:一次性 headless(沿用拼接提示词,仅作兜底)
|
|
1486
|
-
if (!r) r = await runDsh(prompt, peer.cwd, taskId)
|
|
1541
|
+
if (!r) { r = await runDsh(prompt, peer.cwd, taskId); viaFallback = 'headless' }
|
|
1487
1542
|
t.status = r.timedOut ? 'timeout' : (r.ok ? 'done' : 'failed')
|
|
1488
1543
|
t.endedAt = new Date().toISOString(); t.log = r.log; activeTasks = runningCount()
|
|
1489
1544
|
peer.history = ((peer.history || []).concat([{ u: text, a: r.ok ? r.text : (r.error || '失败'), ok: !!r.ok, at: t.endedAt }])).slice(-50)
|
|
1490
|
-
log('task-done', { id: taskId, ok: r.ok, timeout: !!r.timedOut, outLen: r.text?.length ?? 0 })
|
|
1491
|
-
const
|
|
1545
|
+
log('task-done', { id: taskId, ok: r.ok, timeout: !!r.timedOut, viaFallback: viaFallback || 'acp', outLen: r.text?.length ?? 0 })
|
|
1546
|
+
const fallbackNotice = viaFallback
|
|
1547
|
+
? '⚠️ 本轮 ACP 路径不可用,走了兜底(' + viaFallback + ');兜底路径会把历史拼接进提示词(不是上下文回退)。' + NL
|
|
1548
|
+
: ''
|
|
1549
|
+
const audited = auditOutput((r.notice ? r.notice + NL : '') + fallbackNotice + (r.ok ? r.text : '执行失败:' + (r.error ?? '未知错误')))
|
|
1492
1550
|
if (audited.hits.length) log('output-audit', { id: taskId, hits: audited.hits })
|
|
1493
1551
|
await reply(token, from, audited.text, contextToken)
|
|
1494
1552
|
await saveState(state)
|
|
@@ -2215,13 +2273,79 @@ async function main() {
|
|
|
2215
2273
|
hostHome: HOST_HOME || '(未指定)', acpHome: ACP_HOME, childHome: CHILD_HOME,
|
|
2216
2274
|
vault: VAULT_PATH || '(未指定)', contract: CONTRACT_FILE,
|
|
2217
2275
|
acp: { enabled: ACP_ENABLED, preset: ACP_CFG.preset || '(跟随宿主默认)', permPolicy: ACP_PERM,
|
|
2218
|
-
permPreset: ACP_PERM_PRESET, approvalTimeoutMs: APPROVAL_TIMEOUT_MS,
|
|
2276
|
+
permPreset: ACP_PERM_PRESET, approvalTimeoutMs: APPROVAL_TIMEOUT_MS, promptTimeoutMs: PROMPT_TIMEOUT_MS,
|
|
2277
|
+
taskTimeoutMs: TASK_TIMEOUT_MS, patchAcp: ACP_CFG.patchAcp !== false },
|
|
2219
2278
|
overlays: { presetOverlay: overlayFor(ACP_DEFAULT_PRESET), chatOverlay: ensureChatOverlay() },
|
|
2220
2279
|
push: { cli: PUSH_CLI, outbox: OUTBOX_DIR },
|
|
2221
2280
|
}, null, 1))
|
|
2222
2281
|
process.exit(0)
|
|
2223
2282
|
}
|
|
2224
2283
|
|
|
2284
|
+
if (args.includes('--selftest-receipt')) {
|
|
2285
|
+
// 离线自测:发送回执的业务码判定(纯函数,不连网络、不发消息)
|
|
2286
|
+
const cases = [
|
|
2287
|
+
['成功(带 message_id)', { message_id: 12345 }, false],
|
|
2288
|
+
['成功(ret=0 且带 message_id)', { ret: 0, message_id: 1 }, false],
|
|
2289
|
+
['被拒:errcode=-14 会话窗口失效(2026-09-27 实测形态)', { errcode: -14, errmsg: 'session timeout' }, true],
|
|
2290
|
+
['被拒:ret 非 0', { ret: 5 }, true],
|
|
2291
|
+
['被拒:errcode 字符串非 0', { errcode: '-3' }, true],
|
|
2292
|
+
['缺 message_id(只告警,不判失败)', { ret: 0 }, false],
|
|
2293
|
+
]
|
|
2294
|
+
const rows = cases.map(([name, r, wantFail]) => {
|
|
2295
|
+
let failed = false
|
|
2296
|
+
let thrown = ''
|
|
2297
|
+
try { assertSendReceipt(r, 10) } catch (e) { failed = true; thrown = String(e?.message ?? e) }
|
|
2298
|
+
return { name, ok: failed === wantFail, failed, thrown }
|
|
2299
|
+
})
|
|
2300
|
+
console.log('[selftest-receipt] ' + JSON.stringify({ rows }, null, 1))
|
|
2301
|
+
process.exit(rows.every((r) => r.ok) ? 0 : 1)
|
|
2302
|
+
}
|
|
2303
|
+
if (args.includes('--selftest-acp-timeout')) {
|
|
2304
|
+
// 离线自测:**超时 ≠ 失败** 这条规则的三段行为(不连模型、不起子进程)
|
|
2305
|
+
const rows = []
|
|
2306
|
+
const mk = (timeoutMs) => {
|
|
2307
|
+
const h = new AcpHost({ promptTimeoutMs: timeoutMs, permPolicy: 'ask', log: () => {} })
|
|
2308
|
+
h.ready = Promise.resolve({}) // 跳过真实握手
|
|
2309
|
+
h.child = { stdin: { write: () => {} }, exitCode: null }
|
|
2310
|
+
return h
|
|
2311
|
+
}
|
|
2312
|
+
const frame = (id, text, stop) => JSON.stringify({ jsonrpc: '2.0', id, result: { stopReason: stop, _text: text } }) + NL
|
|
2313
|
+
// ① 分类:超时 vs 其它
|
|
2314
|
+
const timeoutErr = Object.assign(new Error('ACP 超时:session/prompt'), { code: 'ACP_TIMEOUT' })
|
|
2315
|
+
rows.push({ name: 'isAcpTimeout(tagged)', ok: isAcpTimeout(timeoutErr) === true })
|
|
2316
|
+
rows.push({ name: 'isAcpTimeout(plain message)', ok: isAcpTimeout(new Error('ACP 超时:session/prompt')) === true })
|
|
2317
|
+
rows.push({ name: 'isAcpTimeout(其它错误)', ok: isAcpTimeout(new Error('ACP 进程已退出(code=1)')) === false })
|
|
2318
|
+
// ② keepAlive:超时后迟到帧交给 onLate(正文靠 session/update 累积)
|
|
2319
|
+
{
|
|
2320
|
+
const h = mk(120)
|
|
2321
|
+
let late = null
|
|
2322
|
+
const id = h.nextId
|
|
2323
|
+
let code = ''
|
|
2324
|
+
try { await h.ask('s-late', 'hi', null, { timeoutMs: 120, keepAliveOnTimeout: true, onLate: (r) => { late = r } }) } catch (e) { code = e.code || '' }
|
|
2325
|
+
const keptAfterTimeout = h.pending.has(id)
|
|
2326
|
+
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))
|
|
2327
|
+
h._onData(Buffer.from(frame(id, '', 'end_turn')))
|
|
2328
|
+
rows.push({ name: '超时错误带 code=ACP_TIMEOUT', ok: code === 'ACP_TIMEOUT' })
|
|
2329
|
+
rows.push({ name: '超时后 pending 条目被保留', ok: keptAfterTimeout === true })
|
|
2330
|
+
rows.push({ name: '迟到帧触发 onLate 且正文完整', ok: !!late && late.text === '迟到正文' && late.stopReason === 'end_turn' })
|
|
2331
|
+
rows.push({ name: '迟到后 pending 清空 + sink 摘除', ok: !h.pending.has(id) && !h.inflight.has('s-late') })
|
|
2332
|
+
}
|
|
2333
|
+
// ③ 对照:不 keepAlive 时超时即丢弃,迟到帧不会触发任何回调(旧行为)
|
|
2334
|
+
{
|
|
2335
|
+
const h = mk(120)
|
|
2336
|
+
let late = null
|
|
2337
|
+
const id = h.nextId
|
|
2338
|
+
let code = ''
|
|
2339
|
+
try { await h.ask('s-drop', 'hi', null, { timeoutMs: 120, onLate: (r) => { late = r } }) } catch (e) { code = e.code || '' }
|
|
2340
|
+
h._onData(Buffer.from(frame(id, '', 'end_turn')))
|
|
2341
|
+
rows.push({ name: '未开 keepAlive:超时即丢弃且无回调', ok: code === 'ACP_TIMEOUT' && late === null && !h.pending.has(id) && !h.inflight.has('s-drop') })
|
|
2342
|
+
}
|
|
2343
|
+
console.log('[selftest-acp-timeout] ' + JSON.stringify({
|
|
2344
|
+
rows, failed: rows.filter((r) => !r.ok).map((r) => r.name),
|
|
2345
|
+
promptTimeoutMs: PROMPT_TIMEOUT_MS, taskTimeoutMs: TASK_TIMEOUT_MS,
|
|
2346
|
+
}, null, 1))
|
|
2347
|
+
process.exit(rows.every((r) => r.ok) ? 0 : 1)
|
|
2348
|
+
}
|
|
2225
2349
|
acquireLock()
|
|
2226
2350
|
// 上次进程遗留的运行态任务:本轮一启动就标成 interrupted。
|
|
2227
2351
|
// 否则 runningCount() 永远虚高,`while (runningCount() >= MAX_CONCURRENT_TASKS)` 会把新任务永久挡住。
|
|
@@ -2302,6 +2426,8 @@ async function main() {
|
|
|
2302
2426
|
// ③ 外部改了 state(扫码登记新主人/面板操作)
|
|
2303
2427
|
reloadStateIfChanged()
|
|
2304
2428
|
const res = await getUpdates(wechatToken, state.syncBuf, timeoutMs)
|
|
2429
|
+
const pollCode = res?.errcode ?? res?.ret
|
|
2430
|
+
if (pollCode !== undefined && pollCode !== null && Number(pollCode) !== 0) log('getupdates-rejected', { errcode: pollCode, errmsg: res?.errmsg ?? '' })
|
|
2305
2431
|
pollCount++
|
|
2306
2432
|
if (Number.isFinite(res?.longpolling_timeout_ms) && res.longpolling_timeout_ms > 0) timeoutMs = Math.max(5000, Math.min(res.longpolling_timeout_ms, 120000))
|
|
2307
2433
|
if (typeof res?.get_updates_buf === 'string' && res.get_updates_buf) state.syncBuf = res.get_updates_buf
|
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.8",
|
|
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
|
+
}
|