@shgroup/dsh-serenity-hooks 1.30.16 → 1.31.0

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/lib/index.js CHANGED
@@ -3,14 +3,14 @@ import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$2, f as re
3
3
  import { a as readSkiffRoles, d as systemPromptSource, i as isSkiffSessionId, l as roleToolWhitelist, o as resolveRoleSystemPrompt } from "./skiff-role-BYvNnwv1.js";
4
4
  import { A as skiffRoleFor, C as listActiveHandymen, D as splitModel, E as requireWhitelistedModel, O as writeFailedStatus, S as handymanProgressPaths, T as readProgress, _ as unregisterSkiffSession, a as startSkiffDebugServer, b as HANDYMAN_GUIDE, c as askSkiff, d as ensureWorkspacePromptSection, f as getSkiffAgent, g as skiffTrajectoryEnabled, h as skiffSessionSnapshot, k as writeProgress, l as createSkiffAgent, m as skiffSessionInfo, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffMsmGate, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as workspaceTrajectoryLine, w as newStopToken, x as buildRoundPrompt, y as waitAgentIdle } from "./skiff-debug-C5ulZKCa.js";
5
5
  import { i as hostSessions, n as hostInjected, o as hostWeb, r as hostService, s as hostWebServer, t as hostAgents } from "./access-dU_vG1q8.js";
6
- import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, h as runLocalStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-BD_yYAZz.js";
6
+ import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-wWk4AIwV.js";
7
7
  import { C as useSession, S as summarize, _ as resolveSessionByTitle, a as archiveSessions, b as setActiveSessionInfo, c as createSession, d as getActiveSessionInfo, f as healthCheck, g as readActiveSessionMd, h as qaCheck, i as SESSION_ACTIONS, l as extractSessionMdPathFromText, m as parseSessionContextFromEvents, n as readLastBound, o as clearActiveSessionInfo, p as listSessions, r as DEFAULT_SESSION_SCOPE, s as closeSession, t as appendBound, u as findSession, v as sessionEvents, x as showSession, y as sessionsRoot } from "./session-bound-BpTayaIS.js";
8
8
  import { n as registerSettingsSection, t as readSimpleSettings } from "./settings-section-CP4uMJf_.js";
9
- import { a as markdownToPlainText, c as sniffImageExt, i as getUpdates, n as downloadMedia, o as sendTextMessage, r as getConfig, s as sendTyping, t as TypingStatus } from "./weixin-api-BMpljodH.js";
9
+ import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-PjEhmjlZ.js";
10
10
  import z from "@deepseek-ai/schemastery";
11
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
12
12
  import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
13
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
13
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
15
15
  import { homedir, platform } from "node:os";
16
16
  import { fileURLToPath } from "node:url";
