cs-bots 0.8.0 → 0.9.1

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/src/room.js CHANGED
@@ -154,6 +154,38 @@ const RELAY_MULTIPLIER = 3
154
154
  const RELAY_FLOOR = 3
155
155
  /** 人类发送者 id(§3.2:`from` 为 sessionId 或 `'user'`)。 */
156
156
  const USER = 'user'
157
+ /**
158
+ * 人类身份全集 —— 与 `teams.js` 的 `HUMAN_IDS` 对齐(`ui` / `user` / `person`)。
159
+ *
160
+ * **绝不唤醒人**:人是「看页面的人」,不是会话;`kernel.followup` 对着这些 id 必然失败。
161
+ * 实测证据(2026-11-11):`wakes.jsonl` 里 `room-msg:m-1:ui → failed` ——
162
+ * 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
163
+ */
164
+ const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
165
+
166
+ /**
167
+ * **规范用户 id**(2026-11-12 t-40 需求⑤)。
168
+ *
169
+ * Boss:「以后要统一一下名称……人=用户=boss=user=我」。
170
+ * 历史数据里同一个人有三种写法(`ui` 来自页面层、`user` 来自 API 层、`person` 来自早期约定),
171
+ * 于是「这是谁」在每一处都要判三遍,迟早漏一处。
172
+ *
173
+ * 采用**读兼容 + 写统一**,而不是一刀切改写历史:
174
+ * - 读:`ui`/`person` 一律视为 `user`(`HUMAN_IDS` 仍然认它们,老数据不会失效);
175
+ * - 写:新写入一律用 `user`。
176
+ * 这样不破坏历史事件流(append-only,改历史等于伪造),又能让新数据只有一个写法。
177
+ */
178
+ const CANONICAL_USER = 'user'
179
+
180
+ /** 是否为人类身份(`ui`/`user`/`person` 都认 —— 读侧兼容)。 */
181
+ function isHumanId(id) {
182
+ return typeof id === 'string' && HUMAN_IDS.includes(id)
183
+ }
184
+
185
+ /** 把任意人类写法归一成规范 id `user`;非人类身份原样返回。 */
186
+ function normalizeHumanId(id) {
187
+ return isHumanId(id) ? CANONICAL_USER : id
188
+ }
157
189
  /** 房间事件类型集合(审计流可枚举)。 */
