dsh-deepmoyu 2.7.0 → 2.7.2

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/index.js CHANGED
@@ -24,7 +24,7 @@ const z = hostDeps.z
24
24
  const defineTool = hostDeps.defineTool
25
25
  import { spawn } from 'node:child_process'
26
26
  import { appendFileSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
- import { join, dirname } from 'node:path'
27
+ import { basename, dirname, join } from 'node:path'
28
28
  import { createRequire } from 'node:module'
29
29
  import { AsyncLocalStorage } from 'node:async_hooks'
30
30
  import { createHash, randomUUID } from 'node:crypto'
@@ -106,7 +106,7 @@ import {
106
106
  // resolve 只出**库内**绝对路径,collect 只收 http(s) 或库内文件 —— 不当下载器,不碰任意路径。
107
107
  import {
108
108
  listStickers, collectSticker, uploadSticker, forgetSticker, resolveSticker, collectedDirOf, readStickerImage, approveSticker, rejectSticker,
109
- snapshotStickerForSend,
109
+ snapshotStickerForSend, STICKER_MAX,
110
110
  sniffImageFormat,
111
111
  } from './lib/sticker.mjs'
112
112
  import { STICKER_DEFAULTS, STICKER_LIMITS, clampStickerSettings, stickerSettings, createStickerAutomation } from './lib/sticker-automation.mjs'
@@ -128,6 +128,8 @@ import {
128
128
  detectInstallMode, summarizeUpgrade, upgradeBlocker, upgradeCommand, upgradeLogLine,
129
129
  } from './lib/update.mjs'
130
130
  import { buildSevenDayRetrospective, buildRecoverySnapshot } from './lib/product-status.mjs'
131
+ // 🧬 宿主兼容性自检:patch 里写了宿主不注册的 {{变量}} 会在每轮组装提示词时 throw(2026-11-10 事故)
132
+ import { promptVariableWarnings } from './lib/host-compat.mjs'
131
133
 
132
134
  export const name = 'dafeiyu'
133
135
  export const inject = ['webServer', 'tools', 'settings']
@@ -383,10 +385,14 @@ export function apply(ctx, config) {
383
385
  try { const j = readJson(overrideFile, {}); return j && typeof j === 'object' ? j : {} } catch { return {} }
384
386
  }
385
387
  const markOverride = (keys, on) => {
388
+ // 审计 P3-2(2026-11-12):以前裸 read+writeFileSync——并发保存互相覆盖 mark、崩溃留半行
389
+ // JSON(读侧容错回 {} ⇒ 本地优先全量失效)。改走与其它配置写一致的「锁内读改 + 原子写」。
386
390
  try {
387
- const cur = readOverride()
388
- for (const k of keys) { if (on) cur[k] = Date.now(); else delete cur[k] }
389
- writeFileSync(overrideFile, JSON.stringify(cur, null, 2), 'utf8')
391
+ withFileLockSync(overrideFile, () => {
392
+ const cur = readOverride()
393
+ for (const k of keys) { if (on) cur[k] = Date.now(); else delete cur[k] }
394
+ atomicWriteFileSync(overrideFile, JSON.stringify(cur, null, 2), 'utf8')
395
+ })
390
396
  } catch (e) { log('记 local-override 失败:' + (e && e.message ? e.message : e)) }
391
397
  }
392
398
  const logFile = join(stateDir, 'duty.log')
@@ -402,6 +408,12 @@ export function apply(ctx, config) {
402
408
  // 宿主依赖是从哪儿取的(桌面版那次事故的排查看这里:duty.log 里会写明白)
403
409
  for (const n of hostDeps.notes) log(n)
404
410
 
411
+ /* ── 宿主兼容性自检:patch 里写了宿主不认识的 {{变量}} 就先喊一嗓子 ──
412
+ * 2026-11-10 的 `{{cwd}}` 事故就是这么坏的:错在 persona 段里,炸在每轮组装提示词的时候,
413
+ * 插件加载看着一切正常,一选模式就 "unknown prompt variable"。这个自检把它提前到启动时。
414
+ * ⚠️ 只写日志,绝不抛错、绝不阻断加载 —— 自检本身不能变成新的风险。 */
415
+ for (const line of promptVariableWarnings(pluginDir)) log(line)
416
+
405
417
  /**
406
418
  * ⭐ 事件监听的统一挂法(2026-10-26):`ctx.on` 返回的注销函数(cordis 惯例)收进 ctx.effect,
407
419
  * 插件热重载/dispose 时把监听一起摘掉 —— 否则旧闭包(拿着旧 mcp、旧 stateDir)越积越多。
@@ -789,7 +801,11 @@ export function apply(ctx, config) {
789
801
  for (const k of Object.keys(DEFAULTS)) {
790
802
  if (touched.has(k)) { out[k] = saved[k]; continue } // 我们这边管 ⇒ 不再被宿主压回
791
803
  if (user[k] !== undefined) { out[k] = user[k]; continue } // 用户在 DSH 原生页改的 ⇒ 听他的
792
- out[k] = saved[k]
804
+ // 审计 P3-1(2026-11-12):这枝原来是 `out[k] = saved[k]`——把 776 行注释描述的
805
+ // 「describe().value 当基准」整个变成死代码(value 从未幸存)。现在按注释本意:
806
+ // 两边都没显式设过的键,宿主现值(value,含 patch 层解析结果)优先,宿主没给才退回本地 config。
807
+ const hostValue = row.value && typeof row.value === 'object' ? row.value[k] : undefined
808
+ out[k] = hostValue !== undefined ? hostValue : saved[k]
793
809
  }
794
810
  for (const k of touched) {
795
811
  if (k in DEFAULTS && user[k] !== undefined && JSON.stringify(user[k]) !== JSON.stringify(out[k])) diff.push(k)
@@ -2104,6 +2120,13 @@ export function apply(ctx, config) {
2104
2120
  if (hit.hit) {
2105
2121
  return { ok: false, blocked: true, alreadyReplied: true, hint: dupHint('这条动态的这层', hit.at) }
2106
2122
  }
2123
+ } else {
2124
+ // 审计 P3-7:动态新评论现在 JS 层也有账(`opus-new:<oid>`)——发之前先查,
2125
+ // 别等 Python 守卫拦了才知道"这条已经发过"。
2126
+ const hit = dupLookup(repliedCommentsMap(), 'opus-new:' + opusId)
2127
+ if (hit.hit) {
2128
+ return { ok: false, blocked: true, alreadyReplied: true, hint: dupHint('这条动态', hit.at) + '(动态下面只发一条新评论;想接别人的话用 bili_reply_opus 带上他的 rpid)' }
2129
+ }
2107
2130
  }
2108
2131
 
2109
2132
  // ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条):回动态评论也不能连着点
@@ -2113,20 +2136,45 @@ export function apply(ctx, config) {
2113
2136
  const r = await call('bili_reply_opus', { opus_id: opusId, text, rpid, root })
2114
2137
  const o = typeof r === 'object' && r ? r : {}
2115
2138
  if (o.success || o.ok) {
2116
- await paceDone('opusReply')
2139
+ const warnings = []
2140
+ const paced = await safeBookkeep('paceDone(opusReply)', () => paceDone('opusReply'))
2141
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2142
+ const opusRpid = readableId(o.rpid_str ?? o.rpid ?? (isReply ? rpid : '0'))
2117
2143
  if (isReply) {
2118
2144
  const key = keyOpusReply(rpid, root, opusId)
2119
- await withStateLock(() => {
2145
+ const saved = await safeBookkeep('opusReply判重', () => withStateLock(() => {
2120
2146
  const st = readJson(dutyStateFile, {})
2121
2147
  st.repliedComments = dupRemember(st.repliedComments, key, { opus_id: opusId, rpid, at: new Date().toISOString() })
2122
2148
  st.lastReplyAt = new Date().toISOString()
2123
2149
  writeJson(dutyStateFile, st)
2124
- })
2150
+ }))
2151
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2152
+ } else {
2153
+ /* 审计 P3-7(2026-11-12):动态**新评论**以前只记在 Python 守卫层(`opus-new:<oid>`),
2154
+ * JS 的 repliedComments/口味本子/跨通道扫描全无记录——两本账口径漂移:JS 无法在
2155
+ * 调用前主动告知"这条动态已发过",templateCheck 对动态新评论历史全盲。
2156
+ * 现在与视频通道对齐:成功后记 `opus-new:<oid>`,正文进口味本子。 */
2157
+ const saved = await safeBookkeep('opus新评论判重', () => withStateLock(() => {
2158
+ const st = readJson(dutyStateFile, {})
2159
+ st.repliedComments = dupRemember(st.repliedComments, 'opus-new:' + opusId, {
2160
+ opus_id: opusId, rpid: opusRpid, at: new Date().toISOString(),
2161
+ })
2162
+ st.lastReplyAt = new Date().toISOString()
2163
+ // 口味本子的键必须是纯数字 rpid(lib/taste.mjs 的 rpidKey 会拒掉别的形状)——
2164
+ // 用新评论自己的 rpid,点赞通知的 item_id 才对得上;拿不到 rpid 就不记口味。
2165
+ if (/^\d+$/.test(opusRpid) && opusRpid !== '0') {
2166
+ st.taste = tasteRemember(st.taste, opusRpid, {
2167
+ bvid: 'opus-' + opusId, text, at: new Date().toISOString(), likes: 0,
2168
+ })
2169
+ }
2170
+ writeJson(dutyStateFile, st)
2171
+ }))
2172
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2125
2173
  }
2126
- return { ok: true, success: true, opus_id: opusId, rpid: readableId(o.rpid_str ?? o.rpid ?? '0') }
2174
+ return bookWarnings({ ok: true, success: true, opus_id: opusId, rpid: opusRpid }, warnings)
2127
2175
  }
2128
2176
  // 失败里认得出风控码 ⇒ 记整体冷却,并把"歇多久"写进回执(模型看得见就不会硬试)
2129
- const risk = paceRiskOf(o)
2177
+ const risk = await paceRiskOf(o)
2130
2178
  return { ok: false, error: String(o.error || '发送失败(原因未知)'), ...(risk ? { riskNote: risk } : {}) }
2131
2179
  },