@@ -421,10 +421,10 @@ function runCcFs(root, args) {
421
421
  * 进程内注册(取代 v0.1 的 bash spawn runner):zod/schemastery 参数校验、
422
422
  * 规范 JSON 输出、纯 render 投影。逻辑在 fs-ops.ts(可单测)。
423
423
  */
424
- function agentCwd$9(exec) {
424
+ function agentCwd$10(exec) {
425
425
  return exec.agent?.session?.header?.cwd ?? process.cwd();
426
426
  }
427
- function renderText$12(value) {
427
+ function renderText$13(value) {
428
428
  return [{
429
429
  type: "text",
430
430
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -496,10 +496,10 @@ const ccFsTool = defineTool({
496
496
  },
497
497
  output: {
498
498
  schema: { type: "json" },
499
- render: (args, value) => renderText$12(value)
499
+ render: (args, value) => renderText$13(value)
500
500
  },
501
501
  async execute(args, exec) {
502
- const root = findSerenityRoot(agentCwd$9(exec));
502
+ const root = findSerenityRoot(agentCwd$10(exec));
503
503
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
504
504
  return runCcFs(root, args);
505
505
  }
@@ -520,6 +520,208 @@ const ACC_VERSION = (() => {
520
520
  }
521
521
  })();
522
522
  //#endregion
523
+ //#region src/im-bridge.ts
524
+ /**
525
+ * im-bridge.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
526
+ *
527
+ * 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
528
+ * 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
529
+ * 里面有微信,然后保留后续可能兼容别的 im 的空间」。
530
+ *
531
+ * 分层(E↑):
532
+ * `tools/im-bridge.ts`(工具面:参数 → 结果渲染)
533
+ * → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
534
+ * → 通道实现(`im-weixin.ts`:协议/凭据/账号/记录)
535
+ *
536
+ * 边界(R↓):
537
+ * - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
538
+ * - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
539
+ * - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
540
+ * 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
541
+ * - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
542
+ */
543
+ /** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
544
+ const IM_SEND_MAX_TEXT = 4e3;
545
+ /** 文件大小上限(20MB,与媒体接收上限一致) */
546
+ const IM_FILE_MAX_BYTES = 20971520;
547
+ const channels = /* @__PURE__ */ new Map();
548
+ /** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
549
+ function registerImChannel(channel) {
550
+ channels.set(channel.id, channel);
551
+ }
552
+ /** 已注册通道 id(工具 `channel` 参数枚举) */
553
+ function imChannelIds() {
554
+ return [...channels.keys()];
555
+ }
556
+ /**
557
+ * 可见性判据:本 CCC 是否启用了**任一** IM 通道。
558
+ * 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
559
+ * @param root 本会话 CCC 根(由调用方解析)
560
+ */
561
+ function hasEnabledImChannel(root) {
562
+ for (const channel of channels.values()) try {
563
+ if (channel.isEnabled(root)) return true;
564
+ } catch {}
565
+ return false;
566
+ }
567
+ /** 错误码 → 可行动提示(模型视角:说清怎么改) */
568
+ function remediationFor(code) {
569
+ switch (code) {
570
+ case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
571
+ case "CHANNEL_NOT_CONFIGURED": return "This CCC has not configured that channel — enable it in .opencode/serenity.json (weixin.enabled) or the settings panel";
572
+ case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
573
+ case "ACTION_UNSUPPORTED": return "This channel does not support that action";
574
+ case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
575
+ case "TEXT_REQUIRED": return "Provide text";
576
+ case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
577
+ case "USER_REQUIRED": return "Provide user";
578
+ case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
579
+ case "FILE_ESCAPE": return "Use a path inside this CCC";
580
+ case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
581
+ case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
582
+ default: return;
583
+ }
584
+ }
585
+ function fail(code, error, remediation) {
586
+ return {
587
+ ok: false,
588
+ code,
589
+ error,
590
+ ...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
591
+ };
592
+ }
593
+ /** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
594
+ function readCccFile(root, relOrAbs) {
595
+ const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
596
+ const rel = relative(resolve(root), abs);
597
+ if (rel.startsWith("..") || isAbsolute(rel)) return {
598
+ ok: false,
599
+ code: "FILE_ESCAPE",
600
+ error: `file escapes the CCC root: ${relOrAbs}`
601
+ };
602
+ if (!existsSync(abs)) return {
603
+ ok: false,
604
+ code: "FILE_NOT_FOUND",
605
+ error: `file not found: ${relOrAbs}`
606
+ };
607
+ let size = 0;
608
+ try {
609
+ size = statSync(abs).size;
610
+ } catch (err) {
611
+ return {
612
+ ok: false,
613
+ code: "FILE_NOT_FOUND",
614
+ error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
615
+ };
616
+ }
617
+ if (size > 20971520) return {
618
+ ok: false,
619
+ code: "FILE_TOO_LARGE",
620
+ error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
621
+ };
622
+ try {
623
+ return {
624
+ ok: true,
625
+ data: readFileSync(abs),
626
+ fileName: abs.split("/").pop() ?? "file"
627
+ };
628
+ } catch (err) {
629
+ return {
630
+ ok: false,
631
+ code: "FILE_NOT_FOUND",
632
+ error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
633
+ };
634
+ }
635
+ }
636
+ /**
637
+ * 执行一次 IM 动作(工具面唯一入口)。
638
+ *
639
+ * 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
640
+ * 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
641
+ *
642
+ * @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
643
+ * @param req 工具参数
644
+ */
645
+ async function runImBridge(root, req) {
646
+ const channelId = (req.channel ?? "").trim();
647
+ const action = (req.action ?? "").trim();
648
+ if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
649
+ const channel = channels.get(channelId);
650
+ if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
651
+ if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`);
652
+ if (action === "users") return {
653
+ ok: true,
654
+ channel: channelId,
655
+ action,
656
+ detail: { users: channel.listUsers(root) }
657
+ };
658
+ if (action === "status") return {
659
+ ok: true,
660
+ channel: channelId,
661
+ action,
662
+ detail: channel.status(root)
663
+ };
664
+ if (action === "send") {
665
+ const rawUser = (req.user ?? "").trim();
666
+ if (rawUser === "") return fail("USER_REQUIRED", "user is required");
667
+ const text = req.text ?? "";
668
+ if (text === "") return fail("TEXT_REQUIRED", "text is required");
669
+ if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
670
+ const resolved = channel.resolveUser(root, rawUser);
671
+ if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
672
+ try {
673
+ return {
674
+ ok: true,
675
+ channel: channelId,
676
+ action,
677
+ detail: {
678
+ ...await channel.send({
679
+ root,
680
+ userId: resolved.id,
681
+ text,
682
+ accountId: req.account
683
+ }),
684
+ user: resolved.alias ?? resolved.id
685
+ }
686
+ };
687
+ } catch (err) {
688
+ return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
689
+ }
690
+ }
691
+ if (action === "send-file") {
692
+ if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
693
+ const rawUser = (req.user ?? "").trim();
694
+ if (rawUser === "") return fail("USER_REQUIRED", "user is required");
695
+ const fileArg = (req.file ?? "").trim();
696
+ if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
697
+ const resolved = channel.resolveUser(root, rawUser);
698
+ if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
699
+ const file = readCccFile(root, fileArg);
700
+ if (!file.ok) return fail(file.code, file.error);
701
+ try {
702
+ return {
703
+ ok: true,
704
+ channel: channelId,
705
+ action,
706
+ detail: {
707
+ ...await channel.sendFile({
708
+ root,
709
+ userId: resolved.id,
710
+ data: file.data,
711
+ fileName: file.fileName,
712
+ caption: req.caption,
713
+ accountId: req.account
714
+ }),
715
+ user: resolved.alias ?? resolved.id
716
+ }
717
+ };
718
+ } catch (err) {
719
+ return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
720
+ }
721
+ }
722
+ return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
723
+ }
724
+ //#endregion
523
725
  //#region src/seams/guards.ts
524
726
  /**
525
727
  * guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
@@ -712,6 +914,55 @@ function syncSafeModeRestriction(agent, root) {
712
914
  }
713
915
  writeRestrictDiag(root);
714
916
  }
917
+ /** 条件可见的 IM 工具名(与 tools/im-bridge.ts 的注册名一致) */
918
+ const IM_BRIDGE_TOOL_NAME = "im-bridge";
919
+ /** agent key → restrict disposer(im-bridge 隐藏状态;key 同安全模式 = 会话 id) */
920
+ const imBridgeRestrictions = /* @__PURE__ */ new Map();
921
+ /**
922
+ * 同步 im-bridge 可见性(v1.31.0,S142 用户拍板 Q3「未配置则不可见」)。
923
+ *
924
+ * 判据 = 本会话 CCC 是否启用了**任一** IM 通道(`hasEnabledImChannel`,当前只有 weixin):
925
+ * - 未启用 → `agent.ctx.tools.restrict({ deny: ['im-bridge'] })`:**从 schema 移除**
926
+ * (模型看不到,而不是看得到但调用被拒——用户原话"不配置则不可见")
927
+ * - 启用 → 解除隐藏(CCC 配置热更新即时生效)
928
+ *
929
+ * 与 `syncSafeModeRestriction` 同点调用(pre-step 每步 + 会话就绪),失败不阻断
930
+ * (守卫仍兜底;restrict 不可用时最坏是"看得到但通道未配置 → 返回 CHANNEL_NOT_CONFIGURED")。
931
+ */
932
+ function syncImBridgeVisibility(agent, root) {
933
+ const key = agent.session.id ?? "global";
934
+ let enabled = false;
935
+ try {
936
+ enabled = hasEnabledImChannel(root);
937
+ } catch (e) {
938
+ console.error(`[serenity-hooks] im-bridge 可见性判据失败 (key=${key}):`, e.message);
939
+ return;
940
+ }
941
+ const existing = imBridgeRestrictions.get(key);
942
+ if (!enabled && !existing) try {
943
+ const dispose = agent.ctx.tools.restrict({ deny: [IM_BRIDGE_TOOL_NAME] });
944
+ imBridgeRestrictions.set(key, dispose);
945
+ } catch (e) {
946
+ console.error(`[serenity-hooks] im-bridge 隐藏失败 (key=${key}):`, e.message);
947
+ }
948
+ else if (enabled && existing) {
949
+ try {
950
+ existing();
951
+ } catch (e) {
952
+ console.error(`[serenity-hooks] im-bridge 解除隐藏失败 (key=${key}):`, e.message);
953
+ }
954
+ imBridgeRestrictions.delete(key);
955
+ }
956
+ }
957
+ /** 会话销毁清理(lifecycle 调用;防 per-会话 Map 无界增长——review F-08 同族) */
958
+ function forgetImBridgeVisibility(sessionId) {
959
+ const existing = imBridgeRestrictions.get(sessionId);
960
+ if (!existing) return;
961
+ try {
962
+ existing();
963
+ } catch {}
964
+ imBridgeRestrictions.delete(sessionId);
965
+ }
715
966
  /**
716
967
  * 从 exec 参数中提取常见路径字段(write/edit 工具);宽松读取。
717
968
  * container_fs 复合工具:path(单路径)/ paths(数组)/ src / dst——取第一个命中(越界检查
@@ -1316,10 +1567,10 @@ async function runKit(root, args, hostCtx) {
1316
1567
  /**
1317
1568
  * kit.ts — dashboard 真实 DSH 工具定义(defineTool)(v1.30:acc_kit → dashboard)
1318
1569
  */
1319
- function agentCwd$8(exec) {
1570
+ function agentCwd$9(exec) {
1320
1571
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1321
1572
  }
1322
- function renderText$11(value) {
1573
+ function renderText$12(value) {
1323
1574
  return [{
1324
1575
  type: "text",
1325
1576
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -1347,10 +1598,10 @@ function createKitTool(ctx) {
1347
1598
  },
1348
1599
  output: {
1349
1600
  schema: { type: "json" },
1350
- render: (args, value) => renderText$11(value)
1601
+ render: (args, value) => renderText$12(value)
1351
1602
  },
1352
1603
  async execute(args, exec) {
1353
- return await runKit(findSerenityRoot(agentCwd$8(exec)), args, ctx);
1604
+ return await runKit(findSerenityRoot(agentCwd$9(exec)), args, ctx);
1354
1605
  }
1355
1606
  });
1356
1607
  }
@@ -1539,10 +1790,10 @@ function runGit(root, args) {
1539
1790
  /**
1540
1791
  * git.ts — container_git 真实 DSH 工具定义(defineTool)(v1.30:cc_git → container_git)
1541
1792
  */
1542
- function agentCwd$7(exec) {
1793
+ function agentCwd$8(exec) {
1543
1794
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1544
1795
  }
1545
- function renderText$10(value) {
1796
+ function renderText$11(value) {
1546
1797
  return [{
1547
1798
  type: "text",
1548
1799
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -1581,10 +1832,10 @@ const gitTool = defineTool({
1581
1832
  },
1582
1833
  output: {
1583
1834
  schema: { type: "json" },
1584
- render: (args, value) => renderText$10(value)
1835
+ render: (args, value) => renderText$11(value)
1585
1836
  },
1586
1837
  async execute(args, exec) {
1587
- const root = findSerenityRoot(agentCwd$7(exec));
1838
+ const root = findSerenityRoot(agentCwd$8(exec));
1588
1839
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1589
1840
  return runGit(root, args);
1590
1841
  }
@@ -1844,28 +2095,47 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1844
2095
  (扫码绑定后自动写入,永不进 git 明文面)。
1845
2096
  路由 user → role:exact 优先,* 通配兜底;role 必须 ∈ 该 CCC skiff.roles。
1846
2097
  面板(WebUI 设置 → 微信桥)可扫码绑定/移除账号/编辑路由;msm("weixin-doctor", ["status"|"diag"|"verify"|"guide"]) 排查 + 大而全指南。凭据主动查看:localstore get WEIXIN_<ACCOUNT>_TOKEN。
1847
- ▸ 主动发送(v1.30.9):CCC 用 msm("weixin-send", ["send", "--ccc", "<CCC>", "--user", "<别名|id>", "<文本>"])
1848
- 给用户发消息——经 dsp 主动发送入口(**只绑 127.0.0.1**,plugin 全局配置 ~/.dsh/serenity-hooks.json
1849
- 的 weixinApi.port,默认 3082;公网不可达故无密钥)→ weixin-bridge.sendProactiveText →
1850
- 成功后触发 outgoing hook(source="proactive")。send-file 仍由 MSM 直连 iLink 上传并自行补记。
2098
+ ▸ 主动发送(v1.31.0:**ACC 工具 im-bridge**):调
2099
+ im-bridge({channel:"weixin", action:"send", user:"<别名|id>", text:"<文本>"}) 给用户发消息——
2100
+ **工具只能操作本会话 CCC**(无 ccc 参数,防误发他容器)→ weixin-bridge.sendProactiveText →
2101
+ 成功后触发 outgoing hook(source="proactive")。文件用 action:"send-file" + file:"<CCC 内路径>"
2102
+ (逃逸/缺失/超 20MB 拒绝)+ 可选 caption。**可见性**:本 CCC 配置了 IM 通道
2103
+ (weixin.enabled=true)才出现在模型工具清单里;未配置 → 由 guards 逐 agent
2104
+ tools.restrict({deny:['im-bridge']}) 移除(不是"看得到但被拒")。skiff 角色另需在
2105
+ roles.<role>.tools 白名单列出 im-bridge。
2106
+ 历史(v1.30.9~v1.30.x):CCC 侧 MSM weixin-send(走 loopback HTTP 入口 3082)——
2107
+ v1.31.0 已退役(脚本删除 + 注册表注销);3082 入口保留给**非 agent 调用者**。
1851
2108
 
1852
2109
  ▸ weixin.autoReplyWithLastMessage(v1.30.10,关闭桥的自动输出):
1853
2110
  缺省 true = 现状:桥在 agent 一轮结束后把**最终 assistant 文本**自动回发用户(source="reply")。
1854
2111
  显式 false = 关闭:桥**不再转发最终文本**,输出权交给 agent——每轮在用户消息**末尾**追加
1855
- **机制标记** [serenity:weixin-manual-output] + 一行已填好参数的 weixin-send 命令
1856
- (CCC 根/账号/用户 id),agent 自己决定发什么、发几条(全部记 source="proactive")。
2112
+ **机制标记** [serenity:weixin-manual-output] + 一行已填好参数的 im-bridge 调用
2113
+ (账号/用户 id),agent 自己决定发什么、发几条(全部记 source="proactive")。
1857
2114
  **ACC 只给机制与数据,纪律措辞归 CCC**(v1.30.16):怎么写/必须怎么做/后果是什么写在
1858
2115
  CCC 角色提示词文件里(如 .opencode/skiff/<role>.md),可热改(v1.30.15 起角色提示词热重载)。
1859
- 机械闸门(v1.30.16):本轮 turn 结束仍未成功调用 weixin-send → agent 被打回(≤2 次,
1860
- 提示 = 标记 + 事实);达上限放弃并响亮告警。失败调用不算已送达。
2116
+ 机械闸门(v1.30.16):本轮 turn 结束仍未成功调用 im-bridge(send / send-file;兼容旧
2117
+ msm("weixin-send") 形态)→ agent 被打回(≤2 次,提示 = 标记 + 事实);达上限放弃并响亮告警。
2118
+ 失败调用不算已送达。
1861
2119
  语义边界(用户拍板):只关最终文本;系统类消息(新对话通知 / 语音无法解析提示)保留;
1862
- **agent 一轮未输出 → 静默不兜底**(否则开关失去意义);typing 指示与 incoming hook 不受影响。
2120
+ typing 指示与 incoming hook 不受影响。
2121
+
2122
+ ▸ weixin.fallbackOnNoSend(v1.30.17,手动模式的**兜底**;缺省 false):
2123
+ 仅在 autoReplyWithLastMessage=false 时生效。true → 一轮结束时若 agent **一次都没成功**
2124
+ 调用输出工具(闸门打回用尽),桥把该轮最终文本转发给用户(记录 source="reply-fallback",
2125
+ 日志留响亮告警)——保证"不丢消息"。agent 自己发过 → 不兜底(输出权仍在 agent)。
2126
+ 为什么需要(实证):v1.30.16 闸门会打回 ≤2 次,但实测某模型在**寒暄类消息**上连续 4 轮、
2127
+ 跨 3 版 CCC 提示词仍不调工具 → 打回用尽后用户什么都收不到;提示词与闸门均已排除。
2128
+ 代价(显式):角色失去"故意静默"能力(它总会产出一段最终文本)→ 需严格静默的角色保持缺省。
2129
+ 三态:① autoReplyWithLastMessage=true(缺省)桥总是回发;② =false + fallbackOnNoSend=true
2130
+ = agent 优先、桥兜底(推荐);③ 两者皆 false(缺省)= 严格静默(v1.30.10 语义)。
2131
+ ⚠️ 兜底只在**闸门装配成功**时生效(未装配则无法判定是否发送过 → 不冒重复发送风险)。
1863
2132
 
1864
2133
  Config:
1865
2134
  { "weixin": {
1866
2135
  "enabled": true,
1867
2136
  "hook": "scripts/weixin-message-hook.ts",
1868
2137
  "autoReplyWithLastMessage": true,
2138
+ "fallbackOnNoSend": false,
1869
2139
  "accounts": [{ "accountId": "wechat-1", "name": "家庭助手", "enabled": true }],
1870
2140
  "routes": [{ "user": "*", "role": "zhaocai" }]
1871
2141
  } }
@@ -1878,7 +2148,8 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1878
2148
  incoming = 用户 → bot:路由命中后、媒体落盘后触发(文本含语音转写;媒体带落盘 relPath)
1879
2149
  outgoing = bot → 用户:发送成功后触发(reply = 用户实际收到的纯文本,已剥离 think)
1880
2150
  · source="reply"(缺省)= 回复用户消息;source="proactive"(v1.30.9)= bot 主动发起
1881
- (CCC 经 weixin-send MSM → dsp 主动发送入口 → sendProactiveText)
2151
+ (CCC 经 im-bridge 工具(v1.31.0;旧 weixin-send MSM 已退役)→ sendProactiveText);
2152
+ source="reply-fallback"(v1.30.17)= 手动模式下 agent 本轮未发送 → 桥兜底转发最终文本
1882
2153
  · file(仅 send-file 补记):{ "name": "报告.pdf", "size": 12345, "caption": "可选" }
1883
2154
 
1884
2155
  脚本约定(事件 JSON 单行经 stdin 传入;bun 优先 node 兜底):
@@ -1893,7 +2164,7 @@ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC
1893
2164
  "message": { "text": "你好", "media": [{ "kind": "image", "relPath": "_tmp/weixin-inbound/<hash>/img_x.jpg" }] } }
1894
2165
  { "event": "outgoing", "ts": ..., "cccRoot": ..., "accountId": ...,
1895
2166
  "userId": ..., "sessionId": ..., "role": ...,
1896
- "reply": "已记录(纯文本)", "source": "reply" | "proactive" }
2167
+ "reply": "已记录(纯文本)", "source": "reply" | "proactive" | "reply-fallback" }
1897
2168
 
1898
2169
  示例脚本(追加到按日文件——持久化归 CCC 自选:文件/DB/远端均可):
1899
2170
  const fs = require('node:fs'); const p = '/path/ccc/AGENT_SESSIONS/_weixin-log.jsonl';
@@ -2314,13 +2585,13 @@ async function runMsmAsync(root, args) {
2314
2585
  * 管理面(register/deregister/check/guide/catalog/ccc-config)已移入 container_admin。
2315
2586
  * 底层 runMsmAsync/prepareExec 全复用;未命中候选为新增 findSuggestions。
2316
2587
  */
2317
- function agentCwd$6(exec) {
2588
+ function agentCwd$7(exec) {
2318
2589
  return exec.agent?.session?.header?.cwd ?? process.cwd();
2319
2590
  }
2320
2591
  function agentSessionId$1(exec) {
2321
2592
  return exec.agent?.session?.id ?? "";
2322
2593
  }
2323
- function renderText$9(value) {
2594
+ function renderText$10(value) {
2324
2595
  return [{
2325
2596
  type: "text",
2326
2597
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2370,10 +2641,10 @@ const msmTool = defineTool({
2370
2641
  },
2371
2642
  output: {
2372
2643
  schema: { type: "json" },
2373
- render: (args, value) => renderText$9(value)
2644
+ render: (args, value) => renderText$10(value)
2374
2645
  },
2375
2646
  async execute(args, exec) {
2376
- const root = findSerenityRoot(agentCwd$6(exec));
2647
+ const root = findSerenityRoot(agentCwd$7(exec));
2377
2648
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
2378
2649
  const sessionId = agentSessionId$1(exec);
2379
2650
  const name = args.name;
@@ -2431,7 +2702,7 @@ CCC (home-serenity etc.) encodes cognitive content as skills/SESSIONs/design doc
2431
2702
 
2432
2703
  ## Reference
2433
2704
  https://github.com/tellmewhattodo/theory-eap`;
2434
- function renderText$8(value) {
2705
+ function renderText$9(value) {
2435
2706
  return [{
2436
2707
  type: "text",
2437
2708
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2451,7 +2722,7 @@ defineTool({
2451
2722
  } },
2452
2723
  output: {
2453
2724
  schema: { type: "string" },
2454
- render: (_args, value) => renderText$8(value)
2725
+ render: (_args, value) => renderText$9(value)
2455
2726
  },
2456
2727
  async execute(args) {
2457
2728
  if (args.section === "variables") return EAP_CONTENT.split("## Pre-Output Self-Check Checklist")[0];
@@ -2492,7 +2763,7 @@ Requirements → Scope → Solution → Interface → Implementation
2492
2763
  2. Wait for confirmation or correction (small step)
2493
2764
  3. After confirmation, record the decision (into session SESSION.md or a design doc)
2494
2765
  4. Move to the next decision point`;
2495
- function renderText$7(value) {
2766
+ function renderText$8(value) {
2496
2767
  return [{
2497
2768
  type: "text",
2498
2769
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2508,7 +2779,7 @@ defineTool({
2508
2779
  } },
2509
2780
  output: {
2510
2781
  schema: { type: "string" },
2511
- render: (_args, value) => renderText$7(value)
2782
+ render: (_args, value) => renderText$8(value)
2512
2783
  },
2513
2784
  async execute(args) {
2514
2785
  if (args.section === "rules") return NEAT_CONTENT.match(/## Four Iron Rules[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
@@ -2571,7 +2842,7 @@ CCE answers "how structured knowledge should keep evolving across time without l
2571
2842
  ## Relationship with Serenity
2572
2843
  Serenity's session system, session tracking, and entropy management mechanisms (SQC quality loop) are all engineering implementations of CCE;
2573
2844
  the behavioral constraints embedded in CCC system prompts come from CCE.`;
2574
- function renderText$6(value) {
2845
+ function renderText$7(value) {
2575
2846
  return [{
2576
2847
  type: "text",
2577
2848
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2592,7 +2863,7 @@ defineTool({
2592
2863
  } },
2593
2864
  output: {
2594
2865
  schema: { type: "string" },
2595
- render: (_args, value) => renderText$6(value)
2866
+ render: (_args, value) => renderText$7(value)
2596
2867
  },
2597
2868
  async execute(args) {
2598
2869
  const section = args.section;
@@ -2638,7 +2909,7 @@ const PRAXIS_INDEX = `praxis — 可实践理论注入(认知框架,按需
2638
2909
  praxis cce(认知连续性工程——容器 5 行为约束 + H_op 操作熵)
2639
2910
  无参返回本目录;详情以对应框架原文为准(单一真相源)。
2640
2911
  `;
2641
- function renderText$5(value) {
2912
+ function renderText$6(value) {
2642
2913
  return [{
2643
2914
  type: "text",
2644
2915
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2658,7 +2929,7 @@ const praxisTool = defineTool({
2658
2929
  } },
2659
2930
  output: {
2660
2931
  schema: { type: "string" },
2661
- render: (_args, value) => renderText$5(value)
2932
+ render: (_args, value) => renderText$6(value)
2662
2933
  },
2663
2934
  async execute(args) {
2664
2935
  if (args.section === "eap") return EAP_CONTENT;
@@ -2712,10 +2983,10 @@ function handymanPresetInheritance(parentCtx) {
2712
2983
  * preset 继承 + 工具收窄:setup 钩子里对子 agent 执行 agentPresets.composeFrom(对齐
2713
2984
  * subagent 先例)+ tools.restrict deny handyman(worker 内部看不到 handyman 工具)。
2714
2985
  */
2715
- function agentCwd$5(exec) {
2986
+ function agentCwd$6(exec) {
2716
2987
  return (exec.agent?.session)?.header?.cwd ?? process.cwd();
2717
2988
  }
2718
- function renderText$4(value) {
2989
+ function renderText$5(value) {
2719
2990
  return [{
2720
2991
  type: "text",
2721
2992
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -2918,11 +3189,11 @@ function createHandymanTool(ctx) {
2918
3189
  },
2919
3190
  output: {
2920
3191
  schema: { type: "json" },
2921
- render: (_args, value) => renderText$4(value)
3192
+ render: (_args, value) => renderText$5(value)
2922
3193
  },
2923
3194
  async execute(args, exec) {
2924
3195
  if (args.guide) return { guide: HANDYMAN_GUIDE };
2925
- const root = findSerenityRoot(agentCwd$5(exec));
3196
+ const root = findSerenityRoot(agentCwd$6(exec));
2926
3197
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
2927
3198
  const hc = readHandymanConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
2928
3199
  if (hc === null) throw new Error("handyman requires a model whitelist: configure .opencode/serenity.json \"handyman.models\" (e.g. {\"handyman\": {\"models\": [\"minimax-cn-coding-plan/MiniMax-M3\"], \"defaultModel\": \"minimax-cn-coding-plan/MiniMax-M3\"}})");
@@ -2983,7 +3254,7 @@ function createHandymanTool(ctx) {
2983
3254
  * 同步把当前 dsh 会话重命名为该 SESSION 目录名**(sessionTitle.rename,user source
2984
3255
  * pin 住标题)。非创建时预命名。
2985
3256
  */
2986
- function agentCwd$4(exec) {
3257
+ function agentCwd$5(exec) {
2987
3258
  return exec.agent?.session?.header?.cwd ?? process.cwd();
2988
3259
  }
2989
3260
  /** 当前 dsh 会话 id(use/close 按会话隔离的 scope) */
@@ -3099,7 +3370,7 @@ function renameDshSessionForActive(ctx, exec, info, summary) {
3099
3370
  console.warn(`[serenity-hooks] dsh 会话重命名异常: ${String(err?.message ?? err)}`);
3100
3371
  }
3101
3372
  }
3102
- function renderText$3(value) {
3373
+ function renderText$4(value) {
3103
3374
  return [{
3104
3375
  type: "text",
3105
3376
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3245,10 +3516,10 @@ function createSessionTool(ctx) {
3245
3516
  },
3246
3517
  output: {
3247
3518
  schema: { type: "json" },
3248
- render: (args, value) => renderText$3(value)
3519
+ render: (args, value) => renderText$4(value)
3249
3520
  },
3250
3521
  async execute(args, exec) {
3251
- const root = findSerenityRoot(agentCwd$4(exec));
3522
+ const root = findSerenityRoot(agentCwd$5(exec));
3252
3523
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3253
3524
  const entries = loadMsmEntries(root);
3254
3525
  const hasSessionTool = entries.some((e) => e.name === "session-tool");
@@ -3354,7 +3625,7 @@ function createSessionTool(ctx) {
3354
3625
  root,
3355
3626
  note: args.note,
3356
3627
  summary: args.summary,
3357
- agentCwd: agentCwd$4(exec),
3628
+ agentCwd: agentCwd$5(exec),
3358
3629
  dshSessionId
3359
3630
  });
3360
3631
  return {
@@ -3379,7 +3650,7 @@ function createSessionTool(ctx) {
3379
3650
  * 进程内注册,零 DSH 依赖逻辑在 localstore-ops.ts(可单测)。
3380
3651
  * doc 子命令输出存储规范,agent 可直接用 fs 工具(read/write)自己读写。
3381
3652
  */
3382
- const ACTIONS = [
3653
+ const ACTIONS$1 = [
3383
3654
  "list",
3384
3655
  "get",
3385
3656
  "set",
@@ -3387,10 +3658,10 @@ const ACTIONS = [
3387
3658
  "show",
3388
3659
  "doc"
3389
3660
  ];
3390
- function agentCwd$3(exec) {
3661
+ function agentCwd$4(exec) {
3391
3662
  return exec.agent?.session?.header?.cwd ?? process.cwd();
3392
3663
  }
3393
- function renderText$2(value) {
3664
+ function renderText$3(value) {
3394
3665
  return [{
3395
3666
  type: "text",
3396
3667
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3402,7 +3673,7 @@ const localstoreTool = defineTool({
3402
3673
  parameters: {
3403
3674
  action: {
3404
3675
  type: "string",
3405
- enum: [...ACTIONS],
3676
+ enum: [...ACTIONS$1],
3406
3677
  required: true,
3407
3678
  description: "Subcommand: list/get/set/unset/show/doc"
3408
3679
  },
@@ -3422,10 +3693,10 @@ const localstoreTool = defineTool({
3422
3693
  },
3423
3694
  output: {
3424
3695
  schema: { type: "json" },
3425
- render: (args, value) => renderText$2(value)
3696
+ render: (args, value) => renderText$3(value)
3426
3697
  },
3427
3698
  async execute(args, exec) {
3428
- const root = findSerenityRoot(agentCwd$3(exec));
3699
+ const root = findSerenityRoot(agentCwd$4(exec));
3429
3700
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3430
3701
  return runLocalStore(root, args);
3431
3702
  }
@@ -3581,13 +3852,13 @@ function applySkiffConfig(root) {
3581
3852
  *
3582
3853
  * 参数极简(LLM 直觉):domain(管理域)+ action(该域子命令)+ 少量业务参数。
3583
3854
  */
3584
- function agentCwd$2(exec) {
3855
+ function agentCwd$3(exec) {
3585
3856
  return exec.agent?.session?.header?.cwd ?? process.cwd();
3586
3857
  }
3587
3858
  function agentSessionId(exec) {
3588
3859
  return exec.agent?.session?.id ?? "";
3589
3860
  }
3590
- function renderText$1(value) {
3861
+ function renderText$2(value) {
3591
3862
  return [{
3592
3863
  type: "text",
3593
3864
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -3642,10 +3913,10 @@ const containerAdminTool = defineTool({
3642
3913
  },
3643
3914
  output: {
3644
3915
  schema: { type: "json" },
3645
- render: (args, value) => renderText$1(value)
3916
+ render: (args, value) => renderText$2(value)
3646
3917
  },
3647
3918
  async execute(args, exec) {
3648
- const root = findSerenityRoot(agentCwd$2(exec));
3919
+ const root = findSerenityRoot(agentCwd$3(exec));
3649
3920
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
3650
3921
  const sessionId = agentSessionId(exec);
3651
3922
  const domain = args.domain;
@@ -4350,10 +4621,10 @@ const AUTOPILOT_ACTIONS = [
4350
4621
  "guide",
4351
4622
  "diag-live"
4352
4623
  ];
4353
- function agentCwd$1(exec) {
4624
+ function agentCwd$2(exec) {
4354
4625
  return exec.agent?.session?.header?.cwd ?? process.cwd();
4355
4626
  }
4356
- function renderText(value) {
4627
+ function renderText$1(value) {
4357
4628
  return [{
4358
4629
  type: "text",
4359
4630
  text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
@@ -4392,11 +4663,11 @@ function createAutopilotTool(ctx) {
4392
4663
  } },
4393
4664
  output: {
4394
4665
  schema: { type: "json" },
4395
- render: (_args, value) => renderText(value)
4666
+ render: (_args, value) => renderText$1(value)
4396
4667
  },
4397
4668
  async execute(args, exec) {
4398
4669
  if (args.action === "diag-live") return { output: renderDiagLive(diagLive(ctx)) };
4399
- const root = findSerenityRoot(agentCwd$1(exec));
4670
+ const root = findSerenityRoot(agentCwd$2(exec));
4400
4671
  const result = {};
4401
4672
  if (!EXP_SCRIPT) {
4402
4673
  result.error = "autopilot-trajectory 脚本未随安装分发(npm 包缺 experiments/autopilot-trajectory/)——请检查包完整性";
@@ -5876,7 +6147,7 @@ function codeModeAdaptationLine(ctx, scope) {
5876
6147
  * 无 agent → undefined(不注入);有 agent 但无 header.cwd(workflow subagent 等)
5877
6148
  * 回退 process.cwd()——对齐 context.ts,否则这些 agent 系统提示词注入为空
5878
6149
  * (v1.18.6:workflow subagent 无宁静号上下文 bug) */
5879
- function agentCwd(context) {
6150
+ function agentCwd$1(context) {
5880
6151
  const agent = context.agent;
5881
6152
  if (!agent) return void 0;
5882
6153
  return agent.session?.header?.cwd ?? process.cwd();
@@ -5896,7 +6167,7 @@ function registerEntrySkillSectionGlobal(ctx) {
5896
6167
  name: "serenity-entry",
5897
6168
  order: -50,
5898
6169
  text: (context) => {
5899
- const cwd = agentCwd(context);
6170
+ const cwd = agentCwd$1(context);
5900
6171
  if (!cwd) return "";
5901
6172
  const root = findSerenityRoot(cwd);
5902
6173
  if (!root) return "";
@@ -6103,6 +6374,9 @@ function registerContext(ctx, opts = {}) {
6103
6374
  injected.add(key);
6104
6375
  agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
6105
6376
  syncSafeModeRestriction(agent, root);
6377
+ try {
6378
+ syncImBridgeVisibility(agent, root);
6379
+ } catch {}
6106
6380
  };
6107
6381
  if (opts.seedOnStart ?? true) ctx.on("agent/session-start", (payload) => {
6108
6382
  try {
@@ -6120,6 +6394,9 @@ function registerContext(ctx, opts = {}) {
6120
6394
  try {
6121
6395
  syncSafeModeRestriction(agent, root);
6122
6396
  } catch {}
6397
+ try {
6398
+ syncImBridgeVisibility(agent, root);
6399
+ } catch {}
6123
6400
  registerEntrySkillSection(agent, root);
6124
6401
  }
6125
6402
  if (!root || injected.has(key) || downstream.kind !== "enter") return downstream;
@@ -6608,11 +6885,11 @@ function registerStatusApi(ctx, opts = {}) {
6608
6885
  sendJson$2(res, 400, { error: chk.error });
6609
6886
  return;
6610
6887
  }
6611
- const { readWeixinSettings } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6888
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6612
6889
  const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
6613
6890
  const settings = readWeixinSettings(chk.root);
6614
6891
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
6615
- const { readWeixinCredential } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6892
+ const { readWeixinCredential } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6616
6893
  const accounts = (settings.accounts ?? []).map((a) => ({
6617
6894
  accountId: a.accountId,
6618
6895
  name: a.name ?? void 0,
@@ -6641,10 +6918,10 @@ function registerStatusApi(ctx, opts = {}) {
6641
6918
  }
6642
6919
  const root = chk.root;
6643
6920
  if (body.action === "login-start") {
6644
- const { fetchQRCode } = await import("./weixin-api-BMpljodH.js").then((n) => n.l);
6921
+ const { fetchQRCode } = await import("./weixin-api-PjEhmjlZ.js").then((n) => n.u);
6645
6922
  const { randomUUID } = await import("node:crypto");
6646
6923
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
6647
- const { readWeixinSettings } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6924
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6648
6925
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
6649
6926
  const loginKey = randomUUID();
6650
6927
  weixinLogins.set(loginKey, {
@@ -6665,7 +6942,7 @@ function registerStatusApi(ctx, opts = {}) {
6665
6942
  sendJson$2(res, 400, { error: "missing accountId" });
6666
6943
  return;
6667
6944
  }
6668
- const { removeWeixinAccount } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6945
+ const { removeWeixinAccount } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6669
6946
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6670
6947
  removeWeixinAccount(root, body.accountId);
6671
6948
  syncCccBridge(ctx, root);
@@ -6678,7 +6955,7 @@ function registerStatusApi(ctx, opts = {}) {
6678
6955
  sendJson$2(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
6679
6956
  return;
6680
6957
  }
6681
- const { saveWeixinRoutes } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6958
+ const { saveWeixinRoutes } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6682
6959
  const { readSkiffRoles } = await import("./skiff-role-BYvNnwv1.js").then((n) => n.u);
6683
6960
  const roles = readSkiffRoles(root);
6684
6961
  for (const r of routes) if (!roles.has(r.role)) {
@@ -6691,7 +6968,7 @@ function registerStatusApi(ctx, opts = {}) {
6691
6968
  }
6692
6969
  if (body.action === "set-enabled") {
6693
6970
  const enabled = body.enabled === true;
6694
- const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
6971
+ const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6695
6972
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6696
6973
  const settings = readWeixinSettings(root);
6697
6974
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -6737,8 +7014,8 @@ function registerStatusApi(ctx, opts = {}) {
6737
7014
  sendJson$2(res, 200, { status: "expired" });
6738
7015
  return;
6739
7016
  }
6740
- const { pollQRStatus } = await import("./weixin-api-BMpljodH.js").then((n) => n.l);
6741
- const { readWeixinSettings } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
7017
+ const { pollQRStatus } = await import("./weixin-api-PjEhmjlZ.js").then((n) => n.u);
7018
+ const { readWeixinSettings } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6742
7019
  const settings = readWeixinSettings(login.root);
6743
7020
  const status = await pollQRStatus({
6744
7021
  baseUrl: void 0,
@@ -6753,7 +7030,7 @@ function registerStatusApi(ctx, opts = {}) {
6753
7030
  });
6754
7031
  return;
6755
7032
  }
6756
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-BD_yYAZz.js").then((n) => n.u);
7033
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-wWk4AIwV.js").then((n) => n.u);
6757
7034
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
6758
7035
  const accountId = nextWeixinAccountId(settings);
6759
7036
  upsertWeixinAccount(login.root, {
@@ -9185,10 +9462,15 @@ const PLUGIN_SOURCE = {
9185
9462
  plugin: "dsh-serenity-hooks"
9186
9463
  };
9187
9464
  const manualSessions = /* @__PURE__ */ new Map();
9188
- /** 本轮已成功发送的会话(turn-stopping 时结算并清空) */
9465
+ /**
9466
+ * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
9467
+ * v1.30.17 起桥要在 `askSkiff` 返回后读它决定是否兜底,故 turn-stopping 不再结算清空)。
9468
+ */
9189
9469
  const sentThisTurn = /* @__PURE__ */ new Set();
9190
9470
  /** 连续打回计数(成功发送即清零) */
9191
9471
  const rebukeCounts = /* @__PURE__ */ new Map();
9472
+ /** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
9473
+ let guardActive = false;
9192
9474
  /** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
9193
9475
  function noteManualOutputSession(sessionId, session) {
9194
9476
  if (typeof sessionId !== "string" || sessionId === "") return;
@@ -9200,25 +9482,52 @@ function forgetManualOutputSession(sessionId) {
9200
9482
  sentThisTurn.delete(sessionId);
9201
9483
  rebukeCounts.delete(sessionId);
9202
9484
  }
9485
+ /** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
9486
+ function hasSentThisTurn(sessionId) {
9487
+ return sentThisTurn.has(sessionId);
9488
+ }
9489
+ /**
9490
+ * 桥:**每轮开始时**清空本会话的"已发送"标记(turn 边界由桥掌握——v1.30.17)。
9491
+ * 闸门侧不再于 turn-stopping 结算清空,否则桥读不到本轮结果、无法兜底。
9492
+ */
9493
+ function clearSentThisTurn(sessionId) {
9494
+ sentThisTurn.delete(sessionId);
9495
+ }
9496
+ /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
9497
+ function isWeixinOutputGuardActive() {
9498
+ return guardActive;
9499
+ }
9203
9500
  /** 从 agent / 会话对象取会话 id(形状宽容) */
9204
9501
  function sessionIdOf$1(value) {
9205
9502
  const v = value;
9206
9503
  const id = v?.session?.id ?? v?.id;
9207
9504
  return typeof id === "string" && id !== "" ? id : null;
9208
9505
  }
9209
- /** 工具调用是否为「成功的 weixin-send」 */
9506
+ /**
9507
+ * 工具调用是否为「成功的微信发送」。
9508
+ *
9509
+ * v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
9510
+ * - `im-bridge({channel:"weixin", action:"send"|"send-file", …})` —— ACC 工具(推荐)
9511
+ * - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
9512
+ */
9210
9513
  function isSuccessfulWeixinSend(exec, result) {
9211
9514
  const e = exec;
9212
- if (e?.name !== "msm") return false;
9213
- if (e.arguments?.name !== "weixin-send") return false;
9214
- return result?.isError !== true;
9515
+ if (result?.isError === true) return false;
9516
+ const args = e?.arguments;
9517
+ if (e?.name === "im-bridge") {
9518
+ if ((typeof args?.channel === "string" ? args.channel : "") !== "weixin") return false;
9519
+ const action = typeof args?.action === "string" ? args.action : "";
9520
+ return action === "send" || action === "send-file";
9521
+ }
9522
+ if (e?.name === "msm") return args?.name === "weixin-send";
9523
+ return false;
9215
9524
  }
9216
9525
  /** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
9217
9526
  function buildManualOutputRebuke(session) {
9218
9527
  return [
9219
9528
  "[serenity:weixin-manual-output] no send detected in this turn — the user has received NOTHING.",
9220
9529
  `ccc=${session.root} account=${session.accountId} user=${session.userId}`,
9221
- `msm("weixin-send", ["send", "--ccc", "${session.root}", "--account", "${session.accountId}", "--user", "${session.userId}", "<回复>"])`
9530
+ `im-bridge({channel:"weixin", action:"send", account:"${session.accountId}", user:"${session.userId}", text:"<回复>"})`
9222
9531
  ].join("\n");
9223
9532
  }
9224
9533
  /**
@@ -9250,13 +9559,11 @@ function registerWeixinOutputGuard(ctx) {
9250
9559
  if (!sessionId) return;
9251
9560
  const session = manualSessions.get(sessionId);
9252
9561
  if (!session) return;
9253
- const sent = sentThisTurn.has(sessionId);
9254
- sentThisTurn.delete(sessionId);
9255
- if (sent) return;
9562
+ if (sentThisTurn.has(sessionId)) return;
9256
9563
  const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
9257
9564
  if (count > 2) {
9258
9565
  rebukeCounts.delete(sessionId);
9259
- console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——本轮用户将收不到任何回复`);
9566
+ console.warn(`[serenity-hooks] ✗ weixin 输出闸门:连续 2 次打回后仍未发送(session=${sessionId}, role=${session.role})——本轮用户将收不到 agent 自己发的消息`);
9260
9567
  return;
9261
9568
  }
9262
9569
  rebukeCounts.set(sessionId, count);
@@ -9275,7 +9582,9 @@ function registerWeixinOutputGuard(ctx) {
9275
9582
  });
9276
9583
  } catch {
9277
9584
  console.warn("[serenity-hooks] ✗ weixin 输出闸门未装配:agent/turn-stopping 不可用");
9585
+ return;
9278
9586
  }
9587
+ guardActive = true;
9279
9588
  }
9280
9589
  //#endregion
9281
9590
  //#region src/weixin-hook.ts
@@ -9325,7 +9634,8 @@ function buildOutgoingHookEvent(input) {
9325
9634
  sessionId: input.sessionId,
9326
9635
  role: input.role,
9327
9636
  reply: input.reply,
9328
- ...input.source === "proactive" ? { source: "proactive" } : {}
9637
+ ...input.source && input.source !== "reply" ? { source: input.source } : {},
9638
+ ...input.file ? { file: input.file } : {}
9329
9639
  };
9330
9640
  }
9331
9641
  /**
@@ -9439,7 +9749,9 @@ function invokeWeixinHook(root, hookRel, event) {
9439
9749
  //#region src/weixin-bridge.ts
9440
9750
  var weixin_bridge_exports = /* @__PURE__ */ __exportAll({
9441
9751
  handleIncoming: () => handleIncoming,
9752
+ manualOutputFallbackNeeded: () => manualOutputFallbackNeeded,
9442
9753
  registerWeixinBridge: () => registerWeixinBridge,
9754
+ resolveWeixinAccount: () => resolveWeixinAccount,
9443
9755
  sendProactiveText: () => sendProactiveText,
9444
9756
  stopAllBridges: () => stopAllBridges,
9445
9757
  stopCccBridge: () => stopCccBridge,
@@ -9537,14 +9849,26 @@ async function runAccountLoop(ctx, root, accountId, cred, loop) {
9537
9849
  * "约束不够,LLM 不听"——且措辞迭代要动插件代码 + 发版 + 重启;把措辞放 CCC 才能快速迭代,
9538
9850
  * 也符合"ACC 管机制、CCC 管内容"的归属二分。
9539
9851
  *
9540
- * 标记格式(稳定,供 CCC 提示词引用):
9852
+ * 标记格式(稳定,供 CCC 提示词引用;v1.31.0 起为 ACC 工具形态):
9541
9853
  * ```
9542
9854
  * [serenity:weixin-manual-output]
9543
- * msm("weixin-send", ["send", "--ccc", "<root>", "--account", "<account>", "--user", "<user>", "<回复>"])
9855
+ * im-bridge({channel:"weixin", action:"send", account:"<account>", user:"<user>", text:"<回复>"})
9544
9856
  * ```
9545
9857
  */
9546
9858
  function weixinManualOutputMarker(root, accountId, userId) {
9547
- return ["[serenity:weixin-manual-output]", `msm("weixin-send", ["send", "--ccc", "${root}", "--account", "${accountId}", "--user", "${userId}", "<回复>"])`].join("\n");
9859
+ return ["[serenity:weixin-manual-output]", `im-bridge({channel:"weixin", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
9860
+ }
9861
+ /**
9862
+ * 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
9863
+ *
9864
+ * 四个条件同时成立才兜底:
9865
+ * ① CCC 显式开启 `fallbackOnNoSend`(缺省 false = 严格静默,向后兼容)
9866
+ * ② 机械闸门**已装配**(未装配时无法判定"是否发送过" → 宁可静默也不冒重复发送风险)
9867
+ * ③ 本轮**没有**成功发送(agent 自己发过 → 输出权归 agent,桥不插话)
9868
+ * ④ 有最终文本可转发(空文本无意义)
9869
+ */
9870
+ function manualOutputFallbackNeeded(input) {
9871
+ return input.fallbackOnNoSend === true && input.guardActive && !input.sentThisTurn && input.answer !== "";
9548
9872
  }
9549
9873
  /**
9550
9874
  * 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
@@ -9666,6 +9990,7 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
9666
9990
  if (text) parts.push(text);
9667
9991
  parts.push(...mediaNotes, ...degradedNotes);
9668
9992
  if (manualOutput) {
9993
+ clearSentThisTurn(sessionId);
9669
9994
  noteManualOutputSession(sessionId, {
9670
9995
  root,
9671
9996
  accountId,
@@ -9688,7 +10013,35 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
9688
10013
  console.log(`[serenity-hooks] weixin hook incoming error: ${err instanceof Error ? err.message : String(err)}`);
9689
10014
  });
9690
10015
  const answer = (await askSkiff(ctx, ref.agent, question, void 0, { includeTrajectory: false })).answer ?? "";
9691
- if (manualOutput) return;
10016
+ if (manualOutput) {
10017
+ if (!manualOutputFallbackNeeded({
10018
+ fallbackOnNoSend: settings.fallbackOnNoSend,
10019
+ guardActive: isWeixinOutputGuardActive(),
10020
+ sentThisTurn: hasSentThisTurn(sessionId),
10021
+ answer
10022
+ })) return;
10023
+ const reply = markdownToPlainText(stripThink(answer));
10024
+ await sendTextMessage({
10025
+ baseUrl: cred.baseUrl,
10026
+ token: cred.token,
10027
+ toUserId: fromUserId,
10028
+ text: reply,
10029
+ contextToken: msg.context_token
10030
+ });
10031
+ console.warn(`[serenity-hooks] ⚠ weixin 输出兜底:agent 本轮未发送(闸门打回用尽)→ 桥转发最终文本(session=${sessionId}, role=${roleName})`);
10032
+ if (hookRel) invokeWeixinHook(root, hookRel, buildOutgoingHookEvent({
10033
+ cccRoot: root,
10034
+ accountId,
10035
+ userId: fromUserId,
10036
+ sessionId,
10037
+ role: roleName,
10038
+ reply,
10039
+ source: "reply-fallback"
10040
+ })).catch((err) => {
10041
+ console.log(`[serenity-hooks] weixin hook outgoing(reply-fallback) error: ${err instanceof Error ? err.message : String(err)}`);
10042
+ });
10043
+ return;
10044
+ }
9692
10045
  if (answer === "") return;
9693
10046
  const reply = markdownToPlainText(stripThink(answer));
9694
10047
  await sendTextMessage({
@@ -9765,22 +10118,12 @@ function stopAllBridges() {
9765
10118
  bridges.clear();
9766
10119
  }
9767
10120
  /**
9768
- * 以 bot 身份主动给指定用户发文本(**不经用户消息触发**)。
9769
- *
9770
- * 为什么放在桥里而不是让 CCC 自己直连 iLink(用户拍板 A 方案):
9771
- * ① **记录归桥**——发送成功后触发既有 outgoing hook,`_weixin-logs/*.jsonl` 与对话回复
9772
- * 同源同格式(事件多一个 `source: 'proactive'` 标记);② 协议/账号/凭据解析单一真相源,
9773
- * CCC 侧不必重复实现 sendmessage;③ 多账号可选。
9774
- *
9775
- * 记录一致性:`sessionId` 取 `weixinSessionIdFor(toUserId)`——与该用户平时对话**同一条轨迹**,
9776
- * 记录里能直接按会话串联;`role` 取该 CCC 路由命中角色(未命中 → 空串)。
9777
- *
9778
- * 主动消息不带 context_token(iLink 接受 bot 主动发起;带 token 的路径见 handleIncoming 回复)。
9779
- * @param input CCC 根 + 目标用户 + 文本 +(可选)账号
9780
- * @returns 成功含 accountId/userId/sessionId/role;失败含稳定 code 与可行动提示(不抛错)
10121
+ * 解析该 CCC 的发送账号 + 凭据(v1.31.0:文本与文件发送的**单一账号选择真相源**)。
10122
+ * 顺序:桥启用 → 有启用账号 → 指定账号存在 → 凭据已绑定(扫码)。
10123
+ * @param root CCC 根
10124
+ * @param accountId 指定账号(缺省 = 第一个启用账号)
9781
10125
  */
9782
- async function sendProactiveText(input) {
9783
- const { root, toUserId } = input;
10126
+ function resolveWeixinAccount(root, accountId) {
9784
10127
  const settings = readWeixinSettings(root);
9785
10128
  if (!settings.enabled) return {
9786
10129
  ok: false,
@@ -9795,20 +10138,32 @@ async function sendProactiveText(input) {
9795
10138
  error: "微信桥未配置任何启用账号",
9796
10139
  remediation: "在设置面板「微信桥」扫码绑定账号"
9797
10140
  };
9798
- const accountId = input.accountId ?? accounts[0].accountId;
9799
- if (!accounts.some((a) => a.accountId === accountId)) return {
10141
+ const id = accountId ?? accounts[0].accountId;
10142
+ if (!accounts.some((a) => a.accountId === id)) return {
9800
10143
  ok: false,
9801
10144
  code: "ACCOUNT_NOT_FOUND",
9802
- error: `账号不存在或未启用: ${accountId}`,
10145
+ error: `账号不存在或未启用: ${id}`,
9803
10146
  remediation: `可用账号: ${accounts.map((a) => a.accountId).join(", ")}`
9804
10147
  };
9805
- const cred = readWeixinCredential(root, accountId);
10148
+ const cred = readWeixinCredential(root, id);
9806
10149
  if (!cred) return {
9807
10150
  ok: false,
9808
10151
  code: "ACCOUNT_NOT_BOUND",
9809
- error: `账号未绑定(无凭据): ${accountId}`,
10152
+ error: `账号未绑定(无凭据): ${id}`,
9810
10153
  remediation: "在设置面板重新扫码绑定"
9811
10154
  };
10155
+ return {
10156
+ ok: true,
10157
+ accountId: id,
10158
+ cred
10159
+ };
10160
+ }
10161
+ async function sendProactiveText(input) {
10162
+ const { root, toUserId } = input;
10163
+ const settings = readWeixinSettings(root);
10164
+ const account = resolveWeixinAccount(root, input.accountId);
10165
+ if (!account.ok) return account;
10166
+ const { accountId, cred } = account;
9812
10167
  const sessionId = weixinSessionIdFor(toUserId);
9813
10168
  const role = matchWeixinRoute(settings.routes ?? [], toUserId) ?? "";
9814
10169
  try {
@@ -9884,7 +10239,9 @@ function registerWeixinBridge(ctx) {
9884
10239
  * 而 3081 的登录态属于"家庭账号"——一旦挂上去,任何已登录的外部用户都能冒充 bot 发消息。
9885
10240
  * 独立 loopback 监听器则**不经网关、公网不可达**,因此不做共享密钥;仍校验来源地址。
9886
10241
  *
9887
- * 调用者 = CCC 自己的 MSM(`msm weixin-send ...`):ACC **不新增工具**,只多一个本机 HTTP 面。
10242
+ * 调用者 = **非 agent 的调用者**(外部脚本/集成/运维)。**agent 侧已不走这里**:v1.31.0 起
10243
+ * agent 统一调 `im-bridge` 工具(进程内直调 `sendProactiveText`,无 HTTP 一跳),旧 CCC MSM
10244
+ * `weixin-send` 已退役。本入口保留 = 给容器外的进程一个稳定的本机发送面。
9888
10245
  * 发送与记录都经 `weixin-bridge.sendProactiveText` → 复用既有 outgoing hook(`source: 'proactive'`)。
9889
10246
  *
9890
10247
  * 契约:
@@ -10250,6 +10607,9 @@ function cleanupSessionState(sessionId) {
10250
10607
  try {
10251
10608
  forgetManualOutputSession(sessionId);
10252
10609
  } catch {}
10610
+ try {
10611
+ forgetImBridgeVisibility(sessionId);
10612
+ } catch {}
10253
10613
  }
10254
10614
  /**
10255
10615
  * 装配生命周期订阅与资源拆卸。
@@ -10473,6 +10833,243 @@ async function registerWebFetchProvider(ctx) {
10473
10833
  }
10474
10834
  }
10475
10835
  //#endregion
10836
+ //#region src/im-weixin.ts
10837
+ /**
10838
+ * im-weixin.ts — 微信通道实现(`im-bridge` 的通道之一,v1.31.0)
10839
+ *
10840
+ * 能力归 ACC(S142 用户洞察):本文件吸收原 CCC MSM `.opencode/skills/home-serenity/scripts/
10841
+ * weixin-send.ts` 的 `send` / `send-file` / `users` 语义——协议、凭据、账号选择、别名解析、
10842
+ * 记录全部落在 ACC 侧;CCC 只保留「何时发 / 发什么 / 怎么写」。
10843
+ *
10844
+ * 通道内部契约:
10845
+ * - `isEnabled(root)` = 该 CCC 的 `weixin.enabled`(可见性判据,im-bridge 用它决定工具是否可见)
10846
+ * - `send` 复用 `sendProactiveText`(唯一文本发送实现 + 唯一记录点)
10847
+ * - `sendFile` 走 iLink 上传(`weixin-api.sendFileMessage`)+ 同一 outgoing hook(带 `file` 元数据)
10848
+ */
10849
+ /**
10850
+ * 别名 → CCC localstore 凭据键(与 v1.30.9 CCC MSM 逐字一致,迁移零认知成本)。
10851
+ * 别名解析属"数据填值",归 ACC(D23)。
10852
+ */
10853
+ const WEIXIN_ALIAS_MAP = {
10854
+ yh: "WEIXIN_USER_YH",
10855
+ danica: "WEIXIN_USER_DANICA",
10856
+ xiaowang: "WEIXIN_USER_DANICA"
10857
+ };
10858
+ /** 读该 CCC 的一个凭据值(空/缺失 → null) */
10859
+ function credentialValue(root, key) {
10860
+ try {
10861
+ const value = readStore(root, "credential")[key];
10862
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
10863
+ } catch {
10864
+ return null;
10865
+ }
10866
+ }
10867
+ /** 把通道层的失败码转成一句可读错误(工具面统一映射为 SEND_FAILED) */
10868
+ function channelError(code, error, remediation) {
10869
+ return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
10870
+ }
10871
+ const weixinChannel = {
10872
+ id: "weixin",
10873
+ isEnabled(root) {
10874
+ return readWeixinSettings(root).enabled === true;
10875
+ },
10876
+ resolveUser(root, input) {
10877
+ const raw = input.trim();
10878
+ if (raw === "") return null;
10879
+ const alias = raw.toLowerCase();
10880
+ const key = WEIXIN_ALIAS_MAP[alias];
10881
+ if (key) {
10882
+ const id = credentialValue(root, key);
10883
+ return id ? {
10884
+ alias,
10885
+ id
10886
+ } : null;
10887
+ }
10888
+ if (raw.includes("@")) return {
10889
+ alias: raw,
10890
+ id: raw
10891
+ };
10892
+ return null;
10893
+ },
10894
+ listUsers(root) {
10895
+ const out = [];
10896
+ for (const [alias, key] of Object.entries(WEIXIN_ALIAS_MAP)) {
10897
+ const id = credentialValue(root, key);
10898
+ if (id) out.push({
10899
+ alias,
10900
+ id
10901
+ });
10902
+ }
10903
+ return out;
10904
+ },
10905
+ async send(input) {
10906
+ const result = await sendProactiveText({
10907
+ root: input.root,
10908
+ toUserId: input.userId,
10909
+ text: input.text,
10910
+ ...input.accountId ? { accountId: input.accountId } : {}
10911
+ });
10912
+ if (!result.ok) throw channelError(result.code, result.error, result.remediation);
10913
+ return {
10914
+ accountId: result.accountId,
10915
+ userId: result.userId,
10916
+ sessionId: result.sessionId,
10917
+ role: result.role
10918
+ };
10919
+ },
10920
+ async sendFile(input) {
10921
+ const settings = readWeixinSettings(input.root);
10922
+ if (!settings.enabled) throw channelError("BRIDGE_DISABLED", `微信桥未启用(ccc=${input.root})`);
10923
+ const account = resolveWeixinAccount(input.root, input.accountId);
10924
+ if (!account.ok) throw channelError(account.code, account.error, account.remediation);
10925
+ const sent = await sendFileMessage({
10926
+ baseUrl: account.cred.baseUrl,
10927
+ token: account.cred.token,
10928
+ toUserId: input.userId,
10929
+ data: input.data,
10930
+ fileName: input.fileName
10931
+ });
10932
+ if (input.caption) await sendTextMessage({
10933
+ baseUrl: account.cred.baseUrl,
10934
+ token: account.cred.token,
10935
+ toUserId: input.userId,
10936
+ text: input.caption
10937
+ });
10938
+ if (settings.hook) {
10939
+ const role = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
10940
+ invokeWeixinHook(input.root, settings.hook, buildOutgoingHookEvent({
10941
+ cccRoot: input.root,
10942
+ accountId: account.accountId,
10943
+ userId: input.userId,
10944
+ sessionId: weixinSessionIdFor(input.userId),
10945
+ role,
10946
+ reply: input.caption ? `[文件] ${sent.fileName} — ${input.caption}` : `[文件] ${sent.fileName}`,
10947
+ source: "proactive",
10948
+ file: {
10949
+ name: sent.fileName,
10950
+ size: sent.size,
10951
+ ...input.caption ? { caption: input.caption } : {}
10952
+ }
10953
+ })).catch((err) => {
10954
+ console.log(`[serenity-hooks] weixin hook outgoing(file) error: ${err instanceof Error ? err.message : String(err)}`);
10955
+ });
10956
+ }
10957
+ return sent;
10958
+ },
10959
+ /**
10960
+ * 通道健康快照(`status` 动作)。
10961
+ *
10962
+ * 显式映射为 JSON 安全形状(而非把内部结构直接返回):内部类型含 `undefined`
10963
+ * 可选字段与 interface(无隐式索引签名)→ 直接返回既过不了 `ImJson` 类型门,
10964
+ * 也会让工具返回值出现不可序列化字段。缺省用条件展开去掉 undefined。
10965
+ */
10966
+ status(root) {
10967
+ const settings = readWeixinSettings(root);
10968
+ const accounts = weixinBridgeStatus().filter((entry) => entry.ccc === root).flatMap((entry) => entry.accounts.map((account) => ({
10969
+ accountId: account.accountId,
10970
+ lastPollAt: account.lastPollAt,
10971
+ ...account.lastError ? { lastError: account.lastError } : {}
10972
+ })));
10973
+ const routes = (settings.routes ?? []).map((route) => ({
10974
+ user: route.user,
10975
+ role: route.role
10976
+ }));
10977
+ return {
10978
+ enabled: settings.enabled === true,
10979
+ autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
10980
+ fallbackOnNoSend: settings.fallbackOnNoSend === true,
10981
+ routes,
10982
+ accounts
10983
+ };
10984
+ }
10985
+ };
10986
+ //#endregion
10987
+ //#region src/tools/im-bridge.ts
10988
+ /**
10989
+ * im-bridge.ts(工具面)— IM 消息发送工具(v1.31.0)
10990
+ *
10991
+ * 用户洞察(S142):微信桥是 ACC 提供的 → **发送能力也应是 ACC 的工具**;
10992
+ * 「当用户配置了微信桥则可用,不配置则不可见」。
10993
+ *
10994
+ * 本文件只做工具面三件事:① 从 agent 会话解析**本会话 CCC**(R4:不接受目标 CCC)
10995
+ * ② 把参数交给 `im-bridge.ts`(能力层)分发 ③ 渲染结果/错误。
10996
+ *
10997
+ * 可见性不在这里判定:`seams/context.ts` 在会话就绪时按 CCC 配置
10998
+ * `agent.ctx.tools.restrict({ deny: ['im-bridge'] })` 把本工具从**未配置通道**的
10999
+ * agent 工具清单中移除(未配置 = 模型看不到,而非"看得到但被拒")。
11000
+ */
11001
+ const ACTIONS = [
11002
+ "send",
11003
+ "send-file",
11004
+ "users",
11005
+ "status"
11006
+ ];
11007
+ function agentCwd(exec) {
11008
+ return exec.agent?.session?.header?.cwd ?? process.cwd();
11009
+ }
11010
+ function renderText(value) {
11011
+ return [{
11012
+ type: "text",
11013
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
11014
+ }];
11015
+ }
11016
+ /**
11017
+ * IM 发送工具(`im-bridge`)。通道维度为将来其他 IM 预留(R3)——
11018
+ * 新增通道只注册实现,不改工具名与参数形状。
11019
+ */
11020
+ function createImBridgeTool() {
11021
+ return defineTool({
11022
+ name: "im-bridge",
11023
+ description: `Send messages to family members through an IM channel configured for THIS container (currently: weixin / 微信). Every successful send is recorded in the container log automatically; this tool only acts on the current container (no target container parameter). It is hidden entirely when this container has no IM channel configured. Channels: ${imChannelIds().join(", ") || "(none registered)"}. Actions: send (text) / send-file (a file inside this container) / users (list configured recipients) / status (channel health). user accepts a configured alias (e.g. yh, danica) or a raw channel user id. Failures return a stable code plus a remediation hint.`,
11024
+ parameters: {
11025
+ channel: {
11026
+ type: "string",
11027
+ description: `IM channel id (e.g. "weixin"); available: ${imChannelIds().join(", ") || "(none)"}`,
11028
+ required: true
11029
+ },
11030
+ action: {
11031
+ type: "string",
11032
+ enum: [...ACTIONS],
11033
+ required: true,
11034
+ description: "send / send-file / users / status"
11035
+ },
11036
+ user: {
11037
+ type: "string",
11038
+ description: "Recipient: configured alias (yh, danica) or raw channel user id (send / send-file)"
11039
+ },
11040
+ text: {
11041
+ type: "string",
11042
+ description: "Message text, plain text only (send)"
11043
+ },
11044
+ file: {
11045
+ type: "string",
11046
+ description: "File path inside this container, relative to the container root (send-file)"
11047
+ },
11048
+ caption: {
11049
+ type: "string",
11050
+ description: "Optional caption sent as a separate message (send-file)"
11051
+ },
11052
+ account: {
11053
+ type: "string",
11054
+ description: "Channel account id (default: the first enabled, bound account)"
11055
+ }
11056
+ },
11057
+ output: {
11058
+ schema: { type: "json" },
11059
+ render: (_args, value) => renderText(value)
11060
+ },
11061
+ async execute(args, exec) {
11062
+ const root = findSerenityRoot(agentCwd(exec));
11063
+ if (!root) return {
11064
+ ok: false,
11065
+ code: "CCC_UNRESOLVED",
11066
+ error: "this session is not inside a Serenity container (no .serenity marker found from the session cwd)"
11067
+ };
11068
+ return runImBridge(root, args);
11069
+ }
11070
+ });
11071
+ }
11072
+ //#endregion
10476
11073
  //#region src/index.ts
10477
11074
  const name = "dsh-serenity-hooks";
10478
11075
  /** 主动调用的服务;其余(agent 事件)随 harness 装配必然存在
@@ -10522,6 +11119,7 @@ function apply(ctx, config) {
10522
11119
  const report = probeHostContract(ctx, readDshVersion());
10523
11120
  if (report.issues.length > 0) console.warn(`[serenity-hooks] ${summarizeHostContract(report)}`);
10524
11121
  } catch {}
11122
+ registerImChannel(weixinChannel);
10525
11123
  if (config.tools) {
10526
11124
  ctx.tools.register(ccFsTool);
10527
11125
  ctx.tools.register(createSessionTool(ctx));
@@ -10533,6 +11131,7 @@ function apply(ctx, config) {
10533
11131
  ctx.tools.register(localstoreTool);
10534
11132
  ctx.tools.register(containerAdminTool);
10535
11133
  ctx.tools.register(createAutopilotTool(ctx));
11134
+ ctx.tools.register(createImBridgeTool());
10536
11135
  }
10537
11136
  if (config.guards) registerGuards(ctx, { configPaths: config.serenityConfigPaths });
10538
11137
  if (config.keeper) registerKeeper(ctx, {