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/client.js CHANGED
@@ -537,6 +537,47 @@ window.__ModuleLoader__.load({
537
537
  text: '写入失败了,可能是磁盘或权限问题,请检查状态目录',
538
538
  hint: '事件追加失败:拒绝「只改内存不落盘」',
539
539
  },
540
+ // 契约 第2批 ①:跨团队路由错误(对齐 backend-b 的实际枚举,见 src/teams.js resolveRoute)
541
+ ROUTE_TASK_ID_REQUIRED: {
542
+ actionLabel: null,
543
+ text: '缺少关联任务,无法发协作请求',
544
+ hint: 'taskId 必填:路由靠 taskId 做环路检测与去重',
545
+ },
546
+ ROUTE_TARGET_REQUIRED: {
547
+ actionLabel: null,
548
+ text: '没选目标团队',
549
+ hint: 'toTeam 或 toBot 必须给一个',
550
+ },
551
+ ROUTE_TARGET_AMBIGUOUS: {
552
+ actionLabel: null,
553
+ text: '目标不明确(团队和 Bot 只能选一个)',
554
+ hint: 'toTeam 与 toBot 同时给出会被拒',
555
+ },
556
+ ROUTE_TARGET_NOT_FOUND: {
557
+ actionLabel: '刷新团队列表',
558
+ text: '目标团队不存在了,刷新一下',
559
+ hint: 'route.toTeam 不在团队列表中',
560
+ },
561
+ ROUTE_HOP_EXCEEDED: {
562
+ actionLabel: null,
563
+ text: '跳数超限,已阻止循环转发',
564
+ hint: '路由 hop 超过上限(3),防止 A→B→A→B 死循环',
565
+ },
566
+ ROUTE_NO_LEAD: {
567
+ actionLabel: null,
568
+ text: '目标团队没有负责人,无法送达',
569
+ hint: '目标团队 leadId 为空,没人可唤醒',
570
+ },
571
+ ROUTE_NO_DISPATCHER: {
572
+ actionLabel: null,
573
+ text: '唤醒通道不可用,这次请求没送出去',
574
+ hint: 'dispatcher 未装配(宿主缺组件),路由决定不做',
575
+ },
576
+ ROUTE_RECORD_FAILED: {
577
+ actionLabel: null,
578
+ text: '留痕失败,请求已中止(不会偷偷投递)',
579
+ hint: 'route 事件追加失败(磁盘/权限),intent-first 原则拒绝「投了没记」',
580
+ },
540
581
  }
541
582
 