2132
2180
  })
@@ -2222,7 +2270,7 @@ export function apply(ctx, config) {
2222
2270
  parameters: {
2223
2271
  uid: { type: 'string', required: true, description: '发给谁(对方 UID)' },
2224
2272
  text: { type: 'string', required: true, description: '私信正文(短、像人说话、按人格;别粘链接、别写手机号)' },
2225
- msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿;填了才判重,**建议一定要填**)' },
2273
+ msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿)。**建议一定要填**:填了才做「这条回过没」判重与收件箱复核;同一个人每天最多回 3 条的防刷屏闸与它无关,一直都在' },
2226
2274
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
2227
2275
  },
2228
2276
  async execute(args) {
@@ -2260,7 +2308,12 @@ export function apply(ctx, config) {
2260
2308
  * DM_PER_USER_SOFT 条以后,工具层直接打回(本人当场要求可 force)。
2261
2309
  * `sent_today` 提示过但不管用 —— 能被机器保证的,就别交给记性(同 pace.mjs 的教训)。 */
2262
2310
  const used = dmSentToday(uid)
2263
- if (!args.force && seqno !== '0' && used >= DM_PER_USER_SOFT) {
2311
+ /* 审计 P1-1(2026-11-12):软上限以前要求 `seqno !== '0'`——而 msg_seqno 是可选参数,
2312
+ * 省略它(缺省 '0')即可绕过对同一个人的防刷屏刹车(dmPerUser 计数照加却从不被检查)。
2313
+ * 「能被机器保证的,就别交给记性」:现在对一切发送生效,与 seqno 无关。
2314
+ * 判重(2253)与闸门四(收件箱事实复核)仍只在 seqno 真填了时生效——没有 seqno 就没有
2315
+ * 可复核的事实对象,那两道本来就是「回复特定一条」的闸。 */
2316
+ if (!args.force && used >= DM_PER_USER_SOFT) {
2264
2317
  return {
2265
2318
  ok: false, blocked: true, perUserCap: true, sentToday: used,
2266
2319
  hint: `今天已经回过这个人 ${used} 条了 —— 再连着回就成刷屏了。等他有新消息再接,`
@@ -2301,12 +2354,14 @@ export function apply(ctx, config) {
2301
2354
  const r = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: seqno })
2302
2355
  const o = typeof r === 'object' && r ? r : {}
2303
2356
  if (o.success === false || o.error || !(o.success === true || o.ok === true)) {
2304
- const risk = paceRiskOf(o)
2357
+ const risk = await paceRiskOf(o)
2305
2358
  return { ok: false, unknown: o.success !== false && !o.error, error: String(o.error || o.message || '发送结果未知'), raw: o, ...(risk ? { riskNote: risk } : {}) }
2306
2359
  }
2307
- await paceDone('dm')
2360
+ const warnings = []
2361
+ const paced = await safeBookkeep('paceDone(dm)', () => paceDone('dm'))
2362
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2308
2363
  // 记账本:判重 + 当日额度(**发成功之后才记** —— 失败了记上就再也回不了)
2309
- await withStateLock(() => {
2364
+ const saved = await safeBookkeep('dm判重', () => withStateLock(() => {
2310
2365
  const st = readJson(dutyStateFile, {})
2311
2366
  const today = dayKey()
2312
2367
  const crossDay = st.day !== today
@@ -2326,8 +2381,9 @@ export function apply(ctx, config) {
2326
2381
  dmPerUser: perUser,
2327
2382
  lastDmAt: new Date().toISOString(),
2328
2383
  })
2329
- })
2330
- return { ok: true, success: true, uid, sentToday: used + 1 }
2384
+ }))
2385
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2386
+ return bookWarnings({ ok: true, success: true, uid, sentToday: used + 1 }, warnings)
2331
2387
  },
2332
2388
  })
2333
2389
 
@@ -2363,8 +2419,8 @@ export function apply(ctx, config) {
2363
2419
  const state = readJsonFileSync(dutyStateFile, {})
2364
2420
  writeJson(dutyStateFile, { ...state, dmDeliveries: { ...(state.dmDeliveries || {}), [key]: { state: success ? 'sent' : 'unknown', uid, at: new Date().toISOString() } } })
2365
2421
  })
2366
- if (success) await paceDone('dm')
2367
- else paceRiskOf(result)
2422
+ if (success) await safeBookkeep('paceDone(deliver)', () => paceDone('dm'))
2423
+ else await safeBookkeep('paceRiskOf(deliver)', () => paceRiskOf(result))
2368
2424
  return success ? { ok: true, success: true, uid } : { ok: false, unknown: true, error: String(result?.error || '成果私信结果未知,请到 B站核对后再决定') }
2369
2425
  },
2370
2426
  })
