@zaofan/dsh-qqbot 1.8.6 → 1.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC3F,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/shared/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,aAAa,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,UAAU,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5J,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC"}
@@ -17,6 +17,40 @@ export declare function buildUserAgent(): string;
17
17
  * @param baseDir - 起始目录,缺省为插件根(可注入便于测试)
18
18
  */
19
19
  export declare function getProfileDir(baseDir?: string): string | null;
20
+ /**
21
+ * 纯「代码位置」法:从 `baseDir` 向上找 `node_modules`,返回它的父目录(= profile 根)。
22
+ *
23
+ * 为什么单独抽出来(2026-10-12):本机 `~/.dsh/profiles/` 下**多个 profile 都装了本插件**
24
+ * (实测 desktop / qqnew / qqtest / web 四个),`scanDshProfiles()` 返回的是 readdir 顺序里的
25
+ * **第一个**,未必是当前正在跑的那个。而「本文件自己所在的位置」永远只指向**当前这份安装**,
26
+ * 所以判断「我是哪个 profile」时应**优先用本法**,扫描只作兜底(见 `resolveProfileName`)。
27
+ */
28
+ export declare function getProfileDirByLocation(baseDir?: string): string | null;
29
+ /**
30
+ * 从 profile 根目录名推出 profile 名;判据是该目录的**父目录必须叫 `profiles`**。
31
+ *
32
+ * 加这条判据是为了挡住「源码 checkout 目录」:开发态下 `getProfileDirByLocation()` 会返回
33
+ * 仓库上层的普通目录(如 `.../aiwork/<某工作区>`),把它当 profile 名会写出一个莫名其妙的
34
+ * `qqbot-settings/<工作区名>/`。父目录不是 `profiles` 就一律不认 → 回落 `_default`(旧行为)。
35
+ */
36
+ export declare function profileNameFromDir(dir: string | null | undefined): string;
37
+ /**
38
+ * 当前实例的 **profile 名** —— 设置按 profile 隔离(`qqbot-settings/<profile>/<ns>.json`)用。
39
+ *
40
+ * ⚠️ 2026-10-12 修的坑:v1.8.4 起 profile 名取自 `process.env.DSH_PROFILE`,但**宿主不会把这个
41
+ * 变量放进自己的环境**:DSH 是通过 `ctx.get('profileContext').name` 提供 profile 名的,
42
+ * `DSH_PROFILE` 只在**每次 shell / 工具调用**时由 harness 临时注入(见 `@deepseek-ai/dsh-shell-env`:
43
+ * `values[DSH_PROFILE_KEY] = profile.name`,且该命名空间"每次 shell 调用重建、ambient DSH_* 被丢弃")。
44
+ * 结果:插件在宿主进程里读**永远是空** → **永远落 `_default/`**,"web 与 desktop 各写各的"根本没生效
45
+ * (实测证据:`qqbot-settings/_default/<ns>.writer.json` 里记的是 `"profile": "unknown"`)。
46
+ *
47
+ * 现在改为复用既有的 profile 定位链,取**目录名**当 profile 名:
48
+ * ① `DSH_PROFILE` 环境变量(显式指定 / 宿主确实传了时优先尊重)
49
+ * ② 代码位置(实体安装时唯一确定:包根上溯到 `node_modules` 的父目录)
50
+ * ③ 宿主扫描(link / symlink 安装的兜底,多 profile 时有歧义风险)
51
+ * 拿不到返回 `''`,调用方(`nsPathsOf` / `settings-host.js`)落 `_default` —— 与旧行为一致,不会更糟。
52
+ */
53
+ export declare function resolveProfileName(): string;
20
54
  /**
21
55
  * 解析环境变量占位配置
22
56
  */
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/shared/utils.ts"],"names":[],"mappings":"AAuCA;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,MAAoB,GAAG,MAAM,GAAG,IAAI,CAkB1E;AA2CD;;GAEG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAKtE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAOtD"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/shared/utils.ts"],"names":[],"mappings":"AAuCA;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,MAAoB,GAAG,MAAM,GAAG,IAAI,CAW1E;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,MAAoB,GAAG,MAAM,GAAG,IAAI,CASpF;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAOzE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAM3C;AA2CD;;GAEG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAKtE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAOtD"}
@@ -63,6 +63,17 @@ export function getProfileDir(baseDir = PLUGIN_ROOT) {
63
63
  if (scanned)
64
64
  return scanned;
65
65
  // 2) 兜底:代码位置向上找 node_modules 的父目录
66
+ return getProfileDirByLocation(baseDir);
67
+ }
68
+ /**
69
+ * 纯「代码位置」法:从 `baseDir` 向上找 `node_modules`,返回它的父目录(= profile 根)。
70
+ *
71
+ * 为什么单独抽出来(2026-10-12):本机 `~/.dsh/profiles/` 下**多个 profile 都装了本插件**
72
+ * (实测 desktop / qqnew / qqtest / web 四个),`scanDshProfiles()` 返回的是 readdir 顺序里的
73
+ * **第一个**,未必是当前正在跑的那个。而「本文件自己所在的位置」永远只指向**当前这份安装**,
74
+ * 所以判断「我是哪个 profile」时应**优先用本法**,扫描只作兜底(见 `resolveProfileName`)。
75
+ */
76
+ export function getProfileDirByLocation(baseDir = PLUGIN_ROOT) {
66
77
  let dir = baseDir;
67
78
  for (let i = 0; i < 32; i++) {
68
79
  if (basename(dir) === 'node_modules')
@@ -74,6 +85,48 @@ export function getProfileDir(baseDir = PLUGIN_ROOT) {
74
85
  }
75
86
  return null;
76
87
  }
88
+ /**
89
+ * 从 profile 根目录名推出 profile 名;判据是该目录的**父目录必须叫 `profiles`**。
90
+ *
91
+ * 加这条判据是为了挡住「源码 checkout 目录」:开发态下 `getProfileDirByLocation()` 会返回
92
+ * 仓库上层的普通目录(如 `.../aiwork/<某工作区>`),把它当 profile 名会写出一个莫名其妙的
93
+ * `qqbot-settings/<工作区名>/`。父目录不是 `profiles` 就一律不认 → 回落 `_default`(旧行为)。
94
+ */
95
+ export function profileNameFromDir(dir) {
96
+ if (!dir)
97
+ return '';
98
+ try {
99
+ return basename(dirname(dir)) === 'profiles' ? basename(dir) : '';
100
+ }
101
+ catch {
102
+ return '';
103
+ }
104
+ }
105
+ /**
106
+ * 当前实例的 **profile 名** —— 设置按 profile 隔离(`qqbot-settings/<profile>/<ns>.json`)用。
107
+ *
108
+ * ⚠️ 2026-10-12 修的坑:v1.8.4 起 profile 名取自 `process.env.DSH_PROFILE`,但**宿主不会把这个
109
+ * 变量放进自己的环境**:DSH 是通过 `ctx.get('profileContext').name` 提供 profile 名的,
110
+ * `DSH_PROFILE` 只在**每次 shell / 工具调用**时由 harness 临时注入(见 `@deepseek-ai/dsh-shell-env`:
111
+ * `values[DSH_PROFILE_KEY] = profile.name`,且该命名空间"每次 shell 调用重建、ambient DSH_* 被丢弃")。
112
+ * 结果:插件在宿主进程里读**永远是空** → **永远落 `_default/`**,"web 与 desktop 各写各的"根本没生效
113
+ * (实测证据:`qqbot-settings/_default/<ns>.writer.json` 里记的是 `"profile": "unknown"`)。
114
+ *
115
+ * 现在改为复用既有的 profile 定位链,取**目录名**当 profile 名:
116
+ * ① `DSH_PROFILE` 环境变量(显式指定 / 宿主确实传了时优先尊重)
117
+ * ② 代码位置(实体安装时唯一确定:包根上溯到 `node_modules` 的父目录)
118
+ * ③ 宿主扫描(link / symlink 安装的兜底,多 profile 时有歧义风险)
119
+ * 拿不到返回 `''`,调用方(`nsPathsOf` / `settings-host.js`)落 `_default` —— 与旧行为一致,不会更糟。
120
+ */
121
+ export function resolveProfileName() {
122
+ const env = (process.env.DSH_PROFILE ?? '').trim();
123
+ if (env)
124
+ return env;
125
+ const byLoc = profileNameFromDir(getProfileDirByLocation());
126
+ if (byLoc)
127
+ return byLoc;
128
+ return profileNameFromDir(getProfileDir());
129
+ }
77
130
  /** 遍历 ~/.dsh/profiles/<name>,返回装着本插件的 profile 根目录(找不到返回 null) */
78
131
  function scanDshProfiles() {
79
132
  // 与宿主保持一致: 优先环境变量 DSH_HOME, 否则回落 ~/.dsh
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/shared/utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/shared/utils.ts"],"names":[],"mappings":"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"}
@@ -674,7 +674,7 @@ if (finallyDone) {
674
674
  `· 触发方式:${reason}\n· 签到人数:${names.length} 人\n` +
675
675
  `· 名单(按先后):${names.map((n, i) => `${i + 1}. ${n}`).join("、")}\n\n` +
676
676
  `统计文本已经由卡片直接发到群里了(群友已能看到)。` +
677
- `你只需要用鲸鱼娘的口吻补一句简短回应,不需要把名单再复述一遍。`
677
+ `你只需要用人设的口吻补一句简短回应,不需要把名单再复述一遍。`
678
678
  )
679
679
  }
680
680
  ```
@@ -46,6 +46,28 @@
46
46
  | 抓取通道 | 先走 Bing 的 RSS 端点(`?format=rss`,解析最稳、不需要 key),失败回退 HTML 抓取;国内可直连 `cn.bing.com` |
47
47
  | 暴露范围 | 声明了 `expose: 'all'` ⇒ 全部会话可用(含 web / 终端);只想在 QQ 会话可见就删掉那一行 |
48
48
 
49
+ ## 跨会话四件套 — `session_create` / `session_send` / `session_watch` / `sessions_list`
50
+
51
+ 演示「**让一个会话去开另一个会话、把话投过去、再把结果收回来**」。四个配合用;`lib/` 里是它们
52
+ 按 mtime 版本化加载的内部模块(**子目录不参与工具扫描**,放那里最安全;插件铺设时也会连子目录一起搬)。
53
+
54
+ | 工具 | 作用 | 关键点 |
55
+ |---|---|---|
56
+ | `session_create` | 建**子会话**并把初始任务投进去 | 可指定工作区与人设 —— 工作区名、人设的**显示名或 id** 都认(忽略大小写、唯一子串也认) |
57
+ | `session_send` | 往**任意会话**投消息(发送工具) | 照抄本插件的**入站模式**:忙时插话(`inject`)/ 空闲唤醒(`followup`);首行带来源会话 id |
58
+ | `session_watch` | 挂 / 摘「回合结束回灌」钩子 | 子会话跑完自动把末段正文送回来;`action:"list"` 看钩子名单 |
59
+ | `sessions_list` | 列工作区与会话(只读) | 工作区按**子串**过滤(注意「A」也会命中「AB」) |
60
+
61
+ **三条经验(都是踩出来的)**
62
+
63
+ 1. **别自己拼消息对象** —— 往会话塞消息用宿主原语(`agent.inject` / `agent.followup`);
64
+ 手拼 `user/message` 容易写坏会话(本项目实测触发过 `agent/inbox/spliced requires message objects`,
65
+ 只能靠离线修数据恢复)。
66
+ 2. **投递前先 resume** —— 非 live 的会话直接 `append` 会被**静默丢弃**(不报错,最难查的一种)。
67
+ 3. **可见性靠 `expose` 控** —— `expose:'all'` 会被挂到插件根、**所有会话都能看到**(含 web / 终端);
68
+ 不写该字段(或写别的值)则**只在 QQ 会话注册**,子会话看不到。建议:建会话/挂钩子这类
69
+ **父会话专用**件不给子会话看(免得无限套娃),只把「投递/回执」那一个开放给子会话。
70
+
49
71
  ## 写自己的「按钮回调」工具,只要记住三件事
50
72
 
51
73
  1. **发卡前登记卡片**(不登记 = 按钮点了没人认):
@@ -0,0 +1,85 @@
1
+ /**
2
+ * preset_resolve.mjs — 把「人设」输入解析成宿主的 preset **id**
3
+ *
4
+ * ── 为什么单独成库(2026-10-12)────────────────────────────────────────────
5
+ * 解析规则还会变,而工具文件**一旦落名就不能再改**(宿主扫描后按 `name@tag`
6
+ * 幂等跳过,改了不生效 ⇒ 只能再升一个版本号)。把易变逻辑放这里,用
7
+ * `?v=<mtimeMs>` 版本化加载,改本文件即时生效,工具名不用动。
8
+ *
9
+ * ── 主人指定的匹配顺序(2026-10-12)──────────────────────────────────────
10
+ * 收**任何字符**,依次尝试:
11
+ * ① 预设名(显示名)精确匹配 → 反查 id
12
+ * ② id 精确匹配
13
+ * ③ 两者的「忽略大小写」精确匹配
14
+ * ④ 唯一子串匹配(名或 id 包含输入)——唯一才用,多个则报歧义
15
+ * 都失败 → 返回 error,并把可用清单带上(供调用方直接回给用户)。
16
+ *
17
+ * 输入的容忍度:去首尾空白;剥掉成对的「」『』【】《》""'' 方括号/引号。
18
+ */
19
+ export const __LIB_VERSION__ = 'preset-resolve-v1-20261012-先名后id';
20
+
21
+ /** 归一化输入:trim + 剥掉包裹的引号/书名号 */
22
+ export function normPresetKey(s) {
23
+ let t = String(s ?? '').trim();
24
+ for (let i = 0; i < 3; i += 1) {
25
+ const before = t;
26
+ t = t.replace(/^[「『【《〈((\["'“‘]+/, '').replace(/[」』】》〉))\]"'”’]+$/, '').trim();
27
+ if (t === before) break;
28
+ }
29
+ return t;
30
+ }
31
+
32
+ /** 取全部预设(id + 显示名)——权威来源是宿主服务 `ctx.agentPresets` */
33
+ export async function listPresets(root) {
34
+ const get = (k) => { try { return root?.get?.(k); } catch { return undefined; } };
35
+ const reg = get('agentPresets');
36
+ if (!reg || typeof reg.list !== 'function') return [];
37
+ let all = [];
38
+ try { all = (await reg.list()) || []; } catch { return []; }
39
+ if (!Array.isArray(all)) return [];
40
+ return all
41
+ .map((p) => ({ id: String(p?.id ?? '').trim(), name: String(p?.name ?? '').trim() }))
42
+ .filter((p) => p.id);
43
+ }
44
+
45
+ /** 一行展示:id(显示名) */
46
+ export function presetLabel(p) {
47
+ return p && p.id ? p.id + (p.name ? '(' + p.name + ')' : '') : String(p ?? '');
48
+ }
49
+
50
+ /**
51
+ * 解析人设输入 → preset id。
52
+ * @returns {{id:string, via:string, all:Array, error?:string}}
53
+ * via: 'name' | 'id' | 'name(i)' | 'id(i)' | 'substring' | ''(失败)
54
+ */
55
+ export async function resolvePreset(root, want) {
56
+ const raw = String(want ?? '');
57
+ const all = await listPresets(root);
58
+ // 拿不到注册表 → 原样放行(交给宿主报错,至少不阻断)
59
+ if (!all.length) return { id: normPresetKey(raw), via: 'passthrough', all };
60
+ const k = normPresetKey(raw);
61
+ if (!k) return { id: '', via: '', all, error: '人设参数是空的' };
62
+ const lk = k.toLowerCase();
63
+
64
+ // ① 预设名(显示名)精确 → 反查 id ★主人指定优先
65
+ const n1 = all.find((p) => p.name === k);
66
+ if (n1) return { id: n1.id, via: 'name', all };
67
+
68
+ // ② id 精确
69
+ const i1 = all.find((p) => p.id === k);
70
+ if (i1) return { id: i1.id, via: 'id', all };
71
+
72
+ // ③ 忽略大小写的精确(名 → id)
73
+ const n2 = all.find((p) => p.name.toLowerCase() === lk);
74
+ if (n2) return { id: n2.id, via: 'name(i)', all };
75
+ const i2 = all.find((p) => p.id.toLowerCase() === lk);
76
+ if (i2) return { id: i2.id, via: 'id(i)', all };
77
+
78
+ // ④ 唯一子串(名或 id 包含输入)
79
+ const subs = all.filter((p) => (p.name && p.name.toLowerCase().includes(lk)) || p.id.toLowerCase().includes(lk));
80
+ if (subs.length === 1) return { id: subs[0].id, via: 'substring', all };
81
+ if (subs.length > 1) {
82
+ return { id: '', via: '', all, error: '「' + raw + '」匹配到多个人设,请说得更准: ' + subs.map(presetLabel).join('、') };
83
+ }
84
+ return { id: '', via: '', all, error: '没找到人设「' + raw + '」。可用: ' + all.map(presetLabel).join('、') };
85
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * session_watch_lib.mjs — 「回合结束回灌」的共用实现(放在 tools/lib/ 下,不被当工具扫描)
3
+ *
4
+ * 口径**照抄 qqbot 的 passive 收尾**(src/transport/outbound.ts:80-211):
5
+ * · 回合内累积 `assistant/message` 的正文(= 它的 noteGenerated(sessionKey, msgId, text))
6
+ * · `turn/end` 时结算(它的注释原文:「assistant/message 用完即清,turn/end 兜底清」)
7
+ * · 取**最后一个正文块**的文本(它收尾时也是"把最后一块复制一份")
8
+ *
9
+ * 本库只做两件事:挂监听、把末段正文回灌给创建者(父 agent)。
10
+ *
11
+ * ★ 2026-10-12 定案(主人):回灌**模拟用户消息**(source.kind='user')+ **唤醒**父会话。
12
+ * `followup` 就是 `inject` 的唤醒档(inject=next-step 不唤醒;followup=next-turn 唤醒),
13
+ * 所以这里用 followup 即"注入 + 唤醒"。下面的历史分析仍保留,供追溯为什么不再走通知身份。
14
+ * 原因(实测结论):`inject` 落 `agent/inbox/spliced **target=next-step**` —— 排队到下次组包、
15
+ * **不产生回合**,因此既没有 turn 触发节点、**UI 上也什么都没有**。
16
+ * 而 `followup` 落 `target=next-turn` ⇒ 产生回合 ⇒ 被渲染成 `TurnTriggerNodeView`:
17
+ * 那个「白框」。回灌消息的 `source.kind='subagent-settled'` 决定白框标题
18
+ * (dsh-client-ui-chat 的 turnTriggerDetails:subagent-settled → 「子任务状态更新」)。
19
+ * 代价:回灌会**唤醒父会话跑一轮**(烧 token),故正文里加了一行"无需回复"提示。
20
+ *
21
+ * ⚠️ 回灌消息的形状**必须**对齐宿主「子会话结算通知」(createSettlementMessage):
22
+ * { id, role:'user', content:[摘要行, 结尾正文引导语, ...正文块],
23
+ * source:{ kind:'subagent-settled', form:'notice', summary, senderSessionId } }
24
+ *
25
+ * ⚠️ 注入前必须过 isInjectableMessage() 自检(fail-closed)。宿主的顺序是
26
+ * **先 append 落盘、后读 `msg.source.kind`**:形状不合法时坏记录已经写进
27
+ * `agent/inbox/spliced.data.inserted[]`,之后投影抛错 —— 会话文件从此无法加载。
28
+ * 事故实例:会话 9643a885 的 seq 4222 曾被写成 `inserted: ["<字符串>"]`。
29
+ *
30
+ * 用法:
31
+ * import { register } from './lib/session_watch_lib.mjs';
32
+ * await register(root, { parentAgentId, childSessionId, label });
33
+ */
34
+ /**
35
+ * 版本标记:供调用方打印,用来确认**热刷后加载到的到底是哪一版**。
36
+ * 改动本文件时顺手把这个号 +1(或换日期),排查"改了不生效"时一眼就能看出来。
37
+ */
38
+ export const __LIB_VERSION__ = 'lib-v10-20261012-首行给源会话id';
39
+
40
+ /**
41
+ * 盯梢名单。**放在 globalThis 上**,不是模块局部变量。
42
+ *
43
+ * 原因:`tools/lib/` 的 import 带了 `?v=<mtime>` 版本号(为让热刷生效),
44
+ * 于是每改一次本文件就会产生一个**新的模块实例**。若名单是模块局部的,
45
+ * 改一次 lib 就把已登记的盯梢对象全丢了。放 globalThis 上跨实例共享。
46
+ */
47
+ const WATCHING_KEY = Symbol.for('dsh-qqbot.session-watch.watching');
48
+ const watching = (globalThis[WATCHING_KEY] ??= new Map()); // childSessionId -> { parentAgentId, label, lastText, seen, firedAt }
49
+
50
+ /** 回灌钩子总开关。false = 正常挂 `session/event` 监听。 */
51
+ const HOOK_DISABLED = false;
52
+
53
+ /** 监听只装一次(跨模块实例去重);handler 指针让新实例能立刻接管旧监听。 */
54
+ const HOOK_KEY = Symbol.for('dsh-qqbot.session-watch.hook');
55
+ const HANDLER_KEY = Symbol.for('dsh-qqbot.session-watch.handler');
56
+ let hooked = false;
57
+
58
+ /**
59
+ * turn/end 的 data.reason → 人话。
60
+ * 口径来自 webui 的两条文案(dsh-client-ui-chat/lib/client.js):
61
+ * "message.turnProcess.worked" = 已完成 ← 正常跑完
62
+ * "message.stopped" = 已停止 ← 被停止/中断
63
+ * 宿主在 index.js:912 把 event.data.reason 记进 inflight.endReason,webui 据此显示。
64
+ */
65
+ function reasonText(reason) {
66
+ const r = String(reason ?? '').toLowerCase();
67
+ if (!r) return '结束';
68
+ if (/stop|abort|cancel|interrupt|halt/.test(r)) return '已停止';
69
+ if (/complet|done|finish|success|ok/.test(r)) return '已完成';
70
+ if (/error|fail/.test(r)) return '出错';
71
+ return r; // 未知取值就原样带出来,便于以后补映射
72
+ }
73
+
74
+ /**
75
+ * `notice` 摘要行的长度上限,与宿主 `dsh-llm` 的 CONTEXT_SUMMARY_MAX_CHARS 保持一致。
76
+ * 宿主原文注释:producer 的一行说明会写进持久日志,所以要限长。
77
+ */
78
+ const CONTEXT_SUMMARY_MAX_CHARS = 120;
79
+
80
+ /** 把摘要行压到上限内(超出用省略号),口径照抄宿主 dsh-llm 的 boundContextSummary()。 */
81
+ function boundContextSummary(summary) {
82
+ const t = String(summary ?? '');
83
+ return t.length <= CONTEXT_SUMMARY_MAX_CHARS ? t : `${t.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`;
84
+ }
85
+
86
+ /**
87
+ * 取会话短 id 用于显示。
88
+ * ⚠️ 子会话 id 形如 `session-f147236a-…`,直接 slice(0,8) 只会得到无意义的 `session-`。
89
+ * 所以先剥掉 `session-` 前缀再截断。
90
+ */
91
+ export function shortSessionId(id) {
92
+ const s = String(id ?? '');
93
+ return (s.startsWith('session-') ? s.slice(8) : s).slice(0, 8);
94
+ }
95
+
96
+ /**
97
+ * 把 `turn/end` 的 reason 归一成字符串。
98
+ *
99
+ * ⚠️ 2026-10-12 实测:`event.data.reason` **不是字符串而是对象**,形如
100
+ * `{kind:'completed'}` 或 `{kind:'aborted', reason:{kind:'user'}}`。
101
+ * 旧代码直接 `String(reason)` ⇒ 摘要行里出现 `回合[object object](reason=[object Object])`。
102
+ * 本函数优先取最外层的 `kind`,没有就下钻内层 `reason`,都不是才退回 String()。
103
+ */
104
+ export function reasonKind(value) {
105
+ if (value === null || value === undefined) return '';
106
+ if (typeof value === 'string') return value;
107
+ if (typeof value === 'object') {
108
+ if (typeof value.kind === 'string' && value.kind) return value.kind;
109
+ if (value.reason !== undefined && value.reason !== value) return reasonKind(value.reason);
110
+ if (typeof value.status === 'string' && value.status) return value.status;
111
+ if (typeof value.stopReason === 'string' && value.stopReason) return value.stopReason;
112
+ }
113
+ return String(value);
114
+ }
115
+
116
+ /**
117
+ * 把各种可能的 content 形状压成纯文本。
118
+ *
119
+ * ★ 2026-10-12 修(主人实测):**只取 `type === 'text'` 的正文块**。
120
+ * 早先的过滤条件是 `x.type === 'text' || typeof x.text === 'string'` ——
121
+ * 而 **reasoning(思考)块同样带 `.text` 字符串**,于是思考被一起并进来,
122
+ * 回灌的"结尾正文"里冒出一整段英文思考(子会话 3bee83ac 那次实测)。
123
+ * reasoning / tool-call 等块一律丢弃。
124
+ */
125
+ function asText(c) {
126
+ if (typeof c === 'string') return c.trim();
127
+ if (Array.isArray(c)) {
128
+ return c
129
+ .filter((x) => x && x.type === 'text' && typeof x.text === 'string')
130
+ .map((x) => String(x.text))
131
+ .join('\n')
132
+ .trim();
133
+ }
134
+ if (c && typeof c === 'object') {
135
+ if (c.type === 'reasoning') return ''; // 思考块不算正文
136
+ if (typeof c.text === 'string') return String(c.text).trim();
137
+ if (Array.isArray(c.blocks)) return asText(c.blocks); // 有些版本正文藏在 blocks 里
138
+ }
139
+ return '';
140
+ }
141
+
142
+ /** 从事件里尽力抽正文(assistant/message 的形状随版本变,逐层试) */
143
+ function textOf(ev) {
144
+ const cands = [
145
+ ev?.data?.message?.content,
146
+ ev?.data?.content,
147
+ ev?.message?.content,
148
+ ev?.data?.text,
149
+ ev?.text,
150
+ ev?.data?.message?.text,
151
+ ];
152
+ for (const c of cands) {
153
+ const t = asText(c);
154
+ if (t) return t;
155
+ }
156
+ return '';
157
+ }
158
+
159
+ /** 兜底:直接从会话实例取"最后一条助手消息"的正文 */
160
+ function lastAssistantText(root, sid) {
161
+ try {
162
+ const sess = root.get('sessions')?.get?.(sid);
163
+ const msgs = sess?.deriveMessages?.() ?? [];
164
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
165
+ const m = msgs[i];
166
+ const role = String(m?.role ?? m?.type ?? '');
167
+ if (role.includes('assistant')) {
168
+ const t = asText(m?.content);
169
+ if (t) return t;
170
+ }
171
+ }
172
+ } catch { /* ignore */ }
173
+ return '';
174
+ }
175
+
176
+ /**
177
+ * 注入前的形状自检(fail-closed)。
178
+ *
179
+ * 复刻宿主 v4 的准入条件(dsh-session-persistence-jsonl/lib/worker.cjs):
180
+ * ① `agent/inbox/spliced` 的 `inserted[]` 每项必须 `isSessionFormatJsonObject`;
181
+ * ② 每项的 `source` 必须是对象、`kind` 为非空字符串、且 !== 'plugin'
182
+ * (源码原文:`format v4 message requires a producer-owned source kind`)。
183
+ *
184
+ * 为什么必须自检而不是交给 inject():宿主是**先 append 落盘、后读 source.kind** 的顺序。
185
+ * 传进非对象时,坏记录已经写进 `inserted[]`,之后投影抛错 —— 文件从此无法加载。
186
+ * 事故实例:会话 9643a885 的 seq 4222 被写成 `inserted: ["<字符串>"]`,整个会话读不出来。
187
+ */
188
+ export function isInjectableMessage(m) {
189
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return false;
190
+ if (typeof m.role !== 'string' || !m.role) return false;
191
+ if (!Array.isArray(m.content) || m.content.length === 0) return false;
192
+ const src = m.source;
193
+ if (!src || typeof src !== 'object' || Array.isArray(src)) return false;
194
+ if (typeof src.kind !== 'string' || !src.kind || src.kind === 'plugin') return false;
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * 构造一条「子会话结算通知」形状的回灌消息(纯函数,便于单测)。
200
+ *
201
+ * 形状**对齐宿主** dsh-subagent/lib/index.js 的 `createSettlementMessage()`:
202
+ * source : { kind:'subagent-settled', form:'notice', summary, senderSessionId }
203
+ * content: [ 摘要行, 结尾正文引导语, ...正文块 ],role 由 createUserMessage 固定为 'user'
204
+ *
205
+ * 采用宿主形状的三个理由:
206
+ * ① kind 是宿主自有的 producer-owned 值,不再冒充真人 user 发言;
207
+ * ② senderSessionId 明确归属到具体子会话,UI 侧归进**子任务**那条通道而不是混进
208
+ * 用户真实发言流里 —— 这与上一轮「回灌插错位置」是同一类隐患;
209
+ * ⚠️ 更正:dsh-client-ui-chat 的 turnTriggerDetails 里 `subagent-settled` 对应的是
210
+ * 「**子任务状态更新**」(icon subagent);「收到任务消息」是 `agent-message`。本条注释早先写错;
211
+ * ③ summary 与宿主同口径(dsh-llm CONTEXT_SUMMARY_MAX_CHARS = 120)。
212
+ *
213
+ * @param {{sessionId?: string, label?: string, rawReason?: string, text?: string}} input
214
+ * @returns 可直接交给 agent.inject() 的完整消息对象
215
+ */
216
+ export function buildSettlementNotice(input = {}) {
217
+ const sid = String(input.sessionId ?? '');
218
+ const label = String(input.label ?? '');
219
+ const rawReason = String(input.rawReason ?? '');
220
+ const stage = reasonText(rawReason);
221
+ const summary = boundContextSummary(
222
+ `子会话${label ? '「' + label + '」' : ''} ${shortSessionId(sid)}… 回合${stage}` +
223
+ `${rawReason && rawReason !== stage ? '(reason=' + rawReason + ')' : ''}`,
224
+ );
225
+ return {
226
+ id: (globalThis.crypto?.randomUUID?.() ?? ('inj-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8))),
227
+ role: 'user',
228
+ content: [
229
+ // ★ 主人 2026-10-12 定:模拟用户消息的**首行直接给出源会话 id**(完整 id)。
230
+ { type: 'text', text: '【来自会话 ' + sid + '】' },
231
+ { type: 'text', text: summary },
232
+ { type: 'text', text: '它的结尾正文:' },
233
+ { type: 'text', text: String(input.text ?? '') },
234
+ { type: 'text', text: '(这是子会话的自动回灌,父会话无需回复。)' },
235
+ ],
236
+ // ★ 2026-10-12 主人定案:回灌**一律模拟用户消息**(source.kind='user'),
237
+ // 不再伪装成子会话结算通知 / 系统消息 / 开发者消息。
238
+ // 理由:那些身份要么被宿主拒收(system/developer 需要 turn+step 且非 JSON 可序列化即被丢),
239
+ // 要么在 UI 上归属混乱;统一成用户消息最简单、最可预期。
240
+ source: { kind: 'user' },
241
+ };
242
+ }
243
+
244
+ async function handler(root, session, event) {
245
+ try {
246
+ const sid = String(session?.id ?? session?.sessionId ?? '');
247
+ const w = sid ? watching.get(sid) : null;
248
+ if (!w) return;
249
+ const type = String(event?.type ?? '');
250
+
251
+ // 累积(对应 qqbot 的 noteGenerated)
252
+ if (type === 'assistant/message') {
253
+ const t = textOf(event);
254
+ if (t) w.lastText = t;
255
+ return;
256
+ }
257
+ if (type !== 'turn/end') return;
258
+
259
+ // 结算:优先用累积到的那一段;没有再从会话实例兜底取
260
+ let text = w.lastText;
261
+ w.lastText = ''; // ★ 取走即清空:否则"只有工具调用、没有正文"的回合会重复回灌上一回合的正文
262
+ if (!text) text = lastAssistantText(root, sid);
263
+ w.seen = (w.seen ?? 0) + 1;
264
+ w.lastReason = reasonKind(event?.data?.reason ?? event?.data?.status ?? event?.data?.stopReason);
265
+ if (!text) return; // 空回合(只有工具调用/无正文)→ 不回灌
266
+ // ★ 去重:兜底路径可能又取到"上一次已经灌过"的那段正文,内容相同就不再灌第二遍
267
+ if (text === w.lastSent) return;
268
+ w.lastSent = text;
269
+
270
+ const agents = root.get('agents');
271
+ const parent = agents?.get?.(w.parentAgentId) ?? null;
272
+ if (!parent) return; // 父不在(不 live)→ 静默跳过,不制造错误
273
+
274
+ const rawReason = reasonKind(event?.data?.reason ?? event?.data?.status ?? event?.data?.stopReason);
275
+ const candidate = buildSettlementNotice({ sessionId: sid, label: w.label, rawReason, text });
276
+ if (!isInjectableMessage(candidate)) return; // 自检不过 → 宁可这次不回灌
277
+ try {
278
+ // ★ 2026-10-12 定案(照抄 qqbot 插件的入站模式 —— dist/features/qq-user-questions.js:646):
279
+ // 「忙时插话、空闲唤醒」
280
+ // · 父会话**回合进行中(忙)** → `agent.inject`
281
+ // = 宿主 `send(msg,'next-step',wakeup:false)`:进当前回合的下一个轮次边界,
282
+ // 她本轮就读到了;**不排进"下一回合"队列** ⇒ **不会挂到输入框**。
283
+ // · 父会话**空闲** → `agent.followup` 唤醒它接着处理。
284
+ // ⚠️ 之前无论忙闲都用 followup → 忙时落成 `target=next-turn` 排队消息,
285
+ // 前端把它当"待发送输入"显示在输入框上方(主人实测)。这才是那个毛病的根因。
286
+ const busy = (() => {
287
+ try {
288
+ const st = String(parent.status ?? parent.phase ?? '');
289
+ return st !== '' && st !== 'idle';
290
+ } catch { return false; }
291
+ })();
292
+ if (busy) await parent.inject(candidate); // 插话(不唤醒、不排队、不进输入框)
293
+ else await parent.followup(candidate); // 空闲 → 唤醒
294
+ w.firedAt = Date.now();
295
+ } catch { /* 注入失败 → 静默;监听里绝不抛,免得影响宿主事件链 */ }
296
+ } catch { /* 监听里绝不抛,免得影响宿主事件链 */ }
297
+ }
298
+
299
+ function installHook(root) {
300
+ if (HOOK_DISABLED) return;
301
+
302
+ // ① 把「当前实例的 handler」发布到 globalThis:已装好的那个监听会转发到最新实例,
303
+ // 所以改完 lib 热刷后新代码立刻接管,不必重挂监听。
304
+ globalThis[HANDLER_KEY] = (session, event) => { void handler(root, session, event); };
305
+
306
+ // ② 监听全局只装一次。lib 带 mtime 版本号加载,改一次就换一个模块实例;
307
+ // 若各实例各自 root.on 一遍,同一个 turn/end 会被回灌多次。
308
+ if (globalThis[HOOK_KEY]) { hooked = true; return; }
309
+ try {
310
+ // 与 qqbot 的 mc_tell 同款:挂在插件根 ctx 上,事件回调 (session, event)。
311
+ // 注意:回调里通过 globalThis 取 handler,永远调用最新那一版。
312
+ root.on('session/event', (session, event) => {
313
+ const h = globalThis[HANDLER_KEY];
314
+ if (typeof h === 'function') h(session, event);
315
+ });
316
+ globalThis[HOOK_KEY] = true;
317
+ hooked = true;
318
+ } catch { hooked = false; }
319
+ }
320
+
321
+ /**
322
+ * 登记一个"要盯着的子会话"。
323
+ * @returns 人话描述(挂成功/失败原因),不抛错。
324
+ */
325
+ export async function register(root, opts = {}) {
326
+ const parentAgentId = String(opts.parentAgentId ?? '').trim();
327
+ const childSessionId = String(opts.childSessionId ?? '').trim();
328
+ const label = String(opts.label ?? '').trim();
329
+ if (!parentAgentId || !childSessionId) return '未挂(缺 parentAgentId 或 childSessionId)';
330
+ if (!root || typeof root.on !== 'function') return '未挂(root 不支持 .on)';
331
+ installHook(root);
332
+ if (HOOK_DISABLED) return '未挂(回灌钩子当前被 HOOK_DISABLED 主动关闭,属预期;恢复见 installHook 注释)';
333
+ if (!hooked) return '未挂(root.on 抛错)';
334
+ watching.set(childSessionId, { parentAgentId, label, lastText: '', seen: 0, firedAt: 0 });
335
+ return `已挂(子=${shortSessionId(childSessionId)}… → 父=${shortSessionId(parentAgentId)}…;回合结束即回灌末段正文)`;
336
+ }
337
+
338
+ /** 给别的工具(如 session_watch 状态查询)读当前盯着的清单 */
339
+ export function listWatching() {
340
+ return [...watching.entries()].map(([sid, w]) => ({
341
+ childSessionId: sid,
342
+ shortId: shortSessionId(sid),
343
+ parentAgentId: w.parentAgentId,
344
+ parentShortId: shortSessionId(w.parentAgentId),
345
+ label: w.label,
346
+ hasText: !!w.lastText,
347
+ turnsSeen: w.seen ?? 0,
348
+ lastFiredAt: w.firedAt ?? 0,
349
+ }));
350
+ }