542
583
  /**
@@ -1073,7 +1114,7 @@ window.__ModuleLoader__.load({
1073
1114
  }, '📦 看产出物')),
1074
1115
  h('div', { className: 'csb-kv' },
1075
1116
  // ── 5W:谁建的 / 是什么 / 何时 / 在哪做 / 为什么 ──
1076
- // 需求5:表述以「人」为主体——人建的任务就说「用户」,不暴露 Bot 会话 id
1117
+ // 需求5:表述以「Boss」为主体——Boss 建的任务就说「Boss」,不暴露 Bot 会话 id
1077
1118
  h('span', { className: 'csb-k' }, '谁建的'),
1078
1119
  h('span', { className: 'csb-v' }, task.source === 'human' ? HUMAN_LABEL : (task.createdBy ? botName(bots, task.createdBy) : '智能体')),
1079
1120
  h('span', { className: 'csb-k' }, '来源'),
@@ -1936,7 +1977,7 @@ window.__ModuleLoader__.load({
1936
1977
  // 规则(契约原文,逐条实现,不自作主张):
1937
1978
  // room + exists !== false → 按钮「回到消息出处(房间名 #N)」
1938
1979
  // room + exists === false → 按钮禁用 + 提示「来源房间已删除」
1939
- // none → **不渲染任何来源区块**(人建的任务不占位)
1980
+ // none → **不渲染任何来源区块**(Boss 建的任务不占位)
1940
1981
  // todo/evolve → 只读一行「来源:<sourceSeq>」
1941
1982
  // unparsed → 只读一行 + 后端 reason(绝不渲染跳不动的按钮)
1942
1983
  sourceQ.error
@@ -3096,7 +3137,7 @@ window.__ModuleLoader__.load({
3096
3137
  /**
3097
3138
  * 任务看板。
3098
3139
  *
3099
- * 需求1(2026-11-08):任务页**默认只展示人建任务**;智能体自建/分发的任务
3140
+ * 需求1(2026-11-08):任务页**默认只展示 Boss 建任务**;智能体自建/分发的任务
3100
3141
  * 走独立子页签(`sourceFilter='ai'`),按状态分类展示。
3101
3142
  *
3102
3143
  * 为什么这样分:人关心的是「我要盯的活」,智能体之间互相派的活是执行细节——
@@ -4951,6 +4992,11 @@ window.__ModuleLoader__.load({
4951
4992
  onKeyDown: (e) => e.key === 'Enter' && post(),
4952
4993
  }),
4953
4994
  h('button', { className: 'csb-btn csb-btn-p', disabled: !text.trim(), onClick: post }, '发送')),
4995
+ // 契约 t-38 ③:mentionOnly 缺省 true 时提示「@ 才唤醒」
4996
+ current.settings?.mentionOnly !== false
4997
+ ? h('div', { className: 'csb-set-d', style: { fontSize: 12, marginTop: 4 } },
4998
+ '只有被 @ 的人会被唤醒;不 @ 就只记录不打扰。')
4999
+ : null,
4954
5000
  // ── 右栏:管理 ──
4955
5001
  h('div', { className: 'csb-room-side' },
4956
5002
  h(Section, { title: '房间信息', desc: '基本信息和设置' }),
@@ -5048,6 +5094,8 @@ window.__ModuleLoader__.load({
5048
5094
  allowBotAutoPost: room.settings?.allowBotAutoPost !== false,
5049
5095
  maxBotsPerRound: room.settings?.maxBotsPerRound ?? 3,
5050
5096
  relayEnabled: room.settings?.relayEnabled !== false,
5097
+ // 契约 t-38 ③:缺省 true —— 不 @ 就只落消息、不唤醒任何人
5098
+ mentionOnly: room.settings?.mentionOnly !== false,
5051
5099
  })
5052
5100
  const save = async () => {
5053
5101
  try {
@@ -5065,6 +5113,7 @@ window.__ModuleLoader__.load({
5065
5113
  allowBotAutoPost: settings.allowBotAutoPost,
5066
5114
  maxBotsPerRound: Number(settings.maxBotsPerRound) || 3,
5067
5115
  relayEnabled: settings.relayEnabled,
5116
+ mentionOnly: settings.mentionOnly,
5068
5117
  },
5069
5118
  byId: 'user',
5070
5119
  },
@@ -5130,7 +5179,14 @@ window.__ModuleLoader__.load({
5130
5179
  h('input', {
5131
5180
  type: 'checkbox', checked: settings.relayEnabled,
5132
5181
  onChange: (e) => setSettings({ ...settings, relayEnabled: e.target.checked }),
5133
- }), settings.relayEnabled ? '允许' : '不允许')))
5182
+ }), settings.relayEnabled ? '允许' : '不允许')),
5183
+ // 契约 t-38 ③:只有被 @ 的人才被唤醒;不 @ 就只记录不打扰(缺省开)
5184
+ h(Field, { label: '只有被 @ 的人才被唤醒', hint: '关掉后房间每条消息都会唤醒所有 Bot(不推荐,实测会烧 token)' },
5185
+ h('label', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
5186
+ h('input', {
5187
+ type: 'checkbox', checked: settings.mentionOnly,
5188
+ onChange: (e) => setSettings({ ...settings, mentionOnly: e.target.checked }),
5189
+ }), settings.mentionOnly ? '开启' : '关闭')))
5134
5190
  }
5135
5191
 
5136
5192
  // ── 成果(artifacts 聚合)────────────────────────────────────────────────
@@ -5467,7 +5523,7 @@ window.__ModuleLoader__.load({
5467
5523
  }
5468
5524
  /** 下拉里的合法取值(顺序即页面顺序:安全 → 宽松 → 逃生阀)。 */
5469
5525
  const ADMIN_POLICY_KEYS = ['lead-only', 'any-member', 'open']
5470
- /** 团队默认策略:人建的团队一律默认「仅负责人可管理」。 */
5526
+ /** 团队默认策略:Boss 建的团队一律默认「仅负责人可管理」。 */
5471
5527
  const DEFAULT_ADMIN_POLICY = 'lead-only'