@@ -2424,7 +2480,7 @@ export function apply(ctx, config) {
2424
2480
  description: '🐟 鲸鱼摸鱼自己的表情包库。回复评论想配图时:先 list 挑一张,再在 bili_reply 里传 picture:"名字"。\n'
2425
2481
  + '⭐ collect = 自己收集:在评论区(bili_comments 返回的 images)或别处看到合适的鲸鱼娘表情,'
2426
2482
  + '把图床链接收进来,起个好叫的名字(如 name:"贴贴");B站默认小表情不用收 —— [doge] 这类码直接写在回复正文里。\n'
2427
- + '⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(60 张)先 forget 再收。\n'
2483
+ + `⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(上限 ${STICKER_MAX} 张,见 stickerCapacity)先 forget 再收。\n`
2428
2484
  + 'picture 参数只在 bili_reply **发新评论**时能带(回复楼层不支持图片)。自动采集和自动配图按设置概率实际执行;'
2429
2485
  + '你也能主动 collect、补上 description/tags 并显式选图。开启人工审核时新图先待审,只有使用者在表情收藏页批准后才能用;关闭审核也不会放行旧待审图。自动设置不授予评论权限。',
2430
2486
  parameters: {
@@ -2740,22 +2796,27 @@ export function apply(ctx, config) {
2740
2796
  }
2741
2797
  if (autoPicture && out && typeof out === 'object') out = { ...out, auto_picture: autoPicture.name }
2742
2798
  // 发成功才记节奏(失败也记会把后面本来能发的推后)
2743
- if (sendOk(out)) await paceDone(isReply ? 'reply' : 'comment')
2799
+ const warnings = []
2800
+ if (sendOk(out)) {
2801
+ const paced = await safeBookkeep('paceDone', () => paceDone(isReply ? 'reply' : 'comment'))
2802
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
2803
+ }
2744
2804
  // 失败里认得出风控码 ⇒ 记整体冷却,回执里带一句"歇多久"
2745
- const riskNote = out && (out.error || out.success === false) ? paceRiskOf(out) : ''
2805
+ const riskNote = out && (out.error || out.success === false) ? await paceRiskOf(out) : ''
2746
2806
  /* 🎬 视频判重(2026-10-21):**发评论这一刻**就把这个 BV 记下来 ——
2747
2807
  * 不看模型有没有"记得",也不看它有没有往 seen.md 里写。
2748
2808
  * 为什么记在这里:能发出评论,说明这条视频它真的看过并处理完了(挑中 ⇒ 读过 ⇒ 评论),
2749
2809
  * 这是全流程里最靠得住的一个"我处理过它"的信号。
2750
2810
  * ⚠️ 只记新评论和回复都算(回复说明它回头又看了一遍,只可能更该记)。 */
2751
2811
  if (sendOk(out) && isBvid(bvidFinal)) {
2752
- await seenSave((current) => seenRemember(current, bvidFinal, {
2812
+ const saved = await safeBookkeep('视频判重', () => seenSave((current) => seenRemember(current, bvidFinal, {
2753
2813
  note: isReply ? '回过这里的评论' : String(args.text ?? '').slice(0, 100),
2754
- }))
2814
+ })))
2815
+ if (saved?.bookkeepError) warnings.push('seen: ' + saved.bookkeepError)
2755
2816
  }
2756
2817
  // 发成功之后才记账(失败也记上就再也回不了了)—— 钥匙是对方那条评论的 rpid
2757
2818
  if (isReply && sendOk(out)) {
2758
- await withStateLock(() => {
2819
+ const saved = await safeBookkeep('回复判重', () => withStateLock(() => {
2759
2820
  const st = readJson(dutyStateFile, {})
2760
2821
  st.repliedComments = dupRemember(st.repliedComments, keyVideoReply(rpid), {
2761
2822
  bvid,
@@ -2764,14 +2825,15 @@ export function apply(ctx, config) {
2764
2825
  })
2765
2826
  st.lastReplyAt = new Date().toISOString()
2766
2827
  writeJson(dutyStateFile, st)
2767
- })
2828
+ }))
2829
+ if (saved?.bookkeepError) warnings.push('dup: ' + saved.bookkeepError)
2768
2830
  }
2769
2831
  /* 🎬 发新评论成功 ⇒ 记 `nu:<BV号>`("这个视频我评论过了")——
2770
2832
  * 和上面的视频判重账本(seenVideos)是两件事:
2771
2833
  * · `seenVideos` 管"这个视频别再挑我了"(看过就记,不管评没评)
2772
2834
  * · `repliedComments['nu:BV..']` 管"这个视频只发一条新评论"(下轮回复别人不受影响) */
2773
2835
  if (!isReply && sendOk(out) && isBvid(bvidFinal)) {
2774
- await withStateLock(() => {
2836
+ const saved = await safeBookkeep('新评论判重', () => withStateLock(() => {
2775
2837
  const st = readJson(dutyStateFile, {})
2776
2838
  st.repliedComments = dupRemember(st.repliedComments, keyNewComment(bvidFinal), {
2777
2839
  bvid: bvidFinal,
@@ -2779,7 +2841,8 @@ export function apply(ctx, config) {
2779
2841
  at: new Date().toISOString(),
2780
2842
  })
2781
2843
  writeJson(dutyStateFile, st)
2782
- })
2844
+ }))
2845
+ if (saved?.bookkeepError) warnings.push('nu: ' + saved.bookkeepError)
2783
2846
  }
2784
2847
  /* 🧠 新评论同时记进口味本子(2026-10-20):记的是**我们自己**这条的 rpid ——
2785
2848
  * 以后收到点赞通知时 item_id 就是它,靠它把"发过什么"和"被赞多少"对上。
@@ -2788,17 +2851,19 @@ export function apply(ctx, config) {
2788
2851
  if (!isReply && sendOk(out)) {
2789
2852
  const myRpid = readableId(out.rpid_str ?? out.rpid)
2790
2853
  if (myRpid && myRpid !== '0') {
2791
- await withStateLock(() => {
2854
+ const saved = await safeBookkeep('口味本子', () => withStateLock(() => {
2792
2855
  const st = readJson(dutyStateFile, {})
2793
2856
  st.taste = tasteRemember(st.taste, myRpid, {
2794
2857
  bvid, text: String(args.text ?? ''), at: new Date().toISOString(), likes: 0,
2795
2858
  })
2796
2859
  writeJson(dutyStateFile, st)
2797
- })
2860
+ }))
2861
+ if (saved?.bookkeepError) warnings.push('taste: ' + saved.bookkeepError)
2798
2862
  }
2799
2863
  }
2800
2864
  /* 被风控了就把"歇多久"捎回去 —— 模型看得见这句才不会下一轮又去试。
2801
2865
  ⚠️ 只多挂一个 riskNote,别覆盖 MCP 原来的字段(success/rpid/error 那些下游还要用)。 */
2866
+ out = bookWarnings(out, warnings)
2802
2867
  if (riskNote && out && typeof out === 'object') return { ...out, riskNote }
2803
2868
  return out
2804
2869
  },
@@ -2963,7 +3028,19 @@ export function apply(ctx, config) {
2963
3028
  + '适合"我现在就想看一份"的场合。\n'
2964
3029
  + '⚠️ 这是**老的后台刷法**(另起进程、当前对话里看不见)。要"在当前对话里一直刷"用 `bili_feiyu`。',
2965
3030
  parameters: {},
2966
- async execute() { const r = runDuty('manual'); return { ok: r.started === true, ...r } },
3031
+ async execute() {
3032
+ /* 审计 P2-4(2026-11-12):免跑牌(本时段不再自动拉起)以前只在调度器里生效,
3033
+ * 模型一轮对话就能用 bili_run 把用户刚手动停掉的时段任务整轮重启。手动停止的
3034
+ * 承诺优先于模型的「立刻跑一轮」——被牌挡住时明确说明谁来解。 */
3035
+ try {
3036
+ const hold = Number(readJson(dutyStateFile, {}).bgWindowHold) || 0
3037
+ if (hold > Date.now()) {
3038
+ return { ok: false, blocked: true, hint: `使用者手动停止了本时段的后台任务(到 ${new Date(hold).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 前不再自动拉起)。想现在跑一轮,请让本人在工作台点「开始」或等到时段结束。` }
3039
+ }
3040
+ } catch { /* 读不到状态就不拦(与调度器的宽松度一致) */ }
3041
+ const r = runDuty('manual')
3042
+ return { ok: r.started === true, ...r }
3043
+ },
2967
3044
  })
2968
3045
 
2969
3046
  /* ⭐ 鲸鱼摸鱼模式:对话启动 ⇒ 用当前对话模型一直刷(宿主 goal 机制自动续轮) */
@@ -3043,7 +3120,7 @@ export function apply(ctx, config) {
3043
3120
  + '想提视频就一句带过,长度跟着念头走。**没有想说的就不发,配额不是催办。**',
3044
3121
  parameters: {
3045
3122
  text: { type: 'string', description: '正文(300 字以内,纯文本)' },
3046
- images: { type: 'array', items: { type: 'string' }, description: '(可选)图片地址或本地路径,最多 9 张' },
3123
+ images: { type: 'array', items: { type: 'string' }, description: '(可选)**已审核表情库里的图名**(bili_sticker list 可见),最多 9 张。不接受本地路径或网址——先 collect 收进图库并过审,或省略发纯文字。' },
3047
3124
  force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
3048
3125
  },
3049
3126
  async execute(args) {
@@ -3084,6 +3161,10 @@ export function apply(ctx, config) {
3084
3161
  * 都读到 used=cap-1、都过闸、都发送 ⇒ 一天限额被突破。
3085
3162
  * 现在非 force 先在锁内原子地"数一遍 + 占一个",超了立刻退回并拒绝;
3086
3163
  * 发送失败也退回。force 不占额(它本来就越闸,发送成功后才补记)。 */
3164
+ // 输入形状先校验再占名额(2026-11-12 补漏:以前超 9 张在占名额之后才 return,
3165
+ // 名额被白烧一个 —— 预占后的一切 return 路径都必须退额,见下方 catch / 发送失败处的同一约束)
3166
+ const rawImages = Array.isArray(args?.images) ? args.images.map((item) => String(item ?? '').trim()).filter(Boolean) : []
3167
+ if (rawImages.length > 9) return { ok: false, blocked: true, error: '动态最多 9 张附图' }
3087
3168
  let reserved = 0
3088
3169
  if (args?.force !== true) {
3089
3170
  reserved = await bumpMomentBy(1)
@@ -3093,14 +3174,53 @@ export function apply(ctx, config) {
3093
3174
  }
3094
3175
  }
3095
3176
 
3177
+ /* 🔒 审计 P0-1(2026-11-12):以前这里把模型给的 images 原样透传给 MCP——
3178
+ * 非 http 项会被 Python `Picture.from_file` 读**任意本地文件**并作为动态配图公开发布,
3179
+ * http 项被无校验地服务端抓取(本机文件外泄通道,sendReview 默认关时零闸)。
3180
+ * 现在与评论配图同一待遇:只认①已审核表情库的图名(快照出字节再给 MCP)
3181
+ * ②发送审核产出的 send-review-media 哈希文件(批准重放路径 3356-3364 回传的就是它,
3182
+ * 文件名即 SHA-256,逐字节复验)。其余一律拒绝。sendReview 开着时
3183
+ * queueSendReview(3287-3302)已先做同一件事,这里管的是直发路径。 */
3184
+ const imageShots = []
3185
+ const resolvedImages = []
3186
+ try {
3187
+ for (const entry of rawImages) {
3188
+ const approvedFile = /^([0-9a-f]{64})\.(png|jpe?g|gif|webp)$/i.exec(basename(entry))
3189
+ const inReviewMedia = approvedFile && dirname(entry) === join(stateDir, 'send-review-media')
3190
+ if (inReviewMedia) {
3191
+ // 服务端自有的已批准快照:文件名即摘要,读字节复验后才认。
3192
+ const file = join(stateDir, 'send-review-media', basename(entry))
3193
+ let bytes
3194
+ try { bytes = readFileSync(file) } catch { throw new Error(`预览附图已丢失,未发送:${basename(entry)}`) }
3195
+ if (createHash('sha256').update(bytes).digest('hex') !== approvedFile[1].toLowerCase()) throw new Error(`预览附图校验失败,未发送:${basename(entry)}`)
3196
+ resolvedImages.push(file)
3197
+ continue
3198
+ }
3199
+ const shot = snapshotStickerForSend(stateDir, entry)
3200
+ if (!shot) throw new Error(`附图只接受已审核表情库里的图名(不接受本地路径或网址):${entry}。先 bili_sticker collect 收进图库并过审,或省略 images 发纯文字。`)
3201
+ imageShots.push(shot)
3202
+ resolvedImages.push(shot.file)
3203
+ }
3204
+ } catch (error) {
3205
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3206
+ // 2026-11-12 补漏:图片校验失败的 return 也必须退预占名额(P3-6 同一原则——
3207
+ // 「审核已提交(结果未知)」之外的失败路径都不该烧掉当天份额)。
3208
+ if (reserved > 0) await bumpMomentBy(-1)
3209
+ return { ok: false, blocked: true, error: String(error?.message ?? error) }
3210
+ }
3211
+
3096
3212
  let r
3097
- try { r = await call('bili_send_dynamic', { text, images: Array.isArray(args?.images) ? args.images : [] }) }
3213
+ try { r = await call('bili_send_dynamic', { text, images: resolvedImages }) }
3098
3214
  catch (error) {
3099
- // Cancellation before submission did not spend this reservation.
3100
3215
  const reviewed = approvedReviewExecutions.get(toolExecution.getStore())
3101
- if (reserved > 0 && ((toolExecution.getStore()?.signal?.aborted && error?.name === 'AbortError') || reviewed && !reviewed.submitted)) await bumpMomentBy(-1)
3216
+ // 审计 P3-6:以前只有「中止 / 审核未提交」才退预占名额,MCP 未就绪这类普通异常
3217
+ // 会把当天的动态配额白白烧掉。现在除「审核已提交(结果未知,按已花费记)」外一律退回——
3218
+ // 同文重发会被守卫的判重键拦住,不会真的多发。
3219
+ if (reserved > 0 && !(reviewed && reviewed.submitted)) await bumpMomentBy(-1)
3220
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3102
3221
  throw error
3103
3222
  }
3223
+ for (const shot of imageShots) { try { shot.cleanup() } catch { /* 临时文件 */ } }
3104
3224
  let ok = false
3105
3225
  try { const o = typeof r === 'string' ? JSON.parse(r) : r; ok = o?.success === true } catch { ok = false }
3106
3226
  if (!ok) {
@@ -3108,15 +3228,18 @@ export function apply(ctx, config) {
3108
3228
  // 发说说也会撞风控(-352 最常见)—— 认出码就记冷却
3109
3229
  let parsed = null
3110
3230
  try { parsed = typeof r === 'string' ? JSON.parse(r) : r } catch { parsed = null }
3111
- const risk = paceRiskOf(parsed || { error: typeof r === 'string' ? r.slice(0, 300) : '' })
3231
+ const risk = await paceRiskOf(parsed || { error: typeof r === 'string' ? r.slice(0, 300) : '' })
3112
3232
  return { ok: false, error: '发说说失败', raw: typeof r === 'string' ? r.slice(0, 800) : r, ...(risk ? { riskNote: risk } : {}) }
3113
3233
  }
3114
- await paceDone('moment')
3234
+ const warnings = []
3235
+ const paced = await safeBookkeep('paceDone(moment)', () => paceDone('moment'))
3236
+ if (paced?.bookkeepError) warnings.push('pace: ' + paced.bookkeepError)
3115
3237
  const n = reserved > 0 ? reserved : await bumpMoment()
3116
3238
  // 🪞 记下这次的开口(失败的不记)—— 下一轮实时信息里会把它摆给模型看
3117
- await rememberMomentOpening(text)
3239
+ const opening = await safeBookkeep('开口记录', () => rememberMomentOpening(text))
3240
+ if (opening?.bookkeepError) warnings.push('opening: ' + opening.bookkeepError)
3118
3241
  log(`🐟 发了一条说说(今天 ${n}/${cap}):${text.slice(0, 40)}`)
3119
- return { ok: true, momentsToday: n, cap, text }
3242
+ return bookWarnings({ ok: true, momentsToday: n, cap, text }, warnings)
3120
3243
  },
3121
3244
  })
3122
3245
 
@@ -3228,6 +3351,20 @@ export function apply(ctx, config) {
3228
3351
  if (!state || typeof state !== 'object' || Array.isArray(state) || state.sendReview !== undefined && !Array.isArray(state.sendReview)) throw new Error('发送预览记录损坏,已停止操作以保留原件')
3229
3352
  return state
3230
3353
  }
3354
+ /* 审计 P2-3(2026-11-12):批准把行置为 'approving',正常收尾会落到 sent/failed/unknown;
3355
+ * 进程在两态之间崩溃/被杀时该行永远停在 approving——不可批准、不可拒绝、不可清除、
3356
+ * 还占着 80 条队列配额,攒满后整条发送预览通道瘫痪(不可自愈)。
3357
+ * 收敛规则:凡「approving 但不在本进程 reviewSending 集合」的行落盘为 unknown
3358
+ * (结果未知语义不变,但恢复为可管理状态:unknown 行允许人工「确认未发出」转 rejected 释放配额)。
3359
+ * reviewSending 只在批准后存在且进程内,天然区分「正在发」与「残留」。
3360
+ * ⚠️ 只在**已持 state 锁的写路径**(queueSendReview / decideSendReview)里落盘收敛——
3361
+ * 读侧(reviewBook)保持纯映射,避免无锁写与事务写竞争。 */
3362
+ const convergeReviewRows = (rows) => {
3363
+ if (!Array.isArray(rows) || !rows.some((row) => row && row.state === 'approving' && !reviewSending.has(row.id))) return rows
3364
+ return rows.map((row) => row && row.state === 'approving' && !reviewSending.has(row.id)
3365
+ ? { ...row, state: 'unknown', updatedAt: new Date().toISOString(), note: '审批中断(进程重启),结果未知' }
3366
+ : row)
3367
+ }
3231
3368
  const reviewBook = () => {
3232
3369
  const state = readReviewState()
3233
3370
  const rows = Array.isArray(state.sendReview) ? state.sendReview : []
@@ -3296,7 +3433,7 @@ export function apply(ctx, config) {
3296
3433
  row.hash = reviewHash(row)
3297
3434
  const result = await withStateLock(() => {
3298
3435
  const state = readReviewState()
3299
- const rows = Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : []
3436
+ const rows = convergeReviewRows(Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : [])
3300
3437
  exec?.signal?.throwIfAborted()
3301
3438
  const duplicate = rows.find((item) => ['pending', 'approving', 'unknown', 'sent'].includes(item.state) && item.hash === row.hash)
3302
3439
  if (duplicate) return { row: duplicate, duplicate: true }
@@ -3324,11 +3461,18 @@ export function apply(ctx, config) {
3324
3461
  let target = null
3325
3462
  const claimed = await withStateLock(() => {
3326
3463
  const state = readReviewState()
3327
- const rows = Array.isArray(state.sendReview) ? state.sendReview : []
3464
+ const rows = convergeReviewRows(Array.isArray(state.sendReview) ? state.sendReview : [])
3328
3465
  const index = rows.findIndex((item) => item?.id === key)
3329
3466
  if (index < 0) return { ok: false, error: '找不到这条待审核内容' }
3330
3467
  const row = rows[index]
3331
3468
  if (row.hash !== expectedHash || reviewHash(row) !== expectedHash) return { ok: false, error: '预览内容已变化,请重新查看后决定' }
3469
+ if (row.state === 'unknown' && decision === 'reject') {
3470
+ // 审计 P2-3:unknown 行允许人工「确认未发出」→ rejected,释放队列配额。
3471
+ // 仍不允许 approve(结果未知的内容重发有双发风险,必须重新生成预览)。
3472
+ rows[index] = { ...row, state: 'rejected', updatedAt: new Date().toISOString(), decidedAt: new Date().toISOString(), note: '人工确认未发出(unknown 结案)' }
3473
+ writeJson(dutyStateFile, { ...state, sendReview: rows })
3474
+ return { ok: true, item: { ...row, state: 'rejected' } }
3475
+ }
3332
3476
  if (row.state !== 'pending') return { ok: false, already: true, item: row }
3333
3477
  if (decision === 'approve' && (!Number.isFinite(Date.parse(row.createdAt)) || Date.now() - Date.parse(row.createdAt) > 24 * 3600_000)) return { ok: false, error: '这条预览已超过 24 小时,请拒绝旧内容并重新生成,避免发送过时回复' }
3334
3478
  target = { ...row, args: reviewArgs(row.args) }
@@ -3397,11 +3541,8 @@ export function apply(ctx, config) {
3397
3541
  const st = readJson(dutyStateFile, {})
3398
3542
  return paceBook(st.pace)
3399
3543
  }
3400
- const paceWrite = (book) => withStateLock(() => {
3401
- const st = readJson(dutyStateFile, {})
3402
- writeJson(dutyStateFile, { ...st, pace: book })
3403
- return book
3404
- })
3544
+ // paceWrite 已随审计 P2-2 移除:整键覆盖式的锁外写正是那条竞态的根源,
3545
+ // 现在 pace 的写路径只有「锁内读-算-写」一种(paceRisk / paceTake 各自锁内完成)。
3405
3546
  /** 节奏系数的来源:使用者能整体调快/调慢(0.5 = 快一倍,2 = 慢一倍),默认 1 */
3406
3547
  const paceFactor = () => {
3407
3548
  const n = Number(cfg.paceFactor)
@@ -3460,10 +3601,19 @@ export function apply(ctx, config) {
3460
3601
  * ⚠️ 只在**明确的频率类风控码**上冷却(表在 lib/pace.mjs 的 RISK_COOLDOWN):
3461
3602
  * -101 没登录 / -403 没权限这类停着也不会好,该让它把话说清楚,而不是傻等。
3462
3603
  */
3463
- const paceRisk = (code) => {
3464
- const r = paceNoteRisk(paceRead(), code, Date.now())
3465
- if (!r.cooled) return ''
3466
- paceWrite(r.book)
3604
+ const paceRisk = async (code) => {
3605
+ /* 审计 P2-2(2026-11-12):以前锁外读快照整键覆盖 + fire-and-forget 写入——两次并发
3606
+ * 撞码时,后落盘的较短冷却(-352=20min)会盖掉先落盘的较长冷却(-412=60min),
3607
+ * 期间其他进程 paceTake 新增的连发记录也会被回滚。现在读-算-写一次在锁内完成。 */
3608
+ let r = null
3609
+ try {
3610
+ await withStateLock(() => {
3611
+ const st = readJson(dutyStateFile, {})
3612
+ r = paceNoteRisk(paceBook(st.pace), code, Date.now())
3613
+ if (r.cooled) writeJson(dutyStateFile, { ...st, pace: r.book })
3614
+ })
3615
+ } catch (error) { log(`⚠️ 记风控冷却失败(不拦本次回执):${error?.message ?? error}`); return '' }
3616
+ if (!r || !r.cooled) return ''
3467
3617
  const min = Math.ceil(r.waitMs / 60_000)
3468
3618
  if (r.quota) {
3469
3619
  const what = r.bucket === 'comment' ? '评论' : r.bucket
@@ -3482,7 +3632,7 @@ export function apply(ctx, config) {
3482
3632
  * 判据:`code` 字段(MCP 侧成功失败都会带)+ 文本里出现的 -352/-412/-509/-799/+12014。
3483
3633
  * ⚠️ 12014 走的是**按桶停牌**,不是全局冷却(见 lib/pace.mjs 的 QUOTA_CODES)。
3484
3634
  */
3485
- const paceRiskOf = (resp) => {
3635
+ const paceRiskOf = async (resp) => {
3486
3636
  const o = typeof resp === 'object' && resp ? resp : {}
3487
3637
  const raw = o.code ?? o.error_code ?? ''
3488
3638
  const codes = []
@@ -3490,7 +3640,7 @@ export function apply(ctx, config) {
3490
3640
  const text = `${o.error ?? ''} ${o.message ?? ''} ${o.hint ?? ''}`
3491
3641
  for (const m of text.matchAll(/(?:^|[^\d-])(12014|-352|-412|-509|-799)\b/g)) codes.push(m[1])
3492
3642
  for (const c of codes) {
3493
- if (QUOTA_CODES[c] || RISK_COOLDOWN[c]) return paceRisk(c)
3643
+ if (QUOTA_CODES[c] || RISK_COOLDOWN[c]) return await paceRisk(c)
3494
3644
  }
3495
3645
  return ''
3496
3646
  }
@@ -3944,7 +4094,16 @@ export function apply(ctx, config) {
3944
4094
  checkedAt: updateCheckedAt || 0,
3945
4095
  })
3946
4096
  const startUpgrade = async () => {
3947
- const report = updateCache.value || await runUpdateCheck()
4097
+ let report = updateCache.value || await runUpdateCheck()
4098
+ /* 审计 P3-14(2026-11-12):updateCache 最长 10 分钟——检查与升级之间安装方式若被
4099
+ * 改变(比如把 registry 安装换成了目录联接),旧报告里的 canUpgrade/profile 就是旧值。
4100
+ * 升级是高风险动作,安装方式在**此刻**重新判一遍(本地、便宜),判定不一致就按新的来。 */
4101
+ const freshInstall = detectInstallMode({ pluginDir, dshHome: String(ctx?.get?.('dshHomePath')?.('') || process.env.DSH_HOME || '') })
4102
+ if (report.install && (freshInstall.mode !== report.install.mode || freshInstall.profile !== report.install.profile || freshInstall.updatable !== report.install.updatable)) {
4103
+ report = { ...report, install: freshInstall, canUpgrade: Boolean(report.outdated && freshInstall.updatable === true) }
4104
+ appendUpdate(upgradeLogLine({ phase: 'recheck', detail: `安装方式已复核:mode=${freshInstall.mode} profile=${freshInstall.profile} updatable=${freshInstall.updatable}` }))
4105
+ }
4106
+ // 审计 P3-15:busy 在 spawn 前再查一次——检查与点击之间可能刚好有刷站/后台/渲染开跑。
3948
4107
  const blocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
3949
4108
  // ⚠️ 顺序不能反:`updateStatus()` 里带着检查结果的 `ok:true`,
3950
4109
  // 必须把它展开在**前面**,再写本次升级的 ok/code/error,否则失败会被覆盖成成功。
@@ -3955,6 +4114,9 @@ export function apply(ctx, config) {
3955
4114
  electron: Boolean(process.versions.electron), registry: report.registry,
3956
4115
  })
3957
4116
  if (command.ok === false) return { ...updateStatus(), ok: false, code: command.code, error: command.error }
4117
+ // spawn 前最后一次 busy 复查(审计 P3-15):把「检查时闲、spawn 时忙」的窗口压到最小。
4118
+ const lateBlocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
4119
+ if (lateBlocker) return { ...updateStatus(), ok: false, code: 'UPGRADE_BLOCKED', error: '准备升级时有任务刚开跑:' + lateBlocker + '(已取消本次升级,稍后再试)' }
3958
4120
  upgrading = true
3959
4121
  appendUpdate(upgradeLogLine({ phase: 'start', detail: `${report.current} → ${report.latest}(profile ${command.profile})` }))
3960
4122
  // 升级要几十秒,HTTP 不能一直挂着:起完就返回,进度看 /update?rev 或日志
@@ -4166,6 +4328,21 @@ export function apply(ctx, config) {
4166
4328
  */
4167
4329
  const sendOk = (out) => Boolean(out) && typeof out === 'object' && !Array.isArray(out) && !out.error && out.success !== false
4168
4330
  && (out.success === true || out.ok === true)
4331
+ /* 审计 P2-1(2026-11-12):发送成功后的簿记(节奏/判重/口味/台账)以前直接 await——
4332
+ * 任何一处抛错(RECORD_LOCK_BUSY、state.json 损坏后的持续拒写、磁盘错误)都会把
4333
+ * **已经成功的发送**整个变成异常回执:模型只看到"出错",下轮无账可查就重发。
4334
+ * 现在簿记失败降级为日志 + 回执上的 bookkeepingWarning 字段,成功语义不再被记账失败掩盖。
4335
+ * 判重漏记的兜底在 Python 守卫层(reserve/commit 与发送同一调用),不会真的重复发。 */
4336
+ const safeBookkeep = async (label, fn) => {
4337
+ try { return await fn() } catch (error) {
4338
+ const message = String(error?.message ?? error)
4339
+ log(`⚠️ 发送已成功但记账失败(${label}):${message} —— 可用 bili_guard_status / bili_seen 核对账本`)
4340
+ return { bookkeepError: message }
4341
+ }
4342
+ }
4343
+ const bookWarnings = (out, warnings) => warnings.length
4344
+ ? { ...(out && typeof out === 'object' && !Array.isArray(out) ? out : {}), bookkeepingWarning: warnings.join(';') }
4345
+ : out
4169
4346
 
4170
4347
  /* ⭐ 跑一轮的互斥 + 生命周期(2026-10-26):
4171
4348
  * `runDuty` 有三条入口(bili_run 工具 / 设置页 POST /run / 谷时 tick),
@@ -4175,6 +4352,7 @@ export function apply(ctx, config) {
4175
4352
  * ③ 插件收摊时(ctx.effect 的清理)把它一起带走。 */
4176
4353
  let dutyChild = null
4177
4354
  let dutyTimer = null
4355
+ let dutyRelayTimer = null
4178
4356
  let backgroundRun = null
4179
4357
 
4180
4358
  /* ── 🐾 桌宠日志(pet-log.jsonl,2026-10-26 桌宠化改造)───────────────────────
@@ -4233,6 +4411,13 @@ export function apply(ctx, config) {
4233
4411
  // Windows needs a process-tree stop; killing only the shell can leave a worker alive.
4234
4412
  const terminateDutyChild = (child) => {
4235
4413
  if (!child) return false
4414
+ /* 审计 P3-45(2026-11-12):已经退出(或被杀)的子进程不再 taskkill——
4415
+ * Windows 会在进程退出后复用 PID,对旧 PID 跑 `taskkill /T /F` 有误杀无关进程树的小窗口。
4416
+ * (send 审计建议:任何分支都不该对已退出进程按 PID 强杀。) */
4417
+ // ⚠️ 真实子进程运行中 exitCode/signalCode 是 **null**,退出后是数字;测试替身/老宿主给
4418
+ // undefined ⇒ undefined 一律按「还在跑」处理(宁可多杀一次自家的,不按旧 PID 误杀别人)。
4419
+ const exited = (code) => code !== undefined && code !== null
4420
+ if (exited(child.exitCode) || exited(child.signalCode)) return false
4236
4421
  if (child === dutyChild) revokeDutyGrant()
4237
4422
  try {
4238
4423
  if (process.platform === 'win32' && Number.isInteger(child.pid) && child.pid > 0) {
@@ -4525,10 +4710,15 @@ export function apply(ctx, config) {
4525
4710
  log(`这一轮提前收工,但说好要刷满 ⇒ 自动接着刷剩余约 ${remainMin} 分钟(第 ${chain} 次接力)`)
4526
4711
  petLog('bg-continue', { why, chain, remainMin })
4527
4712
  act('note', `后台一轮提前说做完了,还剩约 ${remainMin} 分钟——我自己接着刷。`)
4528
- const continueTimer = setTimeout(() => {
4713
+ /* 审计 P2-5(2026-11-12):接力定时器以前是 close 回调的局部变量,dispose 清理
4714
+ * 覆盖不到——插件在这 2.5 秒窗口内被卸载时,接力照常 spawn 无主 headless。
4715
+ * 现在挂到模块级,dispose 里一并 clearTimeout。 */
4716
+ if (dutyRelayTimer) clearTimeout(dutyRelayTimer)
4717
+ dutyRelayTimer = setTimeout(() => {
4718
+ dutyRelayTimer = null
4529
4719
  try { runDuty(why, { ...opts, _chain: chain, durationMinutes: remainMin }) } catch (e) { log(`接力开跑失败(不影响宿主):${e?.message ?? e}`) }
4530
4720
  }, 2_500)
4531
- continueTimer.unref?.()
4721
+ dutyRelayTimer.unref?.()
4532
4722
  }
4533
4723
  /* 🗨️ 收工小结(2026-10-28):把这一趟翻译成一句人话冒给使用者 —— 时间分档、额度说破。
4534
4724
  * ⚠️ 接力发生的轮次**不是收工**(2.5 秒后就接着刷),绝不发小结,否则时段任务会
@@ -4702,10 +4892,22 @@ export function apply(ctx, config) {
4702
4892
  * 「30 秒内刚拉起过就不重复」,把怪模型的最坏节奏钉在每分钟一轮以内。 */
4703
4893
  const lastLaunch = Number(st.bgWindowLastLaunch) || 0
4704
4894
  if (now.getTime() - lastLaunch < 30_000) return null
4705
- try { writeJson(dutyStateFile, { ...st, bgWindowLastLaunch: now.getTime() }) } catch { /* 节流记不住也只是多跑一轮 */ }
4895
+ /* 🔒 审计 P2-6(2026-11-12)跨实例互斥:时段任务以前只有进程内 dutyChild 判空 +
4896
+ * 30 秒节流,两个 DSH 实例共用家目录时同一时段会各开一条 headless(额度/报告/
4897
+ * 账号节奏全面翻倍)。现在先在锁内认领 owner 标记(pid+时间戳):别的实例
4898
+ * 10 分钟内认领过 ⇒ 跳过(时段任务每轮接力都会刷新认领,10 分钟是接力间隔的
4899
+ * 宽松上限);超过 10 分钟视为原实例已死,本实例接管。daily schedule 的
4900
+ * startedDays 落盘去重与 peakValley 的 windows 落盘原本就有,这里补齐时段这条。 */
4901
+ const owner = st.bgWindowOwner && typeof st.bgWindowOwner === 'object' ? st.bgWindowOwner : null
4902
+ if (owner && Number(owner.at) > now.getTime() - 10 * 60_000 && Number(owner.pid) !== process.pid) return null
4903
+ try { writeJson(dutyStateFile, { ...st, bgWindowLastLaunch: now.getTime(), bgWindowOwner: { pid: process.pid, at: now.getTime() } }) } catch { /* 节流记不住也只是多跑一轮 */ }
4706
4904
  const r = runDuty('bg-window', { durationMinutes: Math.min(180, win.remainMin), windowEndAt: win.endAt })
4707
4905
  if (r.started) { petLog('window-start', { endAt: win.endAt, remainMin: win.remainMin, model: r.model }); act('note', `进入后台时段,开始持续刷(还剩约 ${win.remainMin} 分钟)`) }
4708
- else if (r.error) petLog('window-error', { error: r.error })
4906
+ else {
4907
+ // 认领失败就交还所有权:别的实例(或下一跳)才能接管(审计 P2-6)
4908
+ bumpState({ bgWindowOwner: null }).catch(() => {})
4909
+ if (r.error) petLog('window-error', { error: r.error })
4910
+ }
4709
4911
  return r
4710
4912
  }
4711
4913
  const schedule = dailyScheduleStatus(cfg, st.schedule || {}, now)
@@ -5711,6 +5913,13 @@ export function apply(ctx, config) {
5711
5913
  ctx.effect(() => sp.section({
5712
5914
  name: 'bili:dafeiyu',
5713
5915
  order,
5916
+ // ⚠️ 2026-11-10:`interpolate: false` 必须留着。
5917
+ // dsh-system-prompt 的 interpolate() 会把这一段里所有 `{{名字}}` 当提示词变量去查,
5918
+ // 查不到就**直接 throw**(unknown prompt variable "{{cwd}}" in section "…"),
5919
+ // 整轮 prompt 组装跟着失败 —— 表现就是"选了模式就起不来"。
5920
+ // 这段文字全是插件自己算出来的(人格/soul/陪伴档位/实时配额),本来就不该参与变量替换;
5921
+ // 关掉后即使以后有人在文案里写出 `{{` 也不会把整轮对话炸掉。
5922
+ interpolate: false,
5714
5923
  text: (context) => {
5715
5924
  // ⚠️ 宿主 sp.js 调这个 text 函数时**没有** try/catch ⇒ 这里抛一下会让整轮 prompt 组装失败。
5716
5925
  // 里面的 refreshCfg / countMomentsToday / countPickedToday / adoptFeiyu 都有各自的兜底,
@@ -7211,6 +7420,14 @@ export function apply(ctx, config) {
7211
7420
  'overlayMcp']) {
7212
7421
  if (k in body) patch[k] = body[k]
7213
7422
  }
7423
+ // 审计 P3-3(2026-11-12):未知字段以前在这里被白名单静默丢弃(同一键走 bili_config
7424
+ // 工具会被明确拒绝,三条写入口两拒一吞)。现在对齐 persistCfg 的口径:明确 400。
7425
+ // resetTokensTotal 是动作键不是配置项,放行。
7426
+ const knownKeys = new Set(['resetTokensTotal', ...Object.keys(patch)])
7427
+ const unknownKeys = Object.keys(body).filter((k) => !Object.hasOwn(DEFAULTS, k) && !knownKeys.has(k))
7428
+ if (unknownKeys.length) {
7429
+ return sendJson(res, 400, { ok: false, code: 'CONFIG_INPUT', error: '未知配置字段:' + unknownKeys.join(', ') + '(请核对字段名;完整列表见 GET /api/bili-duty/config 与 LIMITS)' })
7430
+ }
7214
7431
  // ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
7215
7432
  // ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
7216
7433
  if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
@@ -7292,8 +7509,13 @@ export function apply(ctx, config) {
7292
7509
  if (mode !== 'session' && mode !== 'background') {
7293
7510
  return sendJson(res, 400, { ok: false, error: 'mode 只能是 session 或 background' })
7294
7511
  }
7295
- /* ⭐ 两种模式的「开始」都算「鱼动了」⇒ 清免跑牌(不然挂着牌开会话,时段还是哑的) */
7296
- try { await bumpState({ bgWindowHold: 0 }) } catch { /* 清牌失败不拦开刷 */ }
7512
+ /* ⭐ 两种模式的「开始」都算「鱼动了」⇒ 清免跑牌(不然挂着牌开会话,时段还是哑的)。
7513
+ * 审计 P1-4(2026-11-12):清牌以前在路由顶部**先于一切校验**执行——一次失败的
7514
+ * 启动尝试(sessionId 过期、goal 冲突、后台 busy)就已经把手动的「本时段不再自动
7515
+ * 拉起」牌子清掉,下一跳 automationTick 会把用户刚停掉的时段任务悄悄拉起来。
7516
+ * 现在只在**启动成功后**清牌;失败路径原样保留牌子(AGENTS:手动停止的暂停状态
7517
+ * 持续到时段结束,重启调度不得绕过)。 */
7518
+ const clearHoldAfterStart = () => { bumpState({ bgWindowHold: 0 }).catch(() => { /* 清牌失败不拦开刷 */ }) }
7297
7519
  const run = resolveRunOptions(cfg, mode, body)
7298
7520
  if (mode === 'session') {
7299
7521
  const sessionId = String(body?.sessionId || '').trim()
@@ -7306,6 +7528,7 @@ export function apply(ctx, config) {
7306
7528
  petLog('error', { error: String(r?.error || r?.hint || '开刷失败') })
7307
7529
  return sendJson(res, 200, { ok: false, ...r })
7308
7530
  }
7531
+ clearHoldAfterStart()
7309
7532
  const current = feiyuStatus(agent)
7310
7533
  petLog('session-start', { sessionId, goalId: String(r.goalId || ''), model: current.modelActive || current.model, tokenBudget: current.tokens.budget, maxRounds: current.goal?.maxRounds || 0, durationMinutes: current.durationMinutes, deadlineAt: current.deadlineAt })
7311
7534
  return sendJson(res, 200, { ok: true, petLogged: true, ...r, sessionId, tokenBudget: current.tokens.budget, durationMinutes: current.durationMinutes, deadlineAt: current.deadlineAt, tokenBudgetEnforcement: 'round-boundary', durationEnforcement: 'wall-clock-pause' })
@@ -7313,11 +7536,12 @@ export function apply(ctx, config) {
7313
7536
  // Per-run overrides are immutable; they never rewrite the other mode's defaults.
7314
7537
  /* ⭐ 2026-10-28「时段」任务:配置里填了后台时段且现在正处在段内 ⇒ 本轮的墙钟终点
7315
7538
  * 就是时段结束(runDuty 里接力到那个点);配置本身是常设指令,automationTick
7316
- * 每分钟都会在段内把鱼拉起来。免跑牌已在路由顶部统一清掉。 */
7539
+ * 每分钟都会在段内把鱼拉起来。免跑牌在启动成功后清(见上方 clearHoldAfterStart)。 */
7317
7540
  let windowEndAt = 0
7318
7541
  const ws = String(cfg.backgroundWindowStart || '').trim(), we = String(cfg.backgroundWindowEnd || '').trim()
7319
7542
  if (ws && we && backgroundWindowActive(ws, we)) windowEndAt = backgroundWindowEndAt(ws, we)
7320
7543
  const r = runDuty('pet', { ...run, ...(windowEndAt ? { windowEndAt } : {}) })
7544
+ if (r?.started) clearHoldAfterStart()
7321
7545
  if (windowEndAt) petLog('window-start', { start: ws, end: we, endAt: windowEndAt, manual: true })
7322
7546
  // runDuty emits a single start/error record for every background entry point.
7323
7547
  return sendJson(res, 200, r)
@@ -7504,7 +7728,9 @@ export function apply(ctx, config) {
7504
7728
  const body = await readBody(req)
7505
7729
  // 只接受"确认升级"这一个动作;任何额外字段(目标版本、路径、参数)一律忽略,
7506
7730
  // 避免把这条路由变成可执行任意 dsh 命令的入口。
7507
- if (body && typeof body === 'object' && body.confirm !== true) {
7731
+ // 审计 P3-42(2026-11-12):以前 `body && typeof body === 'object'` 才检查——
7732
+ // body 为 null/true/"x"/123 等非对象合法 JSON 时检查整体跳过,无 confirm 也执行升级。
7733
+ if (!body || typeof body !== 'object' || Array.isArray(body) || body.confirm !== true) {
7508
7734
  return sendJson(res, 400, { ok: false, code: 'UPGRADE_CONFIRM_REQUIRED', error: '升级需要显式确认(confirm: true)' })
7509
7735
  }
7510
7736
  return sendJson(res, 200, await startUpgrade())
@@ -7656,6 +7882,11 @@ export function apply(ctx, config) {
7656
7882
  try { mcp?.stop() } catch { /* 收摊 */ }
7657
7883
  // ⭐ headless 那一轮也一起带走:进程被 dispose 时留着一个烧 token 的子进程没有意义
7658
7884
  try { if (dutyTimer) clearTimeout(dutyTimer) } catch { /* 无所谓 */ }
7885
+ // 审计 P2-5:接力定时器同样收掉(原来它不在清理清单里,2.5s 窗口内卸载会 spawn 无主 headless)
7886
+ try { if (dutyRelayTimer) { clearTimeout(dutyRelayTimer); dutyRelayTimer = null } } catch { /* 无所谓 */ }
7887
+ // 审计 P2-9:渲染中的 python 子进程(pv)也一起带走——原来 dispose 清单漏了它,
7888
+ // 卸载后子进程无人跟踪(无信标),最长还能跑 20 分钟。pv.stop() 幂等,闲时无副作用。
7889
+ try { pv?.stop?.() } catch { /* 已经停了 */ }
7659
7890
  try { if (learnReviewRun?.running && learnReviewRun.id) rmSync(join(stateDir, `${learnReviewRun.id}.json`)) } catch { /* already revoked */ }
7660
7891
  try { terminateDutyChild(dutyChild) } catch { /* 已经没了 */ }
7661
7892
  for (const live of feiyuLive.values()) if (live.deadlineTimer) clearTimeout(live.deadlineTimer)
@@ -7709,8 +7940,11 @@ export function apply(ctx, config) {
7709
7940
  t?.unref?.()
7710
7941
  ctx.effect(() => () => clearInterval(t), 'bili-duty: 视频每日定时器')
7711
7942
  // 挂载后先看一眼(开机晚了也能当场补上,不用等第一个 3 分钟)
7943
+ // 审计 P3-4(2026-11-12):这个一次性定时器原来不在清理清单里——挂载后 20 秒内卸载时
7944
+ // 仍会触发一次 videoStudio.start(videoStudio 已 dispose,但 pvAuto 为真时仍可能起制作会话)。
7712
7945
  const t0 = setTimeout(() => { pvTick() }, 20_000)
7713
7946
  t0?.unref?.()
7947
+ ctx.effect(() => () => clearTimeout(t0), 'bili-duty: 视频定时器首跳')
7714
7948
  } catch (e) { log(`🎬 视频定时器没起来:${e?.message ?? e}`) }
7715
7949
 
7716
7950
  log(`已挂载(家=${stateDir},python=${cfg.pythonPath},峰谷=${cfg.peakValley ? '开' : '关'})`)