158
190
  const EVENTS = [
159
191
  'room_created',
@@ -186,6 +218,14 @@ const DEFAULT_SETTINGS = Object.freeze({
186
218
  allowBotAutoPost: true,
187
219
  maxBotsPerRound: 3,
188
220
  relayEnabled: true,
221
+ // ── 定向唤醒(2026-11-11「唤醒风暴」止血)────────────────────────────────
222
+ // 缺省 **true**:房间消息**只在被 @ 时**才唤醒成员。
223
+ //
224
+ // 为什么缺省要改成「安静」:实测 `wakes.jsonl` 里一条消息产生 3 次唤醒尝试、
225
+ // 3 次全失败(`room-msg:m-1:ui` + 两个 bot 全 failed)—— 缺省广播「除自己外全部成员」
226
+ // 既吵又必然失败(`ui` 根本没有会话可唤醒)。改缺省 = 把最吵、最容易失败的路径关掉;
227
+ // 需要广播的房间显式 `mentionOnly:false` 打开。
228
+ mentionOnly: true,
189
229
  })
190
230
  /** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
191
231
  const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
@@ -283,6 +323,113 @@ function stringList(value, field, where) {
283
323
  return out
284
324
  }
285
325
 
326
+ /**
327
+ * 从消息里收集「被 @ 的成员 id」(与 `admission.isAddressed` 的 `@id` 解析同口径)。
328
+ *
329
+ * 两条来源(取并集):
330
+ * 1. `message.mentions` —— 结构化数组,若有则优先
331
+ * 2. `message.text` 里的 `@id` —— 兜底解析(必须是 word-boundary 后的 `@`,避免误伤 `@bot-2` 当 `@bot`)
332
+ *
333
+ * 仅保留 **room.members 里实际存在的** id(非成员被 @ 了也不算唤醒目标)。
334
+ *
335
+ * ⚠️ 注意:`message.from` 即使被 @ 了也不进唤醒名单(自己不唤醒自己)。
336
+ * `HUMAN_IDS`(人类身份)也在 `wakeable()` 里统一过滤,这里不再二次过滤。
337
+ * @param {object} message
338
+ * @param {string[]} members
339
+ * @returns {string[]} 被 @ 的可唤醒成员 id(去重、保序)
340
+ */
341
+ function collectMentions(message, members) {
342
+ if (!message || typeof message !== 'object') return []
343
+ const memberSet = new Set(members)
344
+ const out = []
345
+ const seen = new Set()
346
+
347
+ // ① 结构化 mentions 数组
348
+ if (Array.isArray(message.mentions)) {
349
+ for (const m of message.mentions) {
350
+ if (typeof m === 'string' && memberSet.has(m) && !seen.has(m)) {
351
+ seen.add(m)
352
+ out.push(m)
353
+ }
354
+ }
355
+ }
356
+
357
+ // ② 文本里的 @id(与 admission.js 同口径:`@id` 后必须跟非标识符字符或结尾)
358
+ const text = typeof message.text === 'string' ? message.text : ''
359
+ if (text) {
360
+ for (const m of members) {
361
+ if (seen.has(m)) continue
362
+ const idx = text.indexOf(`@${m}`)
363
+ if (idx >= 0) {
364
+ const after = text.slice(idx + 1 + m.length)
365
+ if (after === '' || !/[A-Za-z0-9_-]/.test(after[0])) {
366
+ seen.add(m)
367
+ out.push(m)
368
+ }
369
+ }
370
+ }
371
+ }
372
+ return out
373
+ }
374
+
375
+ /**
376
+ * 计算「一条房间消息该唤醒谁」—— **唤醒风暴的唯一真相源**。
377
+ *
378
+ * 为什么抽成独立函数(2026-11-12 t-40-B 修复):
379
+ * 房间有**两条**唤醒路径,过去各写一份目标计算,于是必然漂移:
380
+ * - `room.relay()`(`POST /rooms/relay`)实现了四档规则;
381
+ * - `POST /rooms/post`(Bot 用 `bot_room_post`、Boss 用 UI **都走这条**)却是
382
+ * 裸的 `members.filter((m) => m !== skipSelf)` —— 不看 `mentionOnly`、
383
+ * 不排除人类 id。实测 6 个房间全设了 `mentionOnly:true`,**一次都没生效**;
384
+ * 8 成员房间 ⇒ 一条消息 8 次模型调用,其中打给人类 id 的必然失败。
385
+ *
386
+ * 所以这里只留**一份**规则,两个调用点都用它。改规则只改这里。
387
+ *
388
+ * 四档,**顺序即优先级**(与 relay 原注释一致):
389
+ * ① 显式 targets → 原样采信(剔除人类,并 warn:那是调用方的错)
390
+ * ② mentionOnly=true 且 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
391
+ * ③ mentionOnly=true 但没 @ → **空**(只落消息不唤醒,合法结果,不是错误)
392
+ * ④ mentionOnly=false(显式关掉定向)→ 除发送者外的全部成员,**仍排除人类**
393
+ *
394
+ * @param {{ members?: string[], settings?: object, from?: string }} room 房间快照
395
+ * @param {{ from?: string, text?: string, mentions?: string[] }} message 消息
396
+ * @param {{ targets?: unknown, warn?: (m: string) => void }} [options]
397
+ * 显式目标(仅 relay 用);`warn` 注入调用方所在作用域的日志器
398
+ * (工厂内部有 `warn`,模块顶层没有 —— 这里显式注入,不靠隐式作用域)
399
+ * @returns {{ targets: string[], source: 'explicit'|'mention'|'mention-only-no-target'|'broadcast' }}
400
+ */
401
+ function computeWakeTargets(room, message, options = {}) {
402
+ const members = Array.isArray(room?.members) ? room.members : []
403
+ const settings = room?.settings ?? DEFAULT_SETTINGS
404
+ const mentionOnly = settings.mentionOnly !== false
405
+ // 发送者自己永远不唤醒(发消息的人不需要被自己的消息叫醒)
406
+ const sender = message?.from ?? room?.from
407
+ /** 唤醒排除表:人类一律不算唤醒目标(人没有会话,唤醒必然 agent-not-live)。 */
408
+ const wakeable = (id) => id !== sender && !isHumanId(id)
409
+
410
+ if (options?.targets !== undefined && options?.targets !== null) {
411
+ let list = stringList(options.targets, 'targets', 'relay')
412
+ const humans = list.filter((t) => isHumanId(t))
413
+ if (humans.length > 0) {
414
+ const say = typeof options.warn === 'function' ? options.warn : () => {}
415
+ say(`[cs-bots/room] 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
416
+ list = list.filter((t) => !isHumanId(t))
417
+ }
418
+ return { targets: list, source: 'explicit' }
419
+ }
420
+
421
+ if (mentionOnly) {
422
+ const mentioned = collectMentions(message, members)
423
+ const targets = mentioned.filter(wakeable)
424
+ return targets.length > 0
425
+ ? { targets, source: 'mention' }
426
+ : { targets: [], source: 'mention-only-no-target' }
427
+ }
428
+
429
+ // ④ 显式关掉定向才广播 —— 即便如此也不唤醒人类、不唤醒发送者自己
430
+ return { targets: members.filter(wakeable), source: 'broadcast' }
431
+ }
432
+
286
433
  /** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
287
434
  function normalizeNow(value, where) {
288
435
  if (value === undefined || value === null) return Date.now()
@@ -373,7 +520,7 @@ function normalizePurpose(value, where = 'createRoom') {
373
520
 
374
521
  /** `settings` 单个开关校验(按键分派;未知键抛错 —— 拒绝写入「怎么写都不会生效」的配置)。 */
375
522
  function normalizeSettingValue(key, value, where) {
376
- if (key === 'allowBotAutoPost' || key === 'relayEnabled') {
523
+ if (key === 'allowBotAutoPost' || key === 'relayEnabled' || key === 'mentionOnly') {
377
524
  if (typeof value !== 'boolean') {
378
525
  throw new RoomError(
379
526
  `room.${where}:settings.${key} 必须是布尔值,收到 ${JSON.stringify(value)}`,
@@ -1880,25 +2027,93 @@ export function createRoom(ctx, config = {}) {
1880
2027
  const message = requireMessage(room.id, mid, 'relay')
1881
2028
  const actor = optionalString(byId, 'byId', 'relay')
1882
2029
 
2030
+ // ══ 目标计算(2026-11-11「唤醒风暴」止血)═══════════════════════════════
2031
+ // 四档,**顺序即优先级**:
2032
+ // ① 显式给了 targets → 原样采信(调用方说了算;仍强制排除人类 id)
2033
+ // ② mentionOnly=true 且消息 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
2034
+ // ③ mentionOnly=true 但没 @ → **只落消息,不唤醒任何人**(relayed:0)
2035
+ // ④ mentionOnly=false(显式关掉定向)→ 回退「除自己外全部成员」,但仍排除人类
2036
+ //
2037
+ // 为什么③不抛错:「只记录不打扰」是合法结果(Boss 只是同步信息,不需要谁被叫醒)。
2038
+ // 抛错会把「安静」变成「失败」,页面反而要处理异常 —— 那是更差的设计。
2039
+ const settings = room.settings ?? DEFAULT_SETTINGS
2040
+ const mentionOnly = settings.mentionOnly !== false
2041
+ /** 唤醒排除表:人类一律不算唤醒目标(见 HUMAN_IDS 注释的实测证据)。 */
2042
+ const wakeable = (id) => id !== message.from && !isHumanId(id)
1883
2043
  let targetList
1884
- if (targets === undefined || targets === null) {
1885
- targetList = room.members.filter((m) => m !== message.from)
1886
- if (targetList.length === 0) {
1887
- throw new RoomError(
1888
- `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他成员,且未显式指定 targets`
1889
- + ' —— 拒绝空转发(静默无操作)',
1890
- 'ROOM_NO_TARGETS',
1891
- { roomId: room.id, messageId: mid, from: message.from },
1892
- )
2044
+ let targetSource
2045
+ let noTargetReason
2046
+
2047
+ // 规则统一在 computeWakeTargets(t-40-B):与 `POST /rooms/post` 共用同一份,
2048
+ // 避免两条唤醒路径各写一套而再度漂移。
2049
+ const decided = computeWakeTargets(room, message, { targets, warn })
2050
+ targetList = decided.targets
2051
+ targetSource = decided.source
2052
+ if (targetSource === 'mention-only-no-target') noTargetReason = 'mention-only-no-target'
2053
+ // 显式 targets 给了空数组 ⇒ 调用方契约违反,抛错(computeWakeTargets 只管剔除人类)
2054
+ if (targetSource === 'explicit' && Array.isArray(targets) && targets.length === 0) {
2055
+ throw new RoomError(
2056
+ `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
2057
+ 'ROOM_NO_TARGETS',
2058
+ { roomId: room.id, messageId: mid },
2059
+ )
2060
+ }
2061
+ // 广播档但「除发送者外全是人类」⇒ 这是**配置错误**(没有 bot 可广播),抛错比静默诚实。
2062
+ // 注意:mention 档的空结果不算错(③「只记录不打扰」是合法结果),二者不能混。
2063
+ if (targetSource === 'broadcast' && targetList.length === 0) {
2064
+ throw new RoomError(
2065
+ `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
2066
+ + ' —— 拒绝空转发(静默无操作)',
2067
+ 'ROOM_NO_TARGETS',
2068
+ { roomId: room.id, messageId: mid, from: message.from },
2069
+ )
2070
+ }
2071
+
2072
+ // ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
2073
+ // 必须在后台账之前分流:否则每次安静发言都吃掉一次 relayLimit 配额。
2074
+ if (targetList.length === 0) {
2075
+ const at0 = normalizeNow(now, 'relay')
2076
+ appendRoomEvent(room.id, {
2077
+ type: 'relay',
2078
+ roomId: room.id,
2079
+ rev: room.rev,
2080
+ byId: actor ?? null,
2081
+ at: at0,
2082
+ messageId: mid,
2083
+ targets: [],
2084
+ attempted: 0,
2085
+ limit: room.relayLimit,
2086
+ targetSource,
2087
+ reason: noTargetReason ?? 'no-wakeable-target',
2088
+ })
2089
+ let audit0 = { written: true }
2090
+ try {
2091
+ appendRoomEvent(room.id, {
2092
+ type: 'relay_result',
2093
+ roomId: room.id,
2094
+ rev: room.rev,
2095
+ byId: actor ?? null,
2096
+ at: at0,
2097
+ messageId: mid,
2098
+ deliveries: [],
2099
+ })
2100
+ } catch (error) {
2101
+ audit0 = { written: false, reason: `relay-result-append-failed:${error?.message ?? error}` }
2102
+ diagnostics.relayResultAppendFailures += 1
2103
+ warn(`[cs-bots/room] 转发结果事件写入失败(投递已发生):${audit0.reason}`)
1893
2104
  }
1894
- } else {
1895
- targetList = stringList(targets, 'targets', 'relay')
1896
- if (targetList.length === 0) {
1897
- throw new RoomError(
1898
- `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
1899
- 'ROOM_NO_TARGETS',
1900
- { roomId: room.id, messageId: mid },
1901
- )
2105
+ return {
2106
+ ok: true,
2107
+ roomId: room.id,
2108
+ messageId: mid,
2109
+ relayed: 0,
2110
+ attempted: 0,
2111
+ limit: room.relayLimit,
2112
+ remaining: Math.max(0, room.relayLimit - (relayCounts.get(relayKey(room.id, mid)) ?? 0)),
2113
+ deliveries: [],
2114
+ audit: audit0,
2115
+ targetSource,
2116
+ reason: noTargetReason ?? 'no-wakeable-target',
1902
2117
  }
1903
2118
  }
1904
2119
 
@@ -1992,6 +2207,7 @@ export function createRoom(ctx, config = {}) {
1992
2207
  remaining: Math.max(0, limit - (relayCounts.get(rkey) ?? 0)),
1993
2208
  deliveries,
1994
2209
  audit,
2210
+ targetSource,
1995
2211
  }
1996
2212
  if (failed.length > 0) {
1997
2213
  result.reason = failed
@@ -2153,6 +2369,14 @@ export function createRoom(ctx, config = {}) {
2153
2369
  leave,
2154
2370
  post,
2155
2371
  relay,
2372
+ /**
2373
+ * 计算「这条消息该唤醒谁」(t-40-B)。
2374
+ * 暴露给 `POST /rooms/post` 复用,保证两条唤醒路径共用同一份规则;
2375
+ * 纯函数、不落盘、不唤醒,调用方自己决定要不要投递。
2376
+ * 绑定本实例的 `warn`,这样 HTTP 路径的告警与 relay 走同一个 logger。
2377
+ */
2378
+ computeWakeTargets: (room, message, options = {}) =>
2379
+ computeWakeTargets(room, message, { ...options, warn }),
2156
2380
  readBody,
2157
2381
  listMessages,
2158
2382
  // DESIGN-v2 ①:房间配置 / 归档 / 硬删 / 成员角色(签名见 DESIGN 契约,勿改名)
@@ -2187,5 +2411,6 @@ export const ROOM_DEFAULT_SETTINGS = Object.freeze({ ...DEFAULT_SETTINGS })
2187
2411
 
2188
2412
  /** 用户(人类)发送者 id(`from` 允许的特殊值)。 */
2189
2413
  export const ROOM_USER = USER
2414
+ export const ROOM_CANONICAL_USER = CANONICAL_USER
2190
2415
 
2191
2416
  export default createRoom
package/src/roster.js CHANGED
@@ -100,6 +100,7 @@ const ACTIONS = [
100
100
  'create',
101
101
  'rename',
102
102
  'retire',
103
+ 'purge',
103
104
  'set_cwd',
104
105
  'claim',
105
106
  'release',
@@ -517,6 +518,8 @@ export function createRoster(ctx, config = {}) {
517
518
  skipped += 1
518
519
  continue
519
520
  }
521
+ // purge 是终态:重放时把它从内存表移除,而不是复活成一条记录
522
+ if (ev.after.state === 'purged') { bots.delete(ev.botId); events.push(clone(ev)); continue }
520
523
  bots.set(ev.botId, clone(ev.after))
521
524
  events.push(clone(ev))
522
525
  }
@@ -694,7 +697,7 @@ export function createRoster(ctx, config = {}) {
694
697
  * @throws {RosterError} 缺字段 / 非法 cwd / 重名 / 会话创建失败 / 事件落盘失败
695
698
  */
696
699
  async function create(input = {}) {
697
- const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now } = input ?? {}
700
+ const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now, adoptSession } = input ?? {}
698
701
  const by = requireString(byId, 'byId', 'create')
699
702
  const displayName = requireString(name, 'name', 'create')
700
703
  const dir = assertBotCwd(cwd, 'create')
@@ -707,6 +710,28 @@ export function createRoster(ctx, config = {}) {
707
710
  // ── 唯一性(校验通过后才动会话,避免「建了会话才发现重名」的资源泄漏)──
708
711
  assertNameFree(displayName, undefined, 'create')
709
712
 
713
+ // ── adoptSession:把**已存在的会话**注册成 Bot(2026-11-11 Boss 需求)──
714
+ // 为什么需要:`create()` 原本只会**新建**会话(`bot-${randomUUID()}`),
715
+ // 无法接管一个已经在跑、已有上下文的会话。Boss 要把当前会话(带着全部上下文)
716
+ // 变成可在协作平台派活的 Bot ⇒ 会话 id 必须保持不变。
717
+ // 语义边界:
718
+ // - `adoptSession` 给出时**不建新会话**,注册表直接引用它;
719
+ // - 该 sessionId 不能已被别的活跃 Bot 占用(`hasSession` 查重);
720
+ // - 退役 Bot 里的同名 session 不阻止接管(终态已释放名额)。
721
+ const adopt = optionalString(adoptSession, 'adoptSession', 'create')
722
+ if (adopt !== undefined) {
723
+ for (const other of bots.values()) {
724
+ if (other.state !== 'retired' && other.sessionId === adopt) {
725
+ throw new RosterError(
726
+ `roster.create:会话 ${adopt} 已被 Bot「${other.name}」(${other.id})占用——`
727
+ + '同一会话不能注册成两个 Bot',
728
+ 'ROSTER_SESSION_TAKEN',
729
+ { sessionId: adopt, occupiedBy: other.id },
730
+ )
731
+ }
732
+ }
733
+ }
734
+
710
735
  // ── 同 cwd 提示(非阻塞):两个活 Bot 共用一个目录会撞 fs 写入边界 ──
711
736
  for (const other of bots.values()) {
712
737
  if (other.state !== 'retired' && other.cwd === dir) {
@@ -717,29 +742,41 @@ export function createRoster(ctx, config = {}) {
717
742
  }
718
743
  }
719
744
 
720
- // ── 会话创建:**唯一能指定 cwd 的路径**(kernel.js:249-287)──
721
- const id = `bot-${randomUUID()}`
722
- let handle
723
- try {
724
- handle = await kernel.createSession(ctx, {
725
- sessionId: id,
726
- cwd: dir,
727
- warn,
728
- ...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
729
- })
730
- } catch (error) {
731
- throw new RosterError(
732
- `roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
733
- + '——已中止注册(不产生半成品 Bot)',
734
- 'ROSTER_SESSION_CREATE_FAILED',
735
- { name: displayName, cwd: dir, cause: String(error?.message ?? error) },
736
- )
737
- }
738
- const sessionId = pickSessionId(handle, id)
739
- if (sessionId !== id) {
740
- warn(
741
- `[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
742
- )
745
+ // ── 会话:要么新建,要么接管已有(adoptSession)──
746
+ //
747
+ // 新建时这是**唯一能指定 cwd 的路径**(kernel.js:249-287)。
748
+ // 接管时**不碰会话**:那个会话已经在跑、有自己的 cwd 与上下文,
749
+ // 我们只是把它登记进注册表 —— 因此 `id` 直接沿用该 sessionId
750
+ // (不生成 `bot-xxx`),这样 `wake`/任务派活的目标 id 与真实会话对得上。
751
+ let id
752
+ let sessionId
753
+ if (adopt !== undefined) {
754
+ id = adopt
755
+ sessionId = adopt
756
+ } else {
757
+ id = `bot-${randomUUID()}`
758
+ let handle
759
+ try {
760
+ handle = await kernel.createSession(ctx, {
761
+ sessionId: id,
762
+ cwd: dir,
763
+ warn,
764
+ ...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
765
+ })
766
+ } catch (error) {
767
+ throw new RosterError(
768
+ `roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
769
+ + '——已中止注册(不产生半成品 Bot)',
770
+ 'ROSTER_SESSION_CREATE_FAILED',
771
+ { name: displayName, cwd: dir, cause: String(error?.message ?? error) },
772
+ )
773
+ }
774
+ sessionId = pickSessionId(handle, id)
775
+ if (sessionId !== id) {
776
+ warn(
777
+ `[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
778
+ )
779
+ }
743
780
  }
744
781
 
745
782
  /** @type {Record<string, unknown>} */
@@ -831,6 +868,59 @@ export function createRoster(ctx, config = {}) {
831
868
  return commit(current.id, 'rename', by, at, before, next)
832
869
  }
833
870
 
871
+ /**
872
+ * 改预制体(preset)—— 2026-11-11 bots04 补的能力缺口。
873
+ *
874
+ * ## 为什么需要它
875
+ *
876
+ * `preset` 此前**只能**在 `create()` 时设置,之后没有任何接口能改。
877
+ * 实测后果:`myfree调研员`(t-36 挂 `agent-not-live` 的那个)preset 为空,
878
+ * 而空 preset = 走宿主默认预设 = **工具面没有保证**(连 `bot_*` 工具都不一定有)。
879
+ * 想修只能删掉重建 —— 那会丢掉 sessionId 与全部历史关联,代价过高。
880
+ *
881
+ * ## 语义边界
882
+ *
883
+ * - **只改 preset,不重建会话**:已经起来的会话不会被换预设。
884
+ * 这是诚实的边界 —— 换预设真正生效要等该会话下次启动。
885
+ * 调用方要能看见这一点,所以返回值里带 `appliesOnNextSession: true`。
886
+ * - 退役 Bot 不可改(与 `rename`/`setCwd` 同口径)。
887
+ * - CAS:必须带 `expectedRev`。
888
+ * - 空串 / null ⇒ 清除 preset(回到宿主默认)。这与 `create` 里
889
+ * 「不传/空串则不写 preset 字段」是同一口径,避免出现 `preset: ''` 这种半状态。
890
+ *
891
+ * @param {{ id: string, preset: string|null, byId: string, expectedRev: number, now?: number }} input
892
+ * @returns {object} Bot(含 `appliesOnNextSession`)
893
+ * @throws {RosterError} 不存在 / 已退役 / 缺字段 / rev 不匹配
894
+ */
895
+ function setPreset(input = {}) {
896
+ const { preset, byId, now } = input ?? {}
897
+ const current = loadForCas(input, 'setPreset')
898
+ const by = requireString(byId, 'byId', 'setPreset')
899
+ assertNotRetired(current, 'setPreset', '换预制体')
900
+
901
+ const raw = preset === undefined || preset === null ? '' : String(preset).trim()
902
+ const nextPreset = raw === '' ? undefined : raw
903
+ if ((current.preset ?? undefined) === nextPreset) {
904
+ throw new RosterError(
905
+ `roster.setPreset:Bot ${current.id} 的 preset 已是 ${JSON.stringify(nextPreset ?? null)}——无需变更`,
906
+ 'ROSTER_NOTHING_TO_SET_PRESET',
907
+ { id: current.id, preset: nextPreset ?? null },
908
+ )
909
+ }
910
+
911
+ const at = normalizeNow(now, 'setPreset')
912
+ const before = clone(current)
913
+ const next = clone(current)
914
+ next.rev = current.rev + 1
915
+ if (nextPreset === undefined) delete next.preset
916
+ else next.preset = nextPreset
917
+ next.presetUpdatedAt = at
918
+ next.presetUpdatedBy = by
919
+ const committed = commit(current.id, 'setPreset', by, at, before, next)
920
+ // 诚实告知:换 preset 不会立刻影响已存在的会话
921
+ return { ...committed, appliesOnNextSession: true }
922
+ }
923
+
834
924
  /**
835
925
  * 退役 → **终态,但释放名额**(名字可复用、cwd 可回收;§3.1 "关键" 段)。
836
926
  *
@@ -876,6 +966,74 @@ export function createRoster(ctx, config = {}) {
876
966
  return committed
877
967
  }
878
968
 
969
+ /**
970
+ * 清理(purge)已退役的 Bot —— **把记录从注册表里真正移走**(2026-11-12 t-40,Boss 要求)。
971
+ *
972
+ * 为什么 retire 不够:`retire` 只是把 `state` 置为 `retired`,**记录永远留在注册表里**
973
+ * (`listRetired()` 能查到、`roster.jsonl` 里一堆测试残留),时间一长注册表就被
974
+ * 一堆没用的测试 Bot 撑满,管理页看不清到底有哪些在役 Bot。
975
+ *
976
+ * 语义边界(必须守住的四条):
977
+ * 1. **只能清已退役的** —— 在役 Bot 一律拒绝(`ROSTER_PURGE_ACTIVE`)。
978
+ * 退役是"我不用它了",清理是"把这条记录抹掉";两步分开,避免手滑删在役 Bot。
979
+ * 2. **只动注册表,绝不动 cwd 目录、绝不动会话** —— Bot 的工作目录里可能有真实产物
980
+ * (实测 `/home/ubuntu/output/开发测试001` 里存着 t36 调研报告),
981
+ * 删目录是不可逆且越权的,本函数**不碰文件系统**。
982
+ * 3. **事件流仍留档**:往 `roster.jsonl` 追加一条 `purge` 事件(`after` = 清理后的状态),
983
+ * 重放时据此把该 Bot 从内存表里移除 —— 这样「谁在什么时候清了谁」可审计,
984
+ * 而不是把历史抹得干干净净。
985
+ * 4. **幂等**:已清理的再清一次直接跳过(计入 `skipped`),不抛错。
986
+ *
987
+ * @param {{ ids?: string[], allRetired?: boolean, reason?: string, byId: string, now?: number }} input
988
+ * `ids` 指定要清的 Bot;`allRetired: true` 清全部已退役的(与 `ids` 二选一)
989
+ * @returns {{ purged: string[], skipped: string[], refused: Array<{id: string, reason: string}> }}
990
+ */
991
+ function purge(input = {}) {
992
+ const { ids, allRetired, reason, byId, now } = input ?? {}
993
+ const by = requireString(byId, 'byId', 'purge')
994
+ const note = optionalString(reason, 'reason', 'purge')
995
+ const at = normalizeNow(now, 'purge')
996
+
997
+ let targets = []
998
+ if (allRetired === true) {
999
+ for (const b of bots.values()) if (b.state === 'retired') targets.push(b.id)
1000
+ } else if (Array.isArray(ids)) {
1001
+ targets = ids.map((x, i) => requireString(x, `ids[${i}]`, 'purge'))
1002
+ } else {
1003
+ throw new RosterError(
1004
+ 'roster.purge:必须给 ids(数组)或 allRetired:true(二选一)—— 拒绝无目标清理',
1005
+ 'ROSTER_PURGE_NO_TARGET',
1006
+ {},
1007
+ )
1008
+ }
1009
+
1010
+ const purged = []
1011
+ const skipped = []
1012
+ const refused = []
1013
+ for (const botId of targets) {
1014
+ const current = bots.get(botId)
1015
+ if (!current) { skipped.push(botId); continue }
1016
+ if (current.state !== 'retired') {
1017
+ // 在役 Bot 绝不清理:这是本条 API 最重要的护栏
1018
+ refused.push({ id: botId, reason: `在役(state=${current.state})—— 先 retire 再 purge` })
1019
+ continue
1020
+ }
1021
+ const before = clone(current)
1022
+ const next = clone(current)
1023
+ next.rev = current.rev + 1
1024
+ next.state = 'purged'
1025
+ next.purgedAt = at
1026
+ next.purgedBy = by
1027
+ if (note !== undefined) next.purgeReason = note
1028
+ // 写事件而不是把它从 jsonl 里抠掉:留档「谁清的、何时清的」
1029
+ commit(botId, 'purge', by, at, before, next)
1030
+ bots.delete(botId)
1031
+ purged.push(botId)
1032
+ info(`[cs-bots/roster] Bot ${botId}(「${current.name}」)已清理出注册表`)
1033
+ }
1034
+ return { purged, skipped, refused }
1035
+ }
1036
+
879
1037
  /**
880
1038
  * 换工作目录(**官方完全没有的语义**:teammate 强制继承父 cwd)。
881
1039
  *
@@ -1408,6 +1566,8 @@ export function createRoster(ctx, config = {}) {
1408
1566
  hasSession,
1409
1567
  rename,
1410
1568
  retire,
1569
+ purge,
1570
+ setPreset,
1411
1571
  setCwd,
1412
1572
  setPrepareCommand,
1413
1573
  getProfile,