5472
5528
  /**
5473
5529
  * 团队页(需求3,2026-11-08 重做)。
@@ -5480,7 +5536,7 @@ window.__ModuleLoader__.load({
5480
5536
  *
5481
5537
  * ## 由此确定的三条设计
5482
5538
  *
5483
- * 1. **上层只显示「用户建的团队」**。智能体自建的小团队不再占用上层版面——
5539
+ * 1. **上层只显示「Boss 建的团队」**。智能体自建的小团队不再占用上层版面——
5484
5540
  * 它们是执行细节,要看请走「穿透」。所以本页 `list({ type:'big' })` 过滤,
5485
5541
  * 且 UI 上**完全不出现「大/小」字样**(用户明确说不需要这个概念)。
5486
5542
  *
@@ -5506,7 +5562,7 @@ window.__ModuleLoader__.load({
5506
5562
  * 房间页本身已支持列出全部房间 + 消息内容查看,超级管理员不受限制。
5507
5563
  */
5508
5564
  function TeamsTab({ onGoto }) {
5509
- // 只取用户建的团队(big);智能体自建的 small 不上上层
5565
+ // 只取 Boss 建的团队(big);智能体自建的 small 不上上层
5510
5566
  const listQ = useApi('/teams?type=big', [])
5511
5567
  const rosterQ = useApi('/roster')
5512
5568
  // 契约 §3(UI):团队卡片要显示 `房间:<名>` —— 房间名不在团队记录里,
@@ -5536,6 +5592,13 @@ window.__ModuleLoader__.load({
5536
5592
  }
5537
5593
  const act = (p, body) => call(p, { method: 'POST', body })
5538
5594
 
5595
+ // 契约 第2批 ①:跨团队协作请求弹窗状态
5596
+ const [routeOpen, setRouteOpen] = useState(null)
5597
+ const [routeTarget, setRouteTarget] = useState('')
5598
+ const [routeText, setRouteText] = useState('')
5599
+ const [routeBusy, setRouteBusy] = useState(false)
5600
+ const [routeMsg, setRouteMsg] = useState(null)
5601
+
5539
5602
  const teams = listQ.data?.items ?? []
5540
5603
  // 后端下发的合法策略(/teams 返回 policies);万一旧后端没这个字段,就用本地常量兜底
5541
5604
  const policyKeys = listQ.data?.policies?.length ? listQ.data.policies : ADMIN_POLICY_KEYS
@@ -5567,7 +5630,7 @@ window.__ModuleLoader__.load({
5567
5630
  leadId,
5568
5631
  purpose: purpose.trim() || undefined,
5569
5632
  memberIds: selected,
5570
- // 人建的团队默认「仅负责人可管理」(服务端缺省是 open,为了不破坏既有调用面;
5633
+ // Boss 建的团队默认「仅负责人可管理」(服务端缺省是 open,为了不破坏既有调用面;
5571
5634
  // 页面是新入口,没有历史包袱,所以在这里给安全默认值)
5572
5635
  adminPolicy: DEFAULT_ADMIN_POLICY,
5573
5636
  by: 'ui',
@@ -5613,6 +5676,40 @@ window.__ModuleLoader__.load({
5613
5676
  } catch (e) { setMsg(String(e.message ?? e)) } finally { setBusy(false) }
5614
5677
  }
5615
5678
 
5679
+ // 契约 第2批 ①:跨团队发协作请求
5680
+ const sendRoute = async () => {
5681
+ if (!routeTarget) { setRouteMsg('请选择目标团队'); return }
5682
+ if (!routeText.trim()) { setRouteMsg('请填写请求内容'); return }
5683
+ const team = teams.find((t) => t.id === routeOpen)
5684
+ if (!team) { setRouteMsg('源团队不存在'); return }
5685
+ setRouteBusy(true); setRouteMsg(null)
5686
+ try {
5687
+ const r = await act('/teams/route', {
5688
+ fromTeam: routeOpen,
5689
+ toTeam: routeTarget,
5690
+ // taskId:服务层 `bot_team_route` 用它做环路去重键(`route:<from>:<to>:<taskId>`)。
5691
+ // 页面上的「发协作请求」不强制挂任务,所以这里不带 taskId,
5692
+ // 由服务层生成一个会话级临时 id(不挂具体任务 = 每一轮都是独立请求,不会被误去重)。
5693
+ text: routeText.trim(),
5694
+ hop: 1,
5695
+ by: 'ui',
5696
+ })
5697
+ if (r.ok && r.decision === 'delivered') {
5698
+ const toTeam = teams.find((t) => t.id === routeTarget)
5699
+ setRouteMsg(`已送达「${toTeam?.name ?? routeTarget}」的负责人(唤醒 1 次)`)
5700
+ setRouteText('')
5701
+ } else {
5702
+ const he = humanizeError({ code: r.decision, reason: r.reason, details: r.details })
5703
+ setRouteMsg(he.text + (he.hint ? ` —— ${he.hint}` : ''))
5704
+ }
5705
+ } catch (e) {
5706
+ const he = humanizeError(e)
5707
+ setRouteMsg(he.text + (he.hint ? ` —— ${he.hint}` : ''))
5708
+ } finally {
5709
+ setRouteBusy(false)
5710
+ }
5711
+ }
5712
+
5616
5713
  /**
5617
5714
  * 切换「谁能管理这个团队」(需求:管理员权限边界从页面控制)。
5618
5715
  *
@@ -5718,12 +5815,19 @@ window.__ModuleLoader__.load({
5718
5815
  const leadName = botBySession.get(t.leadId)?.name ?? (t.leadId ? short(t.leadId, 12) : '(未指定)')
5719
5816
  const pending = pendingMembers[t.id] ?? []
5720
5817
  const addable = bots.filter((b) => !members.includes(b.sessionId))
5818
+ // 契约 t-38 ①:徽标改文案。kind: 'human' | 'agent'(后端在加);
5819
+ // 旧数据无 kind ⇒ 按 createdBy 兜底(ui/user/person ⇒ Boss建设)。
5820
+ const teamKind = t.kind ?? (['ui','user','person'].includes(t.createdBy) ? 'human' : 'agent')
5821
+ const kindBadge = teamKind === 'human'
5822
+ ? h('span', { className: 'csb-b csb-b-run', title: 'Boss 建设的团队' }, '👤 Boss建设')
5823
+ : h('span', { className: 'csb-b csb-b-idle', title: '智能体建设的团队' }, '🤖 智能体建设')
5721
5824
  return h('div', { key: t.id, className: 'csb-card', style: { flexDirection: 'column', alignItems: 'stretch', marginBottom: 8 } },
5722
5825
  h('div', { className: 'csb-card-main' },
5723
5826
  h('div', { className: 'csb-card-t' },
5724
5827
  t.name,
5725
5828
  t.status === 'closed' ? h('span', { className: 'csb-b csb-b-idle', style: { marginLeft: 8 } }, '已关闭') : null,
5726
- t.purpose ? h('span', { className: 'csb-set-d' }, ` · ${t.purpose.slice(0, 40)}`) : null),
5829
+ t.purpose ? h('span', { className: 'csb-set-d' }, ` · ${t.purpose.slice(0, 40)}`) : null,
5830
+ h('span', { style: { marginLeft: 8 } }, kindBadge)),
5727
5831
 
5728
5832
  // 管理员权限边界(需求:管理员权限边界从页面控制)
5729
5833
  // 徽章:人要能一眼看出「谁能动这个团队」,所以常驻显示当前策略的中文名。
@@ -5834,13 +5938,77 @@ window.__ModuleLoader__.load({
5834
5938
  className: 'csb-btn csb-btn-s',
5835
5939
  onClick: () => onGoto?.('bots'),
5836
5940
  }, '🤖 看 Bot'),
5941
+ // 契约 t-38 ②:AI 团队不置灰,正常可进入查看
5942
+ h('button', {
5943
+ className: 'csb-btn csb-btn-s',
5944
+ onClick: () => onGoto?.('team', { sub: 'teams' }),
5945
+ }, '🏠 进入团队'),
5837
5946
  h('div', { className: 'csb-spacer' }),
5947
+
5948
+ // 契约 t-38 ②:AI 团队额外提示
5949
+ teamKind === 'agent'
5950
+ ? h('div', { className: 'csb-set-d', style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-text-sub)' } },
5951
+ '这是智能体自己建的团队,你可以查看和发言')
5952
+ : null,
5838
5953
  h('button', {
5839
5954
  className: 'csb-btn csb-btn-s csb-btn-d',
5840
5955
  disabled: busy, onClick: () => closeTeam(t),
5841
- }, '关闭团队')))
5956
+ }, '关闭团队')),
5957
+ // 契约 第2批 ③:团队卡片显示今日唤醒数
5958
+ t.wakeCountToday !== undefined
5959
+ ? h('div', { className: 'csb-set-d', style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-text-sub)' } },
5960
+ `今日唤醒 ${t.wakeCountToday} 次`)
5961
+ : null,
5962
+ // 契约 第2批 ①:跨团队发请求按钮
5963
+ h('button', {
5964
+ className: 'csb-btn csb-btn-s',
5965
+ disabled: busy,
5966
+ onClick: () => setRouteOpen(t.id),
5967
+ }, '📤 发协作请求'))
5842
5968
  : null)
5843
- })))
5969
+ })),
5970
+
5971
+ // 契约 第2批 ①:跨团队协作请求弹窗。
5972
+ // 后端端点按 /teams/route(B 在加 bot_team_route 工具,端点未定则按此先写)。
5973
+ routeOpen
5974
+ ? h(Modal, {
5975
+ title: '发协作请求给另一个团队',
5976
+ onClose: () => { setRouteOpen(null); setRouteMsg(null); setRouteText(''); setRouteTarget('') },
5977
+ footer: [
5978
+ h('button', {
5979
+ key: 'c', className: 'csb-btn',
5980
+ onClick: () => { setRouteOpen(null); setRouteMsg(null); setRouteText(''); setRouteTarget('') },
5981
+ }, '取消'),
5982
+ h('button', {
5983
+ key: 's', className: 'csb-btn csb-btn-p',
5984
+ disabled: routeBusy || !routeTarget || !routeText.trim(),
5985
+ onClick: sendRoute,
5986
+ }, routeBusy ? '发送中…' : '发送请求'),
5987
+ ],
5988
+ },
5989
+ h('div', null,
5990
+ h('div', { className: 'csb-fld' },
5991
+ h('div', { className: 'csb-fld-l' }, `从「${teams.find((x) => x.id === routeOpen)?.name ?? routeOpen}」发给`),
5992
+ h('select', {
5993
+ className: 'csb-sel', style: { width: '100%' },
5994
+ value: routeTarget,
5995
+ onChange: (e) => { setRouteTarget(e.target.value); setRouteMsg(null) },
5996
+ },
5997
+ h('option', { value: '' }, '选择目标团队…'),
5998
+ teams.filter((x) => x.id !== routeOpen && x.status !== 'closed')
5999
+ .map((x) => h('option', { key: x.id, value: x.id }, x.name)))),
6000
+ h('div', { className: 'csb-fld' },
6001
+ h('div', { className: 'csb-fld-l' }, '请求内容(会唤醒对方负责人一次)'),
6002
+ h('textarea', {
6003
+ className: 'csb-in', rows: 4, style: { width: '100%', resize: 'vertical' },
6004
+ placeholder: '一句话说清要对方做什么…',
6005
+ value: routeText, onChange: (e) => setRouteText(e.target.value),
6006
+ })),
6007
+ routeMsg
6008
+ ? h('div', { className: 'csb-notice csb-notice-ok', style: { marginTop: 8 } },
6009
+ h('span', { className: 'csb-notice-t' }, routeMsg))
6010
+ : null))
6011
+ : null)
5844
6012
  }
5845
6013
 
5846
6014
  // ══════════════════════════════════════════════════════════════════════════
@@ -5913,12 +6081,18 @@ window.__ModuleLoader__.load({
5913
6081
  const open = expanded[t.id] === true
5914
6082
  const lead = botBySession.get(t.leadId)
5915
6083
  const memberBots = (t.members ?? []).map((m) => botBySession.get(m)).filter(Boolean)
6084
+ // 契约 t-38 ①:同 TeamsTab 的 kindBadge 逻辑
6085
+ const teamKind = t.kind ?? (['ui','user','person'].includes(t.createdBy) ? 'human' : 'agent')
6086
+ const kindBadge = teamKind === 'human'
6087
+ ? h('span', { className: 'csb-b csb-b-run', title: 'Boss 建设的团队' }, '👤 Boss建设')
6088
+ : h('span', { className: 'csb-b csb-b-idle', title: '智能体建设的团队' }, '🤖 智能体建设')
5916
6089
  return h('div', { key: t.id, className: 'csb-set-c' },
5917
6090
  h('div', { className: 'csb-row', style: { alignItems: 'center' } },
5918
6091
  h('div', { style: { flex: 1, minWidth: 0 } },
5919
6092
  h('div', { style: { fontWeight: 600 } },
5920
6093
  t.name,
5921
- t.status === 'closed' ? h('span', { className: 'csb-set-d' }, ' (已关闭)') : null),
6094
+ t.status === 'closed' ? h('span', { className: 'csb-set-d' }, ' (已关闭)') : null,
6095
+ h('span', { style: { marginLeft: 8 } }, kindBadge)),
5922
6096
  h('div', { className: 'csb-set-d' },
5923
6097
  `负责人:${lead?.name ?? t.leadId ?? '(无)'}`,
5924
6098
  ` 成员 ${memberBots.length} 个`)),
@@ -5969,7 +6143,13 @@ window.__ModuleLoader__.load({
5969
6143
  h('button', {
5970
6144
  type: 'button', className: 'csb-btn csb-btn-s',
5971
6145
  onClick: () => onGoto?.('team', { sub: 'teams' }),
5972
- }, '→ 管理团队')))
6146
+ }, '→ 管理团队')),
6147
+ // 契约 t-38 ②:AI 团队额外提示
6148
+ teamKind === 'agent'
6149
+ ? h('div', { className: 'csb-set-d', style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-text-sub)' } },
6150
+ '这是智能体自己建的团队,你可以查看和发言')
6151
+ : null
6152
+ )
5973
6153
  }
5974
6154
 
5975
6155
  return h('div', { className: 'csb-wrap' },
@@ -8048,7 +8228,7 @@ window.__ModuleLoader__.load({
8048
8228
  exports.TaskConversationPanel = TaskConversationPanel
8049
8229
  // 需求4:PDCA 周期页(human / agent 两类周期)
8050
8230
  exports.CyclesTab = CyclesTab
8051
- // 需求3:团队页(只显示用户建的大团队)
8231
+ // 需求3:团队页(只显示 Boss 建的大团队)
8052
8232
  exports.TeamsTab = TeamsTab
8053
8233
  exports.PANEL_ID = PANEL_ID
8054
8234
  exports.API = API
package/src/dispatcher.js CHANGED
@@ -336,6 +336,8 @@ export function createDispatcher(ctx, config = {}) {
336
336
  result,
337
337
  // ── 平台扩展字段(§3.5 未列,但对「可读原因」与可审计是必需的)──
338
338
  text: draft.text,
339
+ // t-39 第2批 D:唤醒成本可见。无团队归属时是 null(不猜)。
340
+ teamId: draft.teamId ?? null,
339
341
  updatedAt: at,
340
342
  seq: takeSeq(),
341
343
  }
@@ -345,14 +347,22 @@ export function createDispatcher(ctx, config = {}) {
345
347
  return { wake, durable }
346
348
  }
347
349
 
348
- /** 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。 */
349
- function draftWake({ target, reason, dedupKey, text, at }) {
350
+ /**
351
+ * 构造 wake 草稿(此刻尚无终态;`result` 只在 `commit` 时赋值)。
352
+ *
353
+ * `teamId`:唤醒成本可见(t-39 第2批 D)。**不是所有唤醒都属于某个团队**
354
+ * (例如任务派活、房间广播都可能与团队无关)—— 那种情况一律写 `null`,
355
+ * 绝不按 target 猜一个团队塞进去:猜出来的数字比没有更糟,它会让
356
+ * 「每团队唤醒成本」的统计凭空多出一份不属于它的开销。
357
+ */
358
+ function draftWake({ target, reason, dedupKey, text, at, teamId }) {
350
359
  return {
351
360
  id: `wake-${at}-${process.pid.toString(36)}-${takeSeq().toString(36)}`,
352
361
  target,
353
362
  reason,
354
363
  dedupKey,
355
364
  text,
365
+ teamId: teamId ?? null,
356
366
  scheduledAt: at,
357
367
  }
358
368
  }
@@ -449,7 +459,7 @@ export function createDispatcher(ctx, config = {}) {
449
459
  */
450
460
  function notify(request = {}) {
451
461
  const at = now()
452
- const { target, reason, dedupKey, text, delayMs } = request
462
+ const { target, reason, dedupKey, text, delayMs, teamId } = request
453
463
  const wakeText = text ?? `[cs-bots 唤醒] ${REASON_TEXT[reason] ?? String(reason)}`
454
464
 
455
465
  // 每调用一次 = 一次决策,先计数(恒等式 total === sent + skipped + failed + pending 由此成立)。
@@ -462,7 +472,7 @@ export function createDispatcher(ctx, config = {}) {
462
472
  }
463
473
  if (!WAKE_REASONS.includes(reason)) {
464
474
  counters.failed += 1
465
- const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at })
475
+ const draft = draftWake({ target, reason: String(reason), dedupKey, text: wakeText, at, teamId })
466
476
  const { wake, durable } = commit(draft, 'failed', { at, detail: `invalid-reason:${reason}` })
467
477
  return { ok: false, result: 'failed', wakeId: wake.id, reason: 'invalid-reason', durable }
468
478
  }
@@ -474,7 +484,7 @@ export function createDispatcher(ctx, config = {}) {
474
484
  let wakeId
475
485
  let durable = probe.writable
476
486
  if (last === undefined || at - last >= SKIP_RECORD_INTERVAL_MS) {
477
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
487
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
478
488
  const committed = commit(draft, 'skipped', { at })
479
489
  wakeId = committed.wake.id
480
490
  durable = committed.durable
@@ -490,7 +500,7 @@ export function createDispatcher(ctx, config = {}) {
490
500
  if (queue.sizeOf(target) >= maxPerTarget) {
491
501
  counters.backpressured += 1
492
502
  counters.failed += 1
493
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
503
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
494
504
  // 请求被**拒绝**(而非「受理后没送成」)⇒ 该 dedupKey 尚未被消费,释放它,
495
505
  // 队列疏通后上游仍可用同一个 dedupKey 重发。
496
506
  if (dedupKey) dedup.keys.delete(dedupKey)
@@ -516,7 +526,7 @@ export function createDispatcher(ctx, config = {}) {
516
526
  deferred = notBefore > at
517
527
  }
518
528
 
519
- const draft = draftWake({ target, reason, dedupKey, text: wakeText, at })
529
+ const draft = draftWake({ target, reason, dedupKey, text: wakeText, at, teamId })
520
530
  const entry = { draft, notBefore, keyHeld: Boolean(dedupKey) }
521
531
  queue.add(entry)
522
532
  if (deferred) counters.deferred += 1
@@ -614,25 +624,74 @@ export function createDispatcher(ctx, config = {}) {
614
624
  /**
615
625
  * 从 `wakes.jsonl` 读唤醒记录(**读盘,不是读内存** —— 进程重启后仍可审计)。
616
626
  *
617
- * @param {{ target?: string, limit?: number }} [options]
627
+ * @param {{ target?: string, teamId?: string, limit?: number }} [options]
628
+ * `teamId` 是 t-39 第2批 D 加的:唤醒成本可见(按团队过滤)。
618
629
  * @returns {object[]} 终态 Wake 记录(落盘顺序;有 `limit` 时取**最后** limit 条)
619
630
  */
620
631
  function list(options = {}) {
621
- const { target, limit } = options
622
- // 先按 limit 取尾,再按 target 过滤 —— 语义是「最近 limit 条里属于该 target 的」。
632
+ const { target, teamId, limit } = options
633
+ // 先按 limit 取尾,再按 target / teamId 过滤 —— 语义是「最近 limit 条里属于该 target/team 的」。
623
634
  const { rows, bad, total } = readJsonl(wakesFile, limit ? { limit } : {})
624
- const filtered = target ? rows.filter((row) => row?.target === target) : rows
635
+ let filtered = rows
636
+ if (target) filtered = filtered.filter((row) => row?.target === target)
637
+ if (teamId) filtered = filtered.filter((row) => row?.teamId === teamId)
625
638
  // 坏行**不静默**:作为非枚举属性挂上,调用方可用 `Object.keys` 忽略、也可显式读取。
626
639
  Object.defineProperty(filtered, 'bad', { value: bad, enumerable: false })
627
640
  Object.defineProperty(filtered, 'total', { value: total, enumerable: false })
628
641
  return filtered
629
642
  }
630
643
 
644
+ /**
645
+ * 按团队聚合唤醒成本(t-39 第2批 D)。
646
+ *
647
+ * 为什么需要:Boss 要看「哪个团队在消耗唤醒配额」。单看 target 看不出团队归属
648
+ * (target 是会话 id,团队归属在 `teamId` 上)。
649
+ *
650
+ * 只聚合**真正有 teamId 的记录**;`teamId === null` 的(任务派活、房间广播等
651
+ * 与团队无关的唤醒)单独归入 `teamId: null` 桶,不混进任何团队数字里。
652
+ *
653
+ * @param {{ since?: number }} [options] `since` = epoch ms,只统计该时刻之后的记录
654
+ * @returns {{ since?: number, teams: object[], unassigned: object, total: number }}
655
+ */
656
+ function byTeam(options = {}) {
657
+ const { since } = options
658
+ const { rows } = readJsonl(wakesFile, { limit: 100_000 })
659
+ const buckets = new Map()
660
+ let total = 0
661
+ for (const row of rows) {
662
+ const at = row?.scheduledAt ?? row?.at
663
+ if (since !== undefined && Number.isFinite(since) && Number.isFinite(at) && at < since) continue
664
+ total += 1
665
+ const key = row?.teamId ?? null
666
+ if (!buckets.has(key)) {
667
+ buckets.set(key, { teamId: key, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: new Set() })
668
+ }
669
+ const b = buckets.get(key)
670
+ b.count += 1
671
+ const r = row?.result
672
+ if (r === 'sent') b.sent += 1
673
+ else if (r === 'skipped') b.skipped += 1
674
+ else if (r === 'failed') b.failed += 1
675
+ else if (r === 'pending') b.pending += 1
676
+ if (row?.target) b.targets.add(row.target)
677
+ }
678
+ const teams = [...buckets.entries()]
679
+ .filter(([key]) => key !== null)
680
+ .map(([, b]) => ({ ...b, targets: b.targets.size, targetsList: [...b.targets] }))
681
+ .sort((a, b) => b.count - a.count)
682
+ const unassignedEntry = buckets.get(null)
683
+ const unassigned = unassignedEntry
684
+ ? { teamId: null, count: unassignedEntry.count, sent: unassignedEntry.sent, skipped: unassignedEntry.skipped, failed: unassignedEntry.failed, pending: unassignedEntry.pending, targets: unassignedEntry.targets.size, targetsList: [...unassignedEntry.targets] }
685
+ : { teamId: null, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: 0, targetsList: [] }
686
+ return { since: since ?? null, teams, unassigned, total }
687
+ }
688
+
631
689
  return {
632
690
  notify,
633
691
  flush,
634
692
  stats,
635
693
  list,
694
+ byTeam,
636
695
  /** 只读诊断:状态目录 / 文件路径 / 是否可写。 */
637
696
  paths: { stateDir: stateDirectory, wakesFile },
638
697
  /** 状态目录可否持久化;`false` ⇒ 所有返回值都会带 `durable:false`。 */
package/src/index.js CHANGED
@@ -127,7 +127,14 @@ function applyInner(ctx, config, { log, warn }) {
127
127
  // ⚠️ 2026-11-05(P0):createBoard 必须拿到 settings —— wipLimitPerBot(WIP 上限)
128
128
  // 与 requireTriageForReady(Ready 闸门)都从 config.settings 读。只传 stateDir 会让
129
129
  // 平台设置对任务板静默失效(永远退回代码内默认值),且用户改了设置看不到任何变化。
130
- services.board = createBoard(ctx, { stateDir: config.stateDir, leaseMs: settings.defaultLeaseMs, settings })
130
+ services.board = createBoard(ctx, {
131
+ stateDir: config.stateDir,
132
+ leaseMs: settings.defaultLeaseMs,
133
+ settings,
134
+ // 指标旁路(bots04 欠账2):传**取值函数**而不是实例 ——
135
+ // metrics 在 L276 才装配,此刻还是 undefined。传函数可规避装配顺序耦合。
136
+ metrics: () => services.metrics,
137
+ })
131
138
  log(`[cs-bots] BoardService 就绪(stateDir=${services.board.paths?.stateDir ?? '?'})`)
132
139
  // ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
133
140
  // owner 崩溃/失联后任务永久停在 in_progress(连"重启时回收"都不做)。
package/src/kernel.js CHANGED
@@ -297,6 +297,21 @@ export function hostLlmAvailable() {
297
297
  * sole ordinary message of its own turn."
298
298
  * ⇒ 适合「有新活要干」的唤醒。
299
299
  *
300
+ * ⚠️ **`followup` 在 `agent` 对象上,不在 `agents` 服务上** —— 这里极易搞错。
301
+ *
302
+ * 写测试替身时,很多人会自然地把 `followup` 挂到假 `agents` 服务上
303
+ * (像 `create` / `get` / `list` 那样),结果永远拿到 `followup-unsupported`,
304
+ * 而且报错信息不指向真正的原因。2026-11-11 实测踩过一次,
305
+ * 排查时还一度以为是 `assertDeliverable` 拦的。
306
+ *
307
+ * 正确形状:
308
+ * ```js
309
+ * get: (key) => key === 'agents' ? {
310
+ * get: (id) => ({ id, sessionId: id, followup(msg) { ... } }), // ← 挂在这里
311
+ * // 不是这里:followup(msg) {}
312
+ * } : undefined
313
+ * ```
314
+ *
300
315
  * @param {object} ctx
301
316
  * @param {string} sessionId
302
317
  * @param {string} text