Prechádzať zdrojové kódy

feat: Bot 工作组二期(组长分派任务、能力卡片、协作工具、限额与暂停、替身组员、逐步执行)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 týždeň pred
rodič
commit
4855d3f7f4
34 zmenil súbory, kde vykonal 4515 pridanie a 123 odobranie
  1. 213 9
      apps/admin/src/views/BotPlaygroundView.vue
  2. 6 2
      apps/admin/src/views/BotsView.vue
  3. 15 0
      apps/admin/src/views/SettingsView.vue
  4. 41 0
      apps/server/drizzle/0007_bot_jobs.sql
  5. 2215 0
      apps/server/drizzle/meta/0007_snapshot.json
  6. 7 0
      apps/server/drizzle/meta/_journal.json
  7. 107 6
      apps/server/src/api-reference/bot-reference.ts
  8. 6 2
      apps/server/src/app.ts
  9. 47 1
      apps/server/src/db/schema.ts
  10. 1 14
      apps/server/src/http/routes/admin.ts
  11. 1 1
      apps/server/src/lib/ids.ts
  12. 6 4
      apps/server/src/mcp-gateway/server.ts
  13. 3 0
      apps/server/src/mcp-gateway/tokens.ts
  14. 4 0
      apps/server/src/services/runs.ts
  15. 10 0
      apps/server/src/services/settings.ts
  16. 31 0
      apps/server/src/services/skill-catalog.ts
  17. 70 0
      apps/server/src/teams/cards.ts
  18. 127 0
      apps/server/src/teams/jobs.ts
  19. 62 8
      apps/server/src/teams/prompt.ts
  20. 25 2
      apps/server/src/teams/routes.ts
  21. 665 61
      apps/server/src/teams/service.ts
  22. 71 0
      apps/server/src/teams/tools.ts
  23. 1 0
      apps/server/test/api-reference.test.ts
  24. 342 0
      apps/server/test/team-jobs.test.ts
  25. 1 1
      docs/README.md
  26. 2 2
      docs/design/11-roadmap.md
  27. 23 1
      docs/design/13-bot-teams.md
  28. 166 0
      packages/chat-ui/src/components/TeamCard.vue
  29. 52 1
      packages/chat-ui/src/components/TeamChat.vue
  30. 1 0
      packages/chat-ui/src/index.ts
  31. 21 2
      packages/chat-ui/src/team-store.ts
  32. 17 4
      packages/client/src/bot.ts
  33. 3 0
      packages/shared/src/settings.ts
  34. 153 2
      packages/shared/src/teams.ts

+ 213 - 9
apps/admin/src/views/BotPlaygroundView.vue

@@ -1,9 +1,10 @@
 <script setup lang="ts">
-// Bot Playground(docs/design/13-bot-teams.md §12.2):以后台身份调用 /bot/v1,建工作组、下命令、查看每个机器人的执行过程
+// Bot Playground(docs/design/13-bot-teams.md §12.2):以后台身份调用 /bot/v1,建工作组、下命令、查看每个机器人的执行过程;
+// 调试面板有成员(能力卡片、替身)、任务、过程、输入、文件、用量页签,顶栏可以开启逐步执行
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { AgentChat, BotAvatar, TeamChat, avatarText, basename, fileIcon, formatSize, useChatSession, useTeamSession, type ChatSession, type TeamSession } from '@agent-server/chat-ui'
-import type { BotDto, FileDto, TeamDto, TeamStatus } from '@agent-server/shared'
+import type { BotCard, BotDto, FileDto, JobDto, TaskDto, TeamDebug, TeamDto, TeamStatus, TeamUsageDto } from '@agent-server/shared'
 import FilePreview from '../components/FilePreview.vue'
 import SidePane from '../components/SidePane.vue'
 import { admin, botClient, client, errorMessage } from '../lib/api'
@@ -38,7 +39,9 @@ async function loadTeams() {
 watch(() => [t.value?.status, t.value?.bots.length, t.value?.updatedAt], () => void loadTeams())
 
 // ---- 新建工作组 ----
-const form = reactive<{ title: string; lead: string; workers: string[]; human: string; saving: boolean }>({ title: '', lead: '', workers: [], human: '管理员', saving: false })
+const form = reactive<{ title: string; lead: string; workers: string[]; human: string; maxTasks: number | null; maxLeadRuns: number | null; saving: boolean }>({
+  title: '', lead: '', workers: [], human: '管理员', maxTasks: null, maxLeadRuns: null, saving: false,
+})
 const leads = computed(() => bots.value.filter(b => b.kind === 'lead' && b.enabled))
 const workers = computed(() => bots.value.filter(b => b.kind === 'worker' && b.enabled))
 const formValid = computed(() => (form.lead || form.workers.length) && form.human.trim())
@@ -50,8 +53,12 @@ async function createTeam() {
     const created = await botClient.teams.create({
       title: form.title.trim() || undefined, human: { id: 'admin', name: form.human.trim() },
       leadBotId: form.lead || undefined, workerBotIds: form.workers,
+      limits: {
+        ...(form.maxTasks ? { maxTasksPerJob: form.maxTasks } : {}),
+        ...(form.maxLeadRuns ? { maxLeadRunsPerJob: form.maxLeadRuns } : {}),
+      },
     })
-    Object.assign(form, { title: '', lead: '', workers: [] })
+    Object.assign(form, { title: '', lead: '', workers: [], maxTasks: null, maxLeadRuns: null })
     await loadTeams()
     await router.push(`/bot-playground/${created.id}`)
   } catch (err) {
@@ -73,6 +80,15 @@ async function act(fn: () => Promise<unknown>) {
 
 type TeamAction = 'pause' | 'resume' | 'close' | 'reopen'
 const setStatus = (action: TeamAction) => act(() => botClient.teams[action](teamId.value!))
+
+// 调试设置:替身组员、逐步执行
+const debug = computed<TeamDebug>(() => t.value?.debug ?? { stepMode: false, stubBotIds: [] })
+const setDebug = (patch: Partial<TeamDebug>) => act(() => botClient.teams.update(teamId.value!, { debug: patch }))
+const toggleStep = () => setDebug({ stepMode: !debug.value.stepMode })
+function toggleStub(botId: string) {
+  const list = debug.value.stubBotIds
+  void setDebug({ stubBotIds: list.includes(botId) ? list.filter(x => x !== botId) : [...list, botId] })
+}
 const interruptBot = (botId: string) => act(() => botClient.teams.interruptBot(teamId.value!, botId))
 const removeBot = (botId: string) => act(() => botClient.teams.removeBot(teamId.value!, botId))
 function addBot(e: Event) {
@@ -102,7 +118,37 @@ const addable = computed(() => {
 
 // ---- 调试面板 ----
 const showPanel = ref(true)
-const tab = ref<'members' | 'process' | 'files'>('members')
+const TABS = [['members', '成员'], ['tasks', '任务'], ['process', '过程'], ['input', '输入'], ['files', '文件'], ['usage', '用量']] as const
+type Tab = (typeof TABS)[number][0]
+const tab = ref<Tab>('members')
+
+// 成员:能力卡片预览
+const cards = ref<BotCard[]>([])
+const cardOpen = ref<string | null>(null)
+const cardOf = (id: string) => cards.value.find(c => c.id === id)
+const botNameOf = (id: string) => t.value?.bots.find(b => b.id === id)?.name ?? bots.value.find(b => b.id === id)?.name ?? '(已移出)'
+const botAvatarOf = (id: string) => t.value?.bots.find(b => b.id === id)?.avatar ?? bots.value.find(b => b.id === id)?.avatar ?? ''
+
+// 任务:任务与子任务的树形视图
+const JOB_STATUS: Record<JobDto['status'], { text: string; cls: string }> = {
+  running: { text: '进行中', cls: 'text-accent' }, blocked: { text: '超出限额', cls: 'text-warning' },
+  completed: { text: '已完成', cls: 'text-success' }, failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
+}
+const TASK_STATUS: Record<TaskDto['status'], { text: string; cls: string }> = {
+  waiting: { text: '等待依赖', cls: 'text-fg-muted' }, queued: { text: '排队中', cls: 'text-fg-muted' }, running: { text: '执行中', cls: 'text-accent' },
+  stub: { text: '等替身结果', cls: 'text-warning' }, completed: { text: '已完成', cls: 'text-success' }, failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
+}
+const jobList = computed(() => Object.values(team.value?.state.jobs ?? {}).sort((a, b) => b.no - a.no))
+const tasksOf = (jobId: string) => Object.values(team.value?.state.tasks ?? {}).filter(x => x.jobId === jobId).sort((a, b) => a.no - b.no)
+function duration(from: string | null, to: string | null) {
+  if (!from) return ''
+  const sec = Math.max(0, Math.round(((to ? Date.parse(to) : Date.now()) - Date.parse(from)) / 1000))
+  return sec < 60 ? `${sec} 秒` : `${Math.floor(sec / 60)} 分 ${sec % 60} 秒`
+}
+const money = (n: number) => `$${n.toFixed(n >= 1 ? 2 : 4)}`
+function openTask(x: TaskDto) {
+  if (x.sessionId) openProcess({ botId: x.botId, sessionId: x.sessionId, runId: x.runId })
+}
 
 // 过程:某个机器人的会话(只读),定位到指定的一轮
 const proc = reactive<{ botId: string; sessionId: string; runId: string | null }>({ botId: '', sessionId: '', runId: null })
@@ -157,6 +203,31 @@ function openFile(f: OpenFile) {
   preview.value = f
 }
 
+// 输入:每一轮服务端实际交给机器人的内容
+const inputBot = ref('')
+const inputs = ref<{ seq: number; ts: string; runId: string | null; text: string }[]>([])
+async function loadInputs() {
+  const b = t.value?.bots.find(x => x.id === inputBot.value) ?? t.value?.bots.find(x => x.sessionId)
+  if (!b?.sessionId) {
+    inputs.value = []
+    return
+  }
+  inputBot.value = b.id
+  const { items } = await client.sessions.events(b.sessionId).catch(() => ({ items: [] }))
+  inputs.value = items.filter(e => e.type === 'user.message')
+    .map(e => ({ seq: e.seq, ts: e.ts, runId: e.runId, text: (e.payload as { text: string }).text })).reverse()
+}
+const botStatuses = computed(() => t.value?.bots.map(b => `${b.id}:${b.status}`).join() ?? '')
+watch([tab, inputBot, botStatuses], () => {
+  if (tab.value === 'input') void loadInputs()
+})
+
+// 用量:按机器人、任务、子任务
+const usage = ref<TeamUsageDto | null>(null)
+watch([tab, botStatuses], async () => {
+  if (tab.value === 'usage' && teamId.value) usage.value = await botClient.teams.usage(teamId.value).catch(() => null)
+})
+
 // 切换工作组:放在调试面板的状态声明之后(immediate 会立即执行)
 watch(teamId, id => {
   team.value?.dispose()
@@ -164,6 +235,9 @@ watch(teamId, id => {
   closeProcess()
   preview.value = null
   files.value = []
+  inputs.value = []
+  inputBot.value = ''
+  usage.value = null
   if (id) team.value = useTeamSession(botClient, id)
 }, { immediate: true })
 
@@ -171,6 +245,7 @@ let timer: ReturnType<typeof setInterval> | undefined
 onMounted(async () => {
   void loadTeams()
   bots.value = await admin.bots().catch(() => [])
+  cards.value = await botClient.bots().catch(() => [])
   timer = setInterval(loadTeams, 15000)
 })
 onBeforeUnmount(() => {
@@ -242,6 +317,13 @@ onBeforeUnmount(() => {
             </label>
           </div>
         </div>
+        <details>
+          <summary class="label cursor-pointer">限额(可选,只能比系统设置低)</summary>
+          <div class="flex gap-3 mt-1">
+            <div class="flex-1"><label class="label" for="team-max-tasks">每个任务的子任务数</label><input id="team-max-tasks" v-model.number="form.maxTasks" type="number" min="1" class="field" placeholder="系统设置"></div>
+            <div class="flex-1"><label class="label" for="team-max-runs">组长执行轮数</label><input id="team-max-runs" v-model.number="form.maxLeadRuns" type="number" min="1" class="field" placeholder="系统设置"></div>
+          </div>
+        </details>
         <div v-if="!bots.length" class="hint">请先在<RouterLink to="/bots" class="text-link">机器人</RouterLink>页面新建机器人</div>
         <div class="flex justify-end">
           <button type="submit" class="btn-primary" :disabled="!formValid || form.saving"><span class="codicon codicon-add" />新建工作组</button>
@@ -255,6 +337,12 @@ onBeforeUnmount(() => {
         <span v-if="t" class="badge" :class="STATUS[t.status].cls">{{ STATUS[t.status].text }}</span>
         <span class="flex-1" />
         <template v-if="t">
+          <button
+            type="button" class="btn-ghost h-6" :class="{ '!text-warning': debug.stepMode }"
+            title="逐步执行:每次唤醒机器人之前先停下,在时间线中点“继续”才执行" @click="toggleStep"
+          >
+            <span class="codicon codicon-debug-step-over" />逐步{{ debug.stepMode ? ':开' : '' }}
+          </button>
           <button v-if="t.status === 'active'" type="button" class="icon-btn" title="暂停:中断正在执行的工作,新消息只记录" @click="setStatus('pause')"><span class="codicon codicon-debug-pause" /></button>
           <button v-if="t.status === 'paused'" type="button" class="icon-btn" title="恢复" @click="setStatus('resume')"><span class="codicon codicon-debug-continue" /></button>
           <button v-if="t.status !== 'closed'" type="button" class="icon-btn" title="关闭工作组" @click="setStatus('close')"><span class="codicon codicon-archive" /></button>
@@ -268,11 +356,11 @@ onBeforeUnmount(() => {
         <div class="flex-1 min-w-0">
           <TeamChat v-if="team" :key="teamId!" :client="botClient" :session="team" @open-file="openFile" @open-run="openProcess" />
         </div>
-        <div v-if="showPanel && t" class="flex-none min-h-0 flex flex-col bg-bg-side border-l border-l-solid border-border" :class="tab === 'process' ? 'w-[520px]' : 'w-[360px]'">
+        <div v-if="showPanel && t" class="flex-none min-h-0 flex flex-col bg-bg-side border-l border-l-solid border-border" :class="tab === 'process' || tab === 'input' ? 'w-[520px]' : 'w-[380px]'">
           <div class="h-[35px] flex-none flex items-stretch border-b border-b-solid border-border text-[11px] font-600 uppercase tracking-wide">
             <button
-              v-for="x in ([['members', '成员'], ['process', '过程'], ['files', '文件']] as const)" :key="x[0]" type="button"
-              class="px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1"
+              v-for="x in TABS" :key="x[0]" type="button"
+              class="px-2.5 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-1"
               :class="tab === x[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = x[0]; preview = null"
             >
               {{ x[1] }}
@@ -292,8 +380,26 @@ onBeforeUnmount(() => {
               </div>
               <div class="hint">{{ b.provider ?? '?' }} / {{ b.model ?? '?' }}</div>
               <div v-if="b.description" class="hint line-clamp-2">{{ b.description }}</div>
-              <div class="flex gap-1">
+              <div v-if="cardOpen === b.id && cardOf(b.id)" class="flex flex-col gap-1 p-2 rounded-md bg-code">
+                <div class="font-600">能力卡片<span class="hint font-400">(组长看到的内容)</span></div>
+                <div v-if="cardOf(b.id)!.skills.length">
+                  <div class="hint">技能</div>
+                  <div v-for="sk in cardOf(b.id)!.skills" :key="sk.name" class="truncate" :title="sk.description"><b>{{ sk.name }}</b> <span class="hint">{{ sk.description }}</span></div>
+                </div>
+                <div v-else class="hint">没有技能</div>
+                <div v-if="cardOf(b.id)!.mcpServers.length"><span class="hint">外部服务:</span>{{ cardOf(b.id)!.mcpServers.map(m => m.title).join('、') }}</div>
+                <div v-if="cardOf(b.id)!.knowledge.length"><span class="hint">资料库:</span>{{ cardOf(b.id)!.knowledge.join('、') }}</div>
+                <div><span class="hint">权限:</span>{{ { ask: '需审批', auto: '自动', readonly: '只读' }[cardOf(b.id)!.permissionMode ?? 'ask'] }}</div>
+              </div>
+              <div class="flex flex-wrap gap-1 items-center">
                 <button type="button" class="btn-ghost" :disabled="!b.sessionId" @click="pickBot(b.id)"><span class="codicon codicon-list-tree" />过程</button>
+                <button type="button" class="btn-ghost" :disabled="!cardOf(b.id)" @click="cardOpen = cardOpen === b.id ? null : b.id"><span class="codicon codicon-symbol-class" />卡片</button>
+                <label
+                  v-if="b.role === 'worker'" class="flex items-center gap-1 px-1 cursor-pointer" :class="{ 'text-warning': debug.stubBotIds.includes(b.id) }"
+                  title="替身:分派给它的子任务不执行,由你在时间线中填写结果"
+                >
+                  <input type="checkbox" :checked="debug.stubBotIds.includes(b.id)" @change="toggleStub(b.id)">替身
+                </label>
                 <button v-if="b.status !== 'idle'" type="button" class="btn-ghost" @click="interruptBot(b.id)"><span class="codicon codicon-debug-stop" />停止</button>
                 <span class="flex-1" />
                 <button type="button" class="btn-ghost" :disabled="t.bots.length <= 1" @click="removeBot(b.id)">移出</button>
@@ -308,6 +414,104 @@ onBeforeUnmount(() => {
             </select>
           </div>
 
+          <!-- 任务 -->
+          <div v-else-if="tab === 'tasks'" class="flex-1 min-h-0 overflow-y-auto p-3 flex flex-col gap-3 text-[12.5px]">
+            <div class="hint">限额:子任务 {{ t.limits.maxTasksPerJob }} 个 · 组长 {{ t.limits.maxLeadRunsPerJob }} 轮 · {{ t.limits.maxMinutesPerJob }} 分钟{{ t.limits.maxCostUsdPerJob ? ` · $${t.limits.maxCostUsdPerJob}` : '' }} · 同时 {{ t.limits.maxConcurrentRuns }} 个 Run</div>
+            <div v-if="!jobList.length" class="hint">还没有任务。组长判断需要组员配合时会开始一项任务,并把子任务分派给组员。</div>
+            <div v-for="j in jobList" :key="j.id" class="card p-2.5 flex flex-col gap-1.5">
+              <div class="flex items-center gap-2">
+                <span class="codicon codicon-tasklist" />
+                <span class="font-600 truncate">#{{ j.no }} {{ j.title }}</span>
+                <span class="flex-1" />
+                <span class="text-[11px]" :class="JOB_STATUS[j.status].cls">{{ JOB_STATUS[j.status].text }}</span>
+              </div>
+              <div class="hint">组长 {{ j.leadRuns }} 轮 · {{ j.taskCount }} 个子任务 · {{ money(j.costUsd) }} · {{ duration(j.createdAt, j.endedAt) }}</div>
+              <div v-if="j.plan" class="hint line-clamp-3 whitespace-pre-wrap" :title="j.plan">{{ j.plan }}</div>
+              <div v-if="j.summary" class="line-clamp-3 whitespace-pre-wrap" :title="j.summary">{{ j.summary }}</div>
+              <div v-if="j.status === 'running' || j.status === 'blocked'" class="flex gap-1">
+                <button v-if="j.status === 'blocked'" type="button" class="btn-ghost" @click="act(() => team!.continueJob(j.id))"><span class="codicon codicon-debug-continue" />继续</button>
+                <button type="button" class="btn-ghost" @click="act(() => team!.cancelJob(j.id))"><span class="codicon codicon-circle-slash" />取消任务</button>
+              </div>
+              <div class="flex flex-col border-l-2 border-l-solid border-border ml-1.5 pl-2 gap-1">
+                <div
+                  v-for="x in tasksOf(j.id)" :key="x.id" class="flex flex-col gap-0.5 rounded-md px-1.5 py-1"
+                  :class="x.sessionId ? 'cursor-pointer hover:bg-hover' : ''" :title="x.sessionId ? '查看执行过程' : ''" @click="openTask(x)"
+                >
+                  <div class="flex items-center gap-1.5">
+                    <span class="font-mono hint">#{{ x.no }}</span>
+                    <BotAvatar :value="botAvatarOf(x.botId)" :size="16" />
+                    <span class="truncate">{{ botNameOf(x.botId) }} · {{ x.title }}</span>
+                    <span class="flex-1" />
+                    <span class="text-[11px] whitespace-nowrap" :class="TASK_STATUS[x.status].cls">{{ TASK_STATUS[x.status].text }}</span>
+                  </div>
+                  <div class="hint">
+                    <template v-if="x.dependsOn.length">依赖 {{ x.dependsOn.map(n => `#${n}`).join('、') }} · </template>{{ duration(x.startedAt, x.endedAt) || '未开始' }}<template v-if="x.costUsd"> · {{ money(x.costUsd) }}</template>
+                  </div>
+                  <div v-if="x.result" class="line-clamp-2 whitespace-pre-wrap" :title="x.result">{{ x.result }}</div>
+                </div>
+                <div v-if="!tasksOf(j.id).length" class="hint">还没有子任务</div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 输入 -->
+          <template v-else-if="tab === 'input'">
+            <div class="flex-none flex items-center gap-2 px-3 h-9 border-b border-b-solid border-border">
+              <select v-model="inputBot" class="field h-7" aria-label="选择机器人">
+                <option value="" disabled>选择机器人…</option>
+                <option v-for="b in t.bots" :key="b.id" :value="b.id" :disabled="!b.sessionId">{{ avatarText(b.avatar) }} {{ b.name }}{{ b.sessionId ? '' : '(还没有工作过)' }}</option>
+              </select>
+            </div>
+            <div class="flex-1 min-h-0 overflow-y-auto p-3 flex flex-col gap-2 text-[12.5px]">
+              <div class="hint">每一轮服务端实际交给这个机器人的内容(拼好的新消息、子任务说明),新的在前。排查组长的问题时先看这里。</div>
+              <div v-if="!inputs.length" class="hint">还没有输入</div>
+              <div v-for="x in inputs" :key="x.seq" class="card p-2 flex flex-col gap-1">
+                <div class="flex items-center gap-2 hint">
+                  <span>{{ new Date(x.ts).toLocaleString('zh-CN', { hour12: false }) }}</span>
+                  <span class="flex-1" />
+                  <button v-if="x.runId" type="button" class="btn-ghost h-5" @click="openProcess({ botId: inputBot, sessionId: t.bots.find(b => b.id === inputBot)!.sessionId!, runId: x.runId })"><span class="codicon codicon-list-tree" />过程</button>
+                </div>
+                <pre class="m-0 max-h-80 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[12px]">{{ x.text }}</pre>
+              </div>
+            </div>
+          </template>
+
+          <!-- 用量 -->
+          <div v-else-if="tab === 'usage'" class="flex-1 min-h-0 overflow-y-auto p-3 flex flex-col gap-3 text-[12.5px]">
+            <div v-if="!usage" class="hint">加载中…</div>
+            <template v-else>
+              <div class="card p-2.5 flex gap-4">
+                <div><div class="hint">Run</div><div class="font-600">{{ usage.total.runs }}</div></div>
+                <div><div class="hint">输入 / 输出 token</div><div class="font-600">{{ usage.total.inputTokens.toLocaleString() }} / {{ usage.total.outputTokens.toLocaleString() }}</div></div>
+                <div><div class="hint">成本</div><div class="font-600">{{ money(usage.total.costUsd) }}</div></div>
+              </div>
+              <table class="w-full border-collapse">
+                <thead><tr class="text-left hint"><th class="font-500 py-1">机器人</th><th class="font-500">Run</th><th class="font-500">token</th><th class="font-500 text-right">成本</th></tr></thead>
+                <tbody>
+                  <tr v-for="r in usage.bots" :key="r.botId" class="border-t border-t-solid border-border">
+                    <td class="py-1"><span class="inline-flex items-center gap-1"><BotAvatar :value="botAvatarOf(r.botId)" :size="16" />{{ botNameOf(r.botId) }}</span></td>
+                    <td>{{ r.runs }}</td><td>{{ (r.inputTokens + r.outputTokens).toLocaleString() }}</td><td class="text-right">{{ money(r.costUsd) }}</td>
+                  </tr>
+                </tbody>
+              </table>
+              <table v-if="usage.jobs.length" class="w-full border-collapse">
+                <thead><tr class="text-left hint"><th class="font-500 py-1">任务 / 子任务</th><th class="font-500">Run</th><th class="font-500 text-right">成本</th></tr></thead>
+                <tbody>
+                  <template v-for="r in usage.jobs" :key="r.jobId">
+                    <tr class="border-t border-t-solid border-border">
+                      <td class="py-1 font-600 truncate max-w-50">#{{ team?.state.jobs[r.jobId]?.no }} {{ team?.state.jobs[r.jobId]?.title }}</td>
+                      <td>{{ r.runs }}</td><td class="text-right">{{ money(r.costUsd) }}</td>
+                    </tr>
+                    <tr v-for="x in usage.tasks.filter(u => team?.state.tasks[u.taskId]?.jobId === r.jobId)" :key="x.taskId">
+                      <td class="py-0.5 pl-4 hint truncate max-w-50">#{{ team?.state.tasks[x.taskId]?.no }} {{ botNameOf(team?.state.tasks[x.taskId]?.botId ?? '') }} · {{ team?.state.tasks[x.taskId]?.title }}</td>
+                      <td class="hint">{{ x.runs }}</td><td class="text-right hint">{{ money(x.costUsd) }}</td>
+                    </tr>
+                  </template>
+                </tbody>
+              </table>
+            </template>
+          </div>
+
           <!-- 过程 -->
           <template v-else-if="tab === 'process'">
             <div class="flex-none flex items-center gap-2 px-3 h-9 border-b border-b-solid border-border">

+ 6 - 2
apps/admin/src/views/BotsView.vue

@@ -96,12 +96,16 @@ async function removeBot(b: BotDto) {
   await load()
 }
 
-/** 在 Bot Playground 中测试:新建一个只有它的工作组(组长则单独成组,由它直接回复) */
+/**
+ * 在 Bot Playground 中测试:组员新建只有它的工作组;
+ * 组长新建"组长 + 全部可用组员",组员都设为替身,不花组员的成本就能反复测试组长的拆分与汇总
+ */
 async function tryBot(b: BotDto) {
   try {
+    const workers = b.kind === 'lead' ? bots.value.filter(x => x.kind === 'worker' && x.enabled).map(x => x.id) : [b.id]
     const t = await botClient.teams.create({
       title: `测试 · ${b.name}`, human: { id: 'admin', name: '管理员' },
-      ...(b.kind === 'lead' ? { leadBotId: b.id } : { workerBotIds: [b.id] }),
+      ...(b.kind === 'lead' ? { leadBotId: b.id, workerBotIds: workers, debug: { stubBotIds: workers } } : { workerBotIds: workers }),
     })
     await router.push(`/bot-playground/${t.id}`)
   } catch (err) {

+ 15 - 0
apps/admin/src/views/SettingsView.vue

@@ -144,6 +144,21 @@ const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
           <div class="hint">同一份订阅额度与你在终端中的使用共享,并发不宜过高。</div>
         </section>
 
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">工作组限额</h2>
+          <div class="grid grid-cols-3 gap-3">
+            <div><label class="label" for="t-tasks">每个任务的子任务数</label><input id="t-tasks" v-model.number="draft.teamLimits.maxTasksPerJob" type="number" min="1" class="field"></div>
+            <div><label class="label" for="t-runs">每个任务中组长的执行轮数</label><input id="t-runs" v-model.number="draft.teamLimits.maxLeadRunsPerJob" type="number" min="1" class="field"></div>
+            <div><label class="label" for="t-min">每个任务的时长(分钟)</label><input id="t-min" v-model.number="draft.teamLimits.maxMinutesPerJob" type="number" min="1" class="field"></div>
+            <div>
+              <label class="label" for="t-cost">每个任务的成本上限(美元,留空不限)</label>
+              <input id="t-cost" class="field" type="number" min="0.01" step="0.01" :value="draft.teamLimits.maxCostUsdPerJob ?? ''" @change="draft.teamLimits.maxCostUsdPerJob = ($event.target as HTMLInputElement).value ? Number(($event.target as HTMLInputElement).value) : null">
+            </div>
+            <div><label class="label" for="t-conc">每个工作组同时执行的 Run 数</label><input id="t-conc" v-model.number="draft.teamLimits.maxConcurrentRuns" type="number" min="1" class="field"></div>
+          </div>
+          <div class="hint">超出限额时任务自动暂停,由成员选择继续(这一项放宽一倍)或取消。创建工作组时可以调低这些值。</div>
+        </section>
+
         <section class="flex flex-col gap-3">
           <h2 class="section-title m-0">引擎</h2>
           <div class="card p-4 flex flex-col gap-3">

+ 41 - 0
apps/server/drizzle/0007_bot_jobs.sql

@@ -0,0 +1,41 @@
+CREATE TABLE `jobs` (
+	`id` text PRIMARY KEY NOT NULL,
+	`team_id` text NOT NULL,
+	`no` integer NOT NULL,
+	`title` text NOT NULL,
+	`plan` text DEFAULT '' NOT NULL,
+	`status` text DEFAULT 'running' NOT NULL,
+	`summary` text,
+	`files` text DEFAULT '[]' NOT NULL,
+	`blocked_by` text,
+	`limits` text NOT NULL,
+	`lead_runs` integer DEFAULT 0 NOT NULL,
+	`run_ids` text DEFAULT '[]' NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`ended_at` text
+);
+--> statement-breakpoint
+CREATE INDEX `jobs_team_idx` ON `jobs` (`team_id`,`no`);--> statement-breakpoint
+CREATE TABLE `tasks` (
+	`id` text PRIMARY KEY NOT NULL,
+	`job_id` text NOT NULL,
+	`team_id` text NOT NULL,
+	`no` integer NOT NULL,
+	`bot_id` text NOT NULL,
+	`title` text NOT NULL,
+	`instructions` text NOT NULL,
+	`depends_on` text DEFAULT '[]' NOT NULL,
+	`status` text NOT NULL,
+	`result` text,
+	`files` text DEFAULT '[]' NOT NULL,
+	`run_id` text,
+	`session_id` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`started_at` text,
+	`ended_at` text
+);
+--> statement-breakpoint
+CREATE INDEX `tasks_team_idx` ON `tasks` (`team_id`,`no`);--> statement-breakpoint
+CREATE INDEX `tasks_job_idx` ON `tasks` (`job_id`);--> statement-breakpoint
+ALTER TABLE `teams` ADD `limits` text;--> statement-breakpoint
+ALTER TABLE `teams` ADD `debug` text DEFAULT '{"stepMode":false,"stubBotIds":[]}' NOT NULL;

+ 2215 - 0
apps/server/drizzle/meta/0007_snapshot.json

@@ -0,0 +1,2215 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "4acfdb59-bfc6-49ae-bc98-51e3b542511f",
+  "prevId": "2bd63c23-fd55-40ff-a354-6f6148012e1f",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_sources": {
+      "name": "skill_sources",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -50,6 +50,13 @@
       "when": 1793009091003,
       "tag": "0006_bot_teams",
       "breakpoints": true
+    },
+    {
+      "idx": 7,
+      "version": "6",
+      "when": 1793073249147,
+      "tag": "0007_bot_jobs",
+      "breakpoints": true
     }
   ]
 }

+ 107 - 6
apps/server/src/api-reference/bot-reference.ts

@@ -10,7 +10,8 @@ export const BOT_OVERVIEW: ApiOverview = {
     '**可见范围**:每个 Bot Key 只能使用后台为它勾选的机器人,只能看到自己创建的工作组。',
     '**工作组**:所有对话都在工作组中进行。一个工作组有 1 个成员(人,由平台提供 `{ id, name }`,AgentServer 不做认证)、0–1 个组长、0–多个组员,组长和组员至少 1 个。',
     '**唤醒规则**:消息 @ 了谁就交给谁;不 @ 时,有组长交给组长,只有一个组员时交给它,否则返回 `mention_required`。同一机器人按顺序执行,执行期间收到的消息合并到下一轮。',
-    '**ID 前缀**:工作组 `team_`、机器人 `bot_`、消息 `tmsg_`、交互 `itx_`、文件 `file_`;机器人的执行过程是会话 `ses_` 与 Run `run_`。',
+    '**任务**:复杂工作由组长开始一项**任务**(job),拆成**子任务**(task)分派给组员,组员完成后组长汇总交付。同一工作组同一时间只有一项任务;进度见 `job.updated` / `task.updated` 事件和 `GET /bot/v1/teams/:id/jobs`。超出限额时任务暂停,由成员选择继续或取消。',
+    '**ID 前缀**:工作组 `team_`、机器人 `bot_`、消息 `tmsg_`、任务 `job_`、子任务 `task_`、交互 `itx_`、文件 `file_`;机器人的执行过程是会话 `ses_` 与 Run `run_`。',
     '**错误格式**与 Agent API 相同:`{ "error": { "code": "…", "message": "…", "details": {} } }`。',
     '**SDK**:`@agent-server/client` 中的 `BotClient` 与 `@agent-server/chat-ui` 中的 `<TeamChat>`,安装地址见 `$BASE/sdk/index.json`。',
   ],
@@ -50,8 +51,8 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
     endpoints: [
       {
         method: 'GET', path: '/bot/v1/bots', summary: '本平台可用的机器人',
-        desc: '只返回在后台为该 Bot Key 勾选、且已启用的机器人。kind 为 lead(组长)或 worker(组员)。',
-        response: { status: 200, type: 'BotSummary[]' },
+        desc: '只返回在后台为该 Bot Key 勾选、且已启用的机器人,附带能力卡片。kind 为 lead(组长)或 worker(组员)。',
+        response: { status: 200, type: 'BotCard[]' },
       },
     ],
   },
@@ -68,10 +69,11 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
           { name: 'title', type: 'string', desc: '标题(≤ 100 字),省略时用机器人名称' },
           { name: 'leadBotId', type: 'string', desc: '组长(0–1 个)' },
           { name: 'workerBotIds', type: 'string[]', desc: '组员(0–20 个)' },
+          { name: 'limits', type: 'Partial<TeamLimits>', desc: '限额,只能比系统设置低;省略时使用系统设置' },
         ],
         response: { status: 201, type: 'TeamDetail' },
         sampleBody: { title: 'API 调试', human: { id: 'u1', name: '张三' }, workerBotIds: [] },
-        errors: ['400 bad_request:没有机器人、机器人类型不符或已停用', '403 bot_not_allowed:机器人不在本平台可用列表中'],
+        errors: ['400 bad_request:没有机器人、机器人类型不符、已停用,或限额高于系统设置', '403 bot_not_allowed:机器人不在本平台可用列表中'],
         example: `curl -X POST $BASE/bot/v1/teams -H "Authorization: Bearer $TOKEN" \\
   -H "Content-Type: application/json" \\
   -d '{"human":{"id":"u1","name":"张三"},"workerBotIds":["bot_01…"]}'`,
@@ -87,8 +89,11 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
       },
       { method: 'GET', path: team(''), summary: '工作组详情', desc: '包含各机器人的当前状态和未处理的交互。', response: { status: 200, type: 'TeamDetail' } },
       {
-        method: 'PATCH', path: team(''), summary: '修改标题',
-        body: [{ name: 'title', type: 'string', required: true, desc: '1–100 字' }],
+        method: 'PATCH', path: team(''), summary: '修改标题或限额',
+        body: [
+          { name: 'title', type: 'string', desc: '1–100 字' },
+          { name: 'limits', type: 'Partial<TeamLimits>', desc: '只能比系统设置低;对之后开始的任务生效' },
+        ],
         response: { status: 200, type: 'TeamDetail' },
         sampleBody: { title: '新标题' },
       },
@@ -194,6 +199,44 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
       },
     ],
   },
+  {
+    id: 'jobs',
+    title: '任务与子任务',
+    desc: '任务由组长开始和结束,平台只能查看、取消,或在超出限额后选择继续。',
+    endpoints: [
+      { method: 'GET', path: team('/jobs'), summary: '任务与子任务', desc: '全部任务(新的在前)和子任务(按编号)。', response: { status: 200, type: '{ jobs: Job[], tasks: Task[] }' } },
+      {
+        method: 'POST', path: team('/jobs/:jobId/cancel'), summary: '取消任务',
+        desc: '还没有结束的子任务一并取消(正在执行的被中断);组长下一轮会看到取消的消息。',
+        response: { status: 200, type: '{ ok: true }' },
+        errors: ['409 job_ended:任务已经结束'],
+      },
+      {
+        method: 'POST', path: team('/jobs/:jobId/continue'), summary: '超出限额后继续',
+        desc: '把造成暂停的那一项限额放宽一倍,任务恢复,组长被唤醒。',
+        response: { status: 200, type: '{ ok: true }' },
+        errors: ['409 job_not_blocked:任务没有因为限额暂停'],
+      },
+      { method: 'GET', path: team('/usage'), summary: '用量', desc: '按机器人、任务、子任务分别统计 Run 数、token 与成本。', response: { status: 200, type: 'TeamUsage' } },
+      {
+        method: 'POST', path: team('/tasks/:taskId/stub'), summary: '填写替身结果(仅后台)',
+        desc: 'Bot Playground 的调试功能:分派给替身组员的子任务不执行,由管理员填写结果。Bot Key 调用返回 403。',
+        body: [
+          { name: 'outcome', type: '"completed" | "failed"', required: true, desc: '' },
+          { name: 'result', type: 'string', required: true, desc: '结果或失败原因' },
+          { name: 'fileIds', type: 'string[]', desc: '共享目录中的文件' },
+        ],
+        response: { status: 200, type: '{ ok: true }' },
+        errors: ['403 forbidden:不是后台调用', '409 task_not_stub:子任务不在等待替身结果'],
+      },
+      {
+        method: 'POST', path: team('/steps/:stepId/continue'), summary: '逐步执行:继续(仅后台)',
+        desc: 'Bot Playground 的调试功能:逐步执行模式下,每次唤醒机器人前停下,调用它放行这一步。Bot Key 调用返回 403。',
+        response: { status: 200, type: '{ ok: true }' },
+        errors: ['403 forbidden:不是后台调用'],
+      },
+    ],
+  },
   {
     id: 'files',
     title: '文件',
@@ -231,6 +274,54 @@ export const BOT_TYPES: ApiTypeDef[] = [
       { name: 'provider / model', type: 'string | null', desc: '所用引擎与模型' },
     ],
   },
+  {
+    name: 'BotCard', desc: 'BotSummary 的全部字段,另外包括能力卡片:', fields: [
+      { name: 'skills', type: '{ name, description }[]', desc: '技能' },
+      { name: 'mcpServers', type: '{ name, title, description }[]', desc: '外部服务' },
+      { name: 'knowledge', type: 'string[]', desc: '可访问的资料库' },
+      { name: 'permissionMode', type: '"ask" | "auto" | "readonly" | null', desc: '权限模式' },
+    ],
+  },
+  {
+    name: 'TeamLimits', desc: '每项任务的限额', fields: [
+      { name: 'maxTasksPerJob', type: 'number', desc: '子任务数,默认 20' },
+      { name: 'maxLeadRunsPerJob', type: 'number', desc: '组长的执行轮数,默认 30' },
+      { name: 'maxCostUsdPerJob', type: 'number | null', desc: '成本上限(美元),null 不限' },
+      { name: 'maxMinutesPerJob', type: 'number', desc: '时长(分钟),默认 120' },
+      { name: 'maxConcurrentRuns', type: 'number', desc: '工作组同时执行的 Run 数,默认 4' },
+    ],
+  },
+  {
+    name: 'Job', fields: [
+      { name: 'id / no', type: 'string / number', desc: 'job_ 开头;no 是工作组内的序号' },
+      { name: 'title / plan', type: 'string', desc: '组长填写的标题与计划' },
+      { name: 'status', type: '"running" | "blocked" | "completed" | "failed" | "cancelled"', desc: 'blocked:超出限额,等待成员选择继续或取消' },
+      { name: 'summary', type: 'string | null', desc: '完成时的总结,或失败、取消的原因' },
+      { name: 'files', type: '{ fileId, path, mime, size }[]', desc: '交付文件' },
+      { name: 'blockedBy', type: 'string | null', desc: '造成暂停的限额,如 maxTasksPerJob' },
+      { name: 'limits', type: 'object', desc: '本任务生效的限额(继续后放宽)' },
+      { name: 'leadRuns / taskCount / costUsd', type: 'number', desc: '组长执行轮数、子任务数、成本合计' },
+      { name: 'createdAt / endedAt', type: 'string', desc: '' },
+    ],
+  },
+  {
+    name: 'Task', fields: [
+      { name: 'id / no', type: 'string / number', desc: 'task_ 开头;no 是工作组内的序号(消息中显示为 #no)' },
+      { name: 'jobId / botId', type: 'string', desc: '所属任务、执行的组员' },
+      { name: 'title / instructions', type: 'string', desc: '组长写的标题与说明' },
+      { name: 'dependsOn', type: 'number[]', desc: '依赖的子任务序号' },
+      { name: 'status', type: '"waiting" | "queued" | "running" | "stub" | "completed" | "failed" | "cancelled"', desc: 'waiting:等待依赖;stub:等待替身结果(仅后台)' },
+      { name: 'result / files', type: 'string | null / object[]', desc: '结果(或失败原因)与交付文件' },
+      { name: 'runId / sessionId', type: 'string | null', desc: '执行它的 Run 与会话' },
+      { name: 'costUsd', type: 'number', desc: '' },
+    ],
+  },
+  {
+    name: 'TeamUsage', fields: [
+      { name: 'total', type: 'UsageRow', desc: '`{ runs, inputTokens, outputTokens, costUsd }`' },
+      { name: 'bots / jobs / tasks', type: 'UsageRow & { botId | jobId | taskId }[]', desc: '分项统计' },
+    ],
+  },
   {
     name: 'Team', fields: [
       { name: 'id', type: 'string', desc: 'team_ 开头' },
@@ -238,6 +329,8 @@ export const BOT_TYPES: ApiTypeDef[] = [
       { name: 'human', type: '{ id, name }', desc: '成员(人)' },
       { name: 'status', type: '"active" | "paused" | "closed"', desc: '进行中 / 已暂停 / 已关闭' },
       { name: 'bots', type: 'TeamBot[]', desc: '组长排在最前面' },
+      { name: 'limits', type: 'TeamLimits', desc: '生效的限额' },
+      { name: 'activeJobId', type: 'string | null', desc: '进行中(含因限额暂停)的任务' },
       { name: 'lastSeq', type: 'number', desc: '最后一个事件的 seq' },
       { name: 'createdAt / updatedAt', type: 'string', desc: 'ISO 8601' },
     ],
@@ -253,6 +346,7 @@ export const BOT_TYPES: ApiTypeDef[] = [
   {
     name: 'TeamDetail', desc: 'Team 的全部字段,另外包括:', fields: [
       { name: 'pendingInteractions', type: '{ botId, sessionId, request: InteractionRequest }[]', desc: '未处理的交互' },
+      { name: 'pendingSteps', type: '{ id, botId, reason, createdAt }[]', desc: '逐步执行时等待继续的步骤(仅后台的工作组)' },
     ],
   },
   {
@@ -265,6 +359,7 @@ export const BOT_TYPES: ApiTypeDef[] = [
       { name: 'files', type: '{ fileId, path, mime, size }[]', desc: '附件或交付文件' },
       { name: 'runId / sessionId', type: 'string?', desc: '机器人消息:产生它的 Run 与会话' },
       { name: 'level', type: '"info" | "warning" | "error"', desc: '系统消息的级别' },
+      { name: 'card', type: 'object?', desc: '任务卡片:`{ kind: "job", jobId, event }`、`{ kind: "task", taskId, no, event }`、`{ kind: "limit", jobId, limit }`;后台调试时还有 stub、step。状态以 Job / Task 的最新值为准' },
     ],
   },
   {
@@ -302,6 +397,9 @@ export const BOT_EVENTS: ApiEventDef[] = [
   { type: 'bot.status', payload: '{ botId, status, sessionId, runId }', desc: '机器人状态变化:idle / queued / working / waiting_input', persisted: false },
   { type: 'file.changed', payload: '{ file: File, kind: "created" | "modified" }', desc: '共享目录中新建或修改了文件', persisted: false },
   { type: 'team.updated', payload: 'Team', desc: '工作组的标题、状态或成员变化', persisted: false },
+  { type: 'job.updated', payload: 'Job', desc: '任务开始、暂停、继续或结束', persisted: true },
+  { type: 'task.updated', payload: 'Task', desc: '子任务分派、开始、完成、失败或取消', persisted: true },
+  { type: 'step.updated', payload: '{ steps: { id, botId, reason, createdAt }[] }', desc: '逐步执行时等待继续的步骤(仅后台的工作组)', persisted: false },
 ]
 
 export const BOT_ERRORS: ApiErrorDef[] = [
@@ -317,6 +415,9 @@ export const BOT_ERRORS: ApiErrorDef[] = [
   { status: 409, code: 'invalid_status', desc: '工作组当前状态不能执行这个操作' },
   { status: 409, code: 'already_member', desc: '机器人已经在工作组中' },
   { status: 409, code: 'team_busy', desc: '删除时仍有机器人在执行' },
+  { status: 409, code: 'job_ended', desc: '任务已经结束' },
+  { status: 409, code: 'job_not_blocked', desc: '任务没有因为限额暂停' },
+  { status: 409, code: 'task_not_stub', desc: '子任务不在等待替身结果(仅后台)' },
   { status: 409, code: 'already_resolved', desc: '交互已被回复、超时或取消' },
   { status: 413, code: 'file_too_large', desc: '上传文件超过大小上限' },
   { status: 500, code: 'internal_error', desc: '服务器内部错误' },

+ 6 - 2
apps/server/src/app.ts

@@ -30,7 +30,9 @@ import { SkillSourceService } from './custom-skills/sources'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
 import { BotKeyService, BotService } from './teams/bots'
+import { CardService } from './teams/cards'
 import { TeamService } from './teams/service'
+import { createSkillCatalog } from './services/skill-catalog'
 import { forgetGatewaySession } from './mcp-gateway/server'
 
 export interface AppLogger {
@@ -81,15 +83,17 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const bots = new BotService(db, profiles)
   const botKeys = new BotKeyService(db, bots)
   usage.extraSourceNames = () => botKeys.names()
+  const skillCatalog = createSkillCatalog({ settings: () => settings.get(), plugins, communitySkills, customSkills, providers })
+  const teamCards = new CardService({ bots, profiles, knowledge, mcpServers, skillCatalog })
   const teams = new TeamService({
-    db, log, settings, profiles, sessions, runs, files, events, interactions, bots, botKeys,
+    db, log, settings, profiles, sessions, runs, files, events, interactions, bots, botKeys, cards: teamCards,
     forgetSession: id => {
       gatewayTokens.forgetSession(id)
       forgetGatewaySession(id)
     },
   })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 47 - 1
apps/server/src/db/schema.ts

@@ -1,6 +1,8 @@
 import { sql } from 'drizzle-orm'
 import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
-import type { FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolPolicy, PermissionMode, ProfileSnapshot } from '@agent-server/shared'
+import type {
+  FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolPolicy, PermissionMode, ProfileSnapshot, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
+} from '@agent-server/shared'
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
 const createdAt = () => text('created_at').notNull().default(now)
@@ -281,6 +283,10 @@ export const teams = sqliteTable('teams', {
   status: text('status', { enum: ['active', 'paused', 'closed'] }).notNull().default('active'),
   /** 共享目录:组内所有机器人会话的工作目录 */
   workspaceDir: text('workspace_dir').notNull(),
+  /** 限额:创建时以系统设置为准,可以调低 */
+  limits: text('limits', { mode: 'json' }).$type<Partial<TeamLimits>>(),
+  /** 调试设置(仅 admin 的工作组) */
+  debug: text('debug', { mode: 'json' }).$type<TeamDebug>().notNull().default(sql`'{"stepMode":false,"stubBotIds":[]}'`),
   lastSeq: integer('last_seq').notNull().default(0),
   createdAt: createdAt(),
   updatedAt: updatedAt(),
@@ -309,3 +315,43 @@ export const teamEvents = sqliteTable('team_events', {
   payload: text('payload', { mode: 'json' }).notNull(),
   ts: text('ts').notNull(),
 }, t => [primaryKey({ columns: [t.teamId, t.seq] })])
+
+/** 任务:成员交给组长的一项复杂工作,同一工作组内依次执行 */
+export const jobs = sqliteTable('jobs', {
+  id: text('id').primaryKey(),
+  teamId: text('team_id').notNull(),
+  no: integer('no').notNull(),
+  title: text('title').notNull(),
+  plan: text('plan').notNull().default(''),
+  status: text('status', { enum: ['running', 'blocked', 'completed', 'failed', 'cancelled'] }).notNull().default('running'),
+  summary: text('summary'),
+  files: text('files', { mode: 'json' }).$type<TeamFileRef[]>().notNull().default(sql`'[]'`),
+  blockedBy: text('blocked_by').$type<TeamLimitKey>(),
+  /** 本任务生效的限额(继续后加倍) */
+  limits: text('limits', { mode: 'json' }).$type<Record<TeamLimitKey, number | null>>().notNull(),
+  leadRuns: integer('lead_runs').notNull().default(0),
+  /** 本任务产生的全部 Run(组长与子任务),用于统计成本 */
+  runIds: text('run_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  createdAt: createdAt(),
+  endedAt: text('ended_at'),
+}, t => [index('jobs_team_idx').on(t.teamId, t.no)])
+
+/** 子任务:组长分派给某个组员的单项工作 */
+export const tasks = sqliteTable('tasks', {
+  id: text('id').primaryKey(),
+  jobId: text('job_id').notNull(),
+  teamId: text('team_id').notNull(),
+  no: integer('no').notNull(),
+  botId: text('bot_id').notNull(),
+  title: text('title').notNull(),
+  instructions: text('instructions').notNull(),
+  dependsOn: text('depends_on', { mode: 'json' }).$type<number[]>().notNull().default(sql`'[]'`),
+  status: text('status', { enum: ['waiting', 'queued', 'running', 'stub', 'completed', 'failed', 'cancelled'] }).notNull(),
+  result: text('result'),
+  files: text('files', { mode: 'json' }).$type<TeamFileRef[]>().notNull().default(sql`'[]'`),
+  runId: text('run_id'),
+  sessionId: text('session_id'),
+  createdAt: createdAt(),
+  startedAt: text('started_at'),
+  endedAt: text('ended_at'),
+}, t => [index('tasks_team_idx').on(t.teamId, t.no), index('tasks_job_idx').on(t.jobId)])

+ 1 - 14
apps/server/src/http/routes/admin.ts

@@ -165,20 +165,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return { settings: next }
     })
     // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能;Codex = Codex 实际可见的技能
-    g.get<{ Querystring: { engine?: string } }>('/skills', async req => {
-      if (req.query.engine !== 'codex') return [...await app.plugins.skills(), ...app.communitySkills.skillInfos().map(s => ({ ...s, optIn: true })), ...app.customSkills.skillInfos()]
-      const codex = app.providers.codex
-      if (!(codex instanceof CodexProvider)) return []
-      const infos = await codex.skillInfos(app.settings.get()).catch(() => [])
-      const excluded = new Set(app.customSkills.excludedFromAll())
-      return infos.map(s => {
-        const [prefix] = s.id.split(':')
-        const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin'
-          : prefix === COMMUNITY_SKILL_PLUGIN ? 'community' : prefix === CUSTOM_SKILL_PLUGIN ? 'custom' : 'local'
-        const optIn = source === 'community' || (source === 'custom' && excluded.has(s.id))
-        return { id: s.id, name: s.id.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'plugin' || source === 'system' ? 'unknown' : 'open', optIn }
-      })
-    })
+    g.get<{ Querystring: { engine?: string } }>('/skills', async req => app.skillCatalog(req.query.engine === 'codex' ? 'codex' : 'claude'))
 
     // MCP 服务:内置工具(只读展示)+ 第三方服务(经 MCP Gateway 转接,ADR-0021)
     g.get('/mcp', async () => app.mcpServers.state())

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 6 - 4
apps/server/src/mcp-gateway/server.ts

@@ -37,8 +37,10 @@ export interface GatewayDeps {
 /** 会话内"本会话允许"过的第三方工具 */
 const sessionAllowed = new Map<string, Set<string>>()
 
-function builtinTools(): Tool[] {
-  return BUILTIN_TOOLS.map(t => ({
+const ownTools = (ctx: GatewayRunContext) => [...BUILTIN_TOOLS, ...(ctx.extraTools ?? [])]
+
+function builtinTools(ctx: GatewayRunContext): Tool[] {
+  return ownTools(ctx).map(t => ({
     name: t.name, description: t.description,
     inputSchema: z.toJSONSchema(t.schema) as Tool['inputSchema'],
     ...(t.readOnly ? { annotations: { readOnlyHint: true } } : {}),
@@ -70,7 +72,7 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
 
   server.setRequestHandler(ListToolsRequestSchema, async () => ({
     tools: [
-      ...builtinTools(),
+      ...builtinTools(ctx),
       ...(await externalTools(ctx, deps)).map(({ row, tool, exposed }) => ({
         name: exposed,
         description: `[${row.title}] ${tool.description}`.slice(0, 2000),
@@ -87,7 +89,7 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
     const audit = (summary: string, detail: Record<string, unknown> = {}) =>
       ctx.audit('gateway_call', summary, { args, ms: Date.now() - started, engine: ctx.provider, ...detail })
 
-    const builtin = BUILTIN_TOOLS.find(t => t.name === name)
+    const builtin = ownTools(ctx).find(t => t.name === name)
     if (builtin) {
       const parsed = builtin.schema.safeParse(args)
       if (!parsed.success) return fail(`参数不正确:${parsed.error.issues.map(i => `${i.path.join('.')} ${i.message}`).join(';')}`)

+ 3 - 0
apps/server/src/mcp-gateway/tokens.ts

@@ -5,6 +5,7 @@ import crypto from 'node:crypto'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
 import type { InteractionOutcome, NewApproval } from '../services/interactions'
+import type { BuiltinTool } from './builtin'
 
 export interface GatewayRunContext {
   sessionId: string
@@ -20,6 +21,8 @@ export interface GatewayRunContext {
   audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void
   /** 发起审批(与引擎侧的审批相同:显示交互卡片并等待回复) */
   interact: (req: NewApproval) => Promise<InteractionOutcome>
+  /** 这个会话额外提供的工具(工作组的协作工具,按角色固定,见 teams/tools.ts) */
+  extraTools?: BuiltinTool[]
 }
 
 const MAX_AGE_MS = 24 * 3600 * 1000

+ 4 - 0
apps/server/src/services/runs.ts

@@ -24,6 +24,7 @@ import type { McpServerRow } from '../mcp-gateway/upstream'
 import type { PluginService } from './plugins'
 import type { UsageService } from './usage'
 import type { RunTokenRegistry } from '../mcp-gateway/tokens'
+import type { BuiltinTool } from '../mcp-gateway/builtin'
 import { gatewayUrl } from '../config'
 import { isTeamSource, type SessionRow, type SessionService } from './sessions'
 import type { SettingsService } from './settings'
@@ -56,6 +57,8 @@ export class RunManager {
   private active = new Map<string, ActiveRun>()
   /** 用量统计的来源:默认是会话来源;工作组的会话由工作组服务换成工作组的来源(Bot Key 或 admin) */
   usageSourceOf: (session: SessionRow) => string = s => s.source
+  /** 会话额外的网关工具:工作组服务为机器人会话提供协作工具 */
+  gatewayToolsOf: (session: SessionRow) => BuiltinTool[] = () => []
 
   constructor(
     private deps: {
@@ -209,6 +212,7 @@ export class RunManager {
         mcpServerIds: profile.mcpServerIds,
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
         interact,
+        extraTools: this.gatewayToolsOf(session),
       }, { stablePerSession: session.provider === 'codex' })
 
       const result = await provider.run({

+ 10 - 0
apps/server/src/services/settings.ts

@@ -31,6 +31,7 @@ export function defaultSettings(): AppSettings {
       workspaceMaxMB: 2048,
     },
     retention: { workspaceDays: 30 },
+    teamLimits: { maxTasksPerJob: 20, maxLeadRunsPerJob: 30, maxCostUsdPerJob: null, maxMinutesPerJob: 120, maxConcurrentRuns: 4 },
     providers: {
       claude: {
         enabled: true,
@@ -52,6 +53,14 @@ export function defaultSettings(): AppSettings {
 const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝对路径')
 const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确')
 
+export const teamLimitsSchema = z.object({
+  maxTasksPerJob: z.number().int().min(1).max(500),
+  maxLeadRunsPerJob: z.number().int().min(1).max(1000),
+  maxCostUsdPerJob: z.number().min(0.01).max(10000).nullable(),
+  maxMinutesPerJob: z.number().int().min(1).max(7 * 24 * 60),
+  maxConcurrentRuns: z.number().int().min(1).max(32),
+})
+
 const settingsSchema: z.ZodType<AppSettings> = z.object({
   paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, nodeModules: absPath, matplotlibrc: absPath }),
   security: z.object({
@@ -71,6 +80,7 @@ const settingsSchema: z.ZodType<AppSettings> = z.object({
     workspaceMaxMB: z.number().int().min(10).max(102400),
   }),
   retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
+  teamLimits: teamLimitsSchema,
   providers: z.object({
     claude: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string().min(1), models: z.array(z.string().min(1)) }),
     codex: z.object({

+ 31 - 0
apps/server/src/services/skill-catalog.ts

@@ -0,0 +1,31 @@
+// 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能 + 自定义技能;Codex = Codex 实际可见的技能。
+// 后台的 Profile 编辑与工作组的能力卡片共用
+import { COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type ProviderId, type SkillInfo } from '@agent-server/shared'
+import type { CustomSkillService } from '../custom-skills/service'
+import { CodexProvider } from '../providers/codex/adapter'
+import type { AgentProvider } from '../providers/types'
+import type { CommunitySkillService } from './community-skills'
+import type { PluginService } from './plugins'
+
+export function createSkillCatalog(deps: {
+  settings: () => AppSettings
+  plugins: PluginService
+  communitySkills: CommunitySkillService
+  customSkills: CustomSkillService
+  providers: Partial<Record<ProviderId, AgentProvider>>
+}) {
+  return async (engine: ProviderId): Promise<SkillInfo[]> => {
+    if (engine !== 'codex') return [...await deps.plugins.skills(), ...deps.communitySkills.skillInfos().map(s => ({ ...s, optIn: true })), ...deps.customSkills.skillInfos()]
+    const codex = deps.providers.codex
+    if (!(codex instanceof CodexProvider)) return []
+    const infos = await codex.skillInfos(deps.settings()).catch(() => [])
+    const excluded = new Set(deps.customSkills.excludedFromAll())
+    return infos.map(s => {
+      const [prefix] = s.id.split(':')
+      const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin'
+        : prefix === COMMUNITY_SKILL_PLUGIN ? 'community' : prefix === CUSTOM_SKILL_PLUGIN ? 'custom' : 'local'
+      const optIn = source === 'community' || (source === 'custom' && excluded.has(s.id))
+      return { id: s.id, name: s.id.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'plugin' || source === 'system' ? 'unknown' : 'open', optIn }
+    })
+  }
+}

+ 70 - 0
apps/server/src/teams/cards.ts

@@ -0,0 +1,70 @@
+// 能力卡片(docs/design/13-bot-teams.md §5.1):根据机器人的配置生成,组长据此了解组员会什么
+import type { BotCard, BotDto, ProviderId, SkillInfo } from '@agent-server/shared'
+import type { KnowledgeService, ProfileService } from '../services/catalog'
+import type { McpServerService } from '../services/mcp-servers'
+import type { BotService } from './bots'
+
+const CACHE_MS = 60_000
+const MAX_SKILLS = 30
+
+export class CardService {
+  private skillCache = new Map<ProviderId, { at: number; skills: Promise<SkillInfo[]> }>()
+
+  constructor(private deps: {
+    bots: BotService
+    profiles: ProfileService
+    knowledge: KnowledgeService
+    mcpServers: McpServerService
+    skillCatalog: (engine: ProviderId) => Promise<SkillInfo[]>
+  }) {}
+
+  async card(bot: BotDto): Promise<BotCard> {
+    const summary = this.deps.bots.summary(bot)
+    let profile
+    try {
+      profile = this.deps.profiles.get(bot.profileId)
+    } catch {
+      return { ...summary, skills: [], mcpServers: [], knowledge: [], permissionMode: null }
+    }
+    const catalog = await this.skills(profile.provider)
+    // "全部技能"不包含需要单独勾选的技能
+    const skills = (profile.skills === 'all' ? catalog.filter(s => !s.optIn) : catalog.filter(s => (profile.skills as string[]).includes(s.id)))
+      .map(s => ({ name: s.name, description: s.description }))
+    const knowledge = this.deps.knowledge.list().filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)).map(k => k.name)
+    const mcpServers = this.deps.mcpServers.list().filter(m => m.enabled && profile.mcpServerIds.includes(m.id))
+      .map(m => ({ name: m.name, title: m.title, description: m.description }))
+    return { ...summary, skills, mcpServers, knowledge, permissionMode: profile.permissionMode }
+  }
+
+  cards(bots: BotDto[]): Promise<BotCard[]> {
+    return Promise.all(bots.map(b => this.card(b)))
+  }
+
+  private skills(engine: ProviderId): Promise<SkillInfo[]> {
+    const hit = this.skillCache.get(engine)
+    if (hit && Date.now() - hit.at < CACHE_MS) return hit.skills
+    const skills = this.deps.skillCatalog(engine).catch(() => [] as SkillInfo[])
+    this.skillCache.set(engine, { at: Date.now(), skills })
+    return skills
+  }
+}
+
+/** 卡片的文字形式(放进组长的提示词与工具结果) */
+export function cardText(c: BotCard): string {
+  const lines = [`### ${c.name}(id: ${c.id})`]
+  if (c.description.trim()) lines.push(c.description.trim())
+  if (c.skills.length) {
+    lines.push('技能:')
+    for (const s of c.skills.slice(0, MAX_SKILLS)) lines.push(`- ${s.name}:${oneLine(s.description, 160)}`)
+    if (c.skills.length > MAX_SKILLS) lines.push(`- ……另有 ${c.skills.length - MAX_SKILLS} 个技能`)
+  }
+  if (c.mcpServers.length) lines.push(`外部服务:${c.mcpServers.map(m => `${m.title}${m.description ? `(${oneLine(m.description, 80)})` : ''}`).join(';')}`)
+  if (c.knowledge.length) lines.push(`资料库:${c.knowledge.join('、')}`)
+  if (c.permissionMode === 'readonly') lines.push('权限:只读(不能生成或修改文件)')
+  return lines.join('\n')
+}
+
+function oneLine(s: string, max: number) {
+  const t = s.replace(/\s+/g, ' ').trim()
+  return t.length > max ? `${t.slice(0, max)}…` : t
+}

+ 127 - 0
apps/server/src/teams/jobs.ts

@@ -0,0 +1,127 @@
+// 任务与子任务的存储(docs/design/13-bot-teams.md §5.2);流程控制在 service.ts
+import { and, asc, desc, eq, inArray, max } from 'drizzle-orm'
+import type { JobDto, TaskDto, TaskStatus, TeamLimitKey, TeamLimits } from '@agent-server/shared'
+import type { DB } from '../db'
+import { jobs, runs, tasks } from '../db/schema'
+import { notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+
+export type JobRow = typeof jobs.$inferSelect
+export type TaskRow = typeof tasks.$inferSelect
+
+export const LIMIT_KEYS: TeamLimitKey[] = ['maxTasksPerJob', 'maxLeadRunsPerJob', 'maxCostUsdPerJob', 'maxMinutesPerJob']
+export const LIMIT_LABELS: Record<TeamLimitKey, string> = {
+  maxTasksPerJob: '子任务数',
+  maxLeadRunsPerJob: '组长执行轮数',
+  maxCostUsdPerJob: '成本(美元)',
+  maxMinutesPerJob: '时长(分钟)',
+}
+
+export const ACTIVE_JOB = ['running', 'blocked'] as const
+/** 还没有结束的子任务 */
+export const OPEN_TASK: TaskStatus[] = ['waiting', 'queued', 'running', 'stub']
+
+export class JobStore {
+  constructor(private db: DB) {}
+
+  active(teamId: string): JobRow | undefined {
+    return this.db.select().from(jobs).where(and(eq(jobs.teamId, teamId), inArray(jobs.status, [...ACTIVE_JOB]))).get()
+  }
+
+  job(id: string, teamId?: string): JobRow {
+    const row = this.db.select().from(jobs).where(eq(jobs.id, id)).get()
+    if (!row || (teamId && row.teamId !== teamId)) throw notFound('任务')
+    return row
+  }
+
+  task(id: string, teamId?: string): TaskRow {
+    const row = this.db.select().from(tasks).where(eq(tasks.id, id)).get()
+    if (!row || (teamId && row.teamId !== teamId)) throw notFound('子任务')
+    return row
+  }
+
+  taskByNo(teamId: string, no: number): TaskRow | undefined {
+    return this.db.select().from(tasks).where(and(eq(tasks.teamId, teamId), eq(tasks.no, no))).get()
+  }
+
+  jobs(teamId: string): JobRow[] {
+    return this.db.select().from(jobs).where(eq(jobs.teamId, teamId)).orderBy(desc(jobs.no)).all()
+  }
+
+  tasks(teamId: string, filter: { jobId?: string; botId?: string; status?: TaskStatus[] } = {}): TaskRow[] {
+    return this.db.select().from(tasks).where(and(
+      eq(tasks.teamId, teamId),
+      filter.jobId ? eq(tasks.jobId, filter.jobId) : undefined,
+      filter.botId ? eq(tasks.botId, filter.botId) : undefined,
+      filter.status ? inArray(tasks.status, filter.status) : undefined,
+    )).orderBy(asc(tasks.no)).all()
+  }
+
+  createJob(teamId: string, title: string, plan: string, limits: TeamLimits, runId: string | null): JobRow {
+    const no = (this.db.select({ n: max(jobs.no) }).from(jobs).where(eq(jobs.teamId, teamId)).get()?.n ?? 0) + 1
+    const id = newId('job')
+    const own = Object.fromEntries(LIMIT_KEYS.map(k => [k, limits[k]])) as Record<TeamLimitKey, number | null>
+    this.db.insert(jobs).values({ id, teamId, no, title, plan, limits: own, leadRuns: runId ? 1 : 0, runIds: runId ? [runId] : [] }).run()
+    return this.job(id)
+  }
+
+  createTask(job: JobRow, data: { botId: string; title: string; instructions: string; dependsOn: number[] }): TaskRow {
+    const no = (this.db.select({ n: max(tasks.no) }).from(tasks).where(eq(tasks.teamId, job.teamId)).get()?.n ?? 0) + 1
+    const id = newId('task')
+    this.db.insert(tasks).values({ id, jobId: job.id, teamId: job.teamId, no, status: 'waiting', ...data }).run()
+    return this.task(id)
+  }
+
+  updateJob(id: string, patch: Partial<JobRow>): JobRow {
+    this.db.update(jobs).set(patch).where(eq(jobs.id, id)).run()
+    return this.job(id)
+  }
+
+  updateTask(id: string, patch: Partial<TaskRow>): TaskRow {
+    this.db.update(tasks).set(patch).where(eq(tasks.id, id)).run()
+    return this.task(id)
+  }
+
+  /** 记录属于这个任务的 Run(成本按它们累加) */
+  addRun(jobId: string, runId: string, lead: boolean) {
+    const job = this.job(jobId)
+    this.updateJob(jobId, { runIds: [...job.runIds, runId], ...(lead ? { leadRuns: job.leadRuns + 1 } : {}) })
+  }
+
+  end(status: 'completed' | 'failed' | 'cancelled', patch: Partial<JobRow> = {}) {
+    return { status, endedAt: nowIso(), blockedBy: null, ...patch }
+  }
+
+  costOf(runIds: string[]): number {
+    if (!runIds.length) return 0
+    return this.db.select({ c: runs.costUsd }).from(runs).where(inArray(runs.id, runIds)).all().reduce((s, r) => s + (r.c ?? 0), 0)
+  }
+
+  jobDto(r: JobRow): JobDto {
+    return {
+      id: r.id, teamId: r.teamId, no: r.no, title: r.title, plan: r.plan, status: r.status, summary: r.summary, files: r.files,
+      blockedBy: r.blockedBy, limits: r.limits, leadRuns: r.leadRuns, taskCount: this.tasks(r.teamId, { jobId: r.id }).length,
+      costUsd: this.costOf(r.runIds), createdAt: r.createdAt, endedAt: r.endedAt,
+    }
+  }
+
+  taskDto(r: TaskRow): TaskDto {
+    return {
+      id: r.id, jobId: r.jobId, teamId: r.teamId, no: r.no, botId: r.botId, title: r.title, instructions: r.instructions, dependsOn: r.dependsOn,
+      status: r.status, result: r.result, files: r.files, runId: r.runId, sessionId: r.sessionId, costUsd: this.costOf(r.runId ? [r.runId] : []),
+      createdAt: r.createdAt, startedAt: r.startedAt, endedAt: r.endedAt,
+    }
+  }
+
+  removeForTeam(teamId: string) {
+    this.db.delete(tasks).where(eq(tasks.teamId, teamId)).run()
+    this.db.delete(jobs).where(eq(jobs.teamId, teamId)).run()
+  }
+
+  /** 服务重启时仍在执行的子任务:Run 已被标记为中断,子任务按失败处理 */
+  failInterrupted(): TaskRow[] {
+    const rows = this.db.select().from(tasks).where(eq(tasks.status, 'running')).all()
+    for (const r of rows) this.updateTask(r.id, { status: 'failed', result: '服务重启,子任务中断', endedAt: nowIso() })
+    return rows
+  }
+}

+ 62 - 8
apps/server/src/teams/prompt.ts

@@ -1,16 +1,18 @@
-// 工作组中机器人的提示词与每轮输入(docs/design/13-bot-teams.md §4.3)
-import type { BotDto, TeamEventOf, TeamHuman, TeamMessage } from '@agent-server/shared'
+// 工作组中机器人的提示词与每轮输入(docs/design/13-bot-teams.md §4.3、§5)
+import type { BotCard, BotDto, TeamEventOf, TeamHuman, TeamMessage } from '@agent-server/shared'
+import { cardText } from './cards'
+import type { JobRow, TaskRow } from './jobs'
 
 const ROLE = { lead: '组长', worker: '组员' } as const
 
-/** 追加在 Profile 提示词之后:身份、工作组成员、协作约定 */
-export function teamPrompt(opts: { title: string; human: TeamHuman; self: BotDto; bots: BotDto[] }): string {
+/** 追加在 Profile 提示词之后:身份、工作组成员、协作约定。组长另外带上组员的能力卡片 */
+export function teamPrompt(opts: { title: string; human: TeamHuman; self: BotDto; bots: BotDto[]; cards?: BotCard[] }): string {
   const { self } = opts
   const others = opts.bots.filter(b => b.id !== self.id)
   const roster = others.length
     ? others.map(b => `  - ${b.name}(${ROLE[b.kind]})${b.description ? `:${oneLine(b.description)}` : ''}`).join('\n')
     : '  - (没有其他机器人)'
-  return `# 工作组
+  const base = `# 工作组
 你是工作组「${opts.title || '未命名'}」中的机器人「${self.name}」(${ROLE[self.kind]})。${self.description ? `\n你的岗位:${self.description.trim()}` : ''}
 
 - 成员(人类):${opts.human.name}
@@ -21,12 +23,41 @@ ${roster}
   - uploads/ 是成员上传的文件
   - 交付的文件保存到 outputs/ 下,会以文件卡片显示在你的回复中
   - 你的中间文件放在 .work/${self.name}/ 下;不要修改或删除其他机器人的文件
-- 在回复中写"@某个机器人"不会通知对方。需要其他机器人配合时,在回复中向成员说明。`
+- 在回复中写"@某个机器人"不会通知对方。`
+  return self.kind === 'lead' ? `${base}\n\n${leadRules(opts.cards ?? [])}` : `${base}\n\n${WORKER_RULES}`
 }
 
+function leadRules(cards: BotCard[]): string {
+  return `# 组长的工作方式
+你负责理解成员的需求、拆分工作、分派给组员并汇总结果。协作工具由 gateway 提供:
+- 闲聊、简单问题直接回答,不开任务。
+- 需要组员配合的复杂工作:先调用 job_start(title, plan) 开始一项任务(同一时间只能有一项),再用 task_create(bot_id, title, instructions, depends_on) 把子任务分派给组员。
+  - 说明要写清楚输入文件、期望的产出(文件放在 outputs/ 下的哪里)和验收标准,组员看不到你和成员的对话。
+  - 互不依赖的子任务会并行执行;depends_on 填写需要先完成的子任务编号,组员会收到它们的结果。
+- 分派之后结束本轮回复,简短告诉成员你的安排。组员完成或失败时你会被唤醒,收到结果后继续分派、调整计划或重试。
+- 所有子任务都完成后,调用 job_complete(summary, files) 交付:summary 是给成员看的总结,files 是交付文件(outputs/ 下的路径)。无法完成时调用 job_fail(reason)。之后的最终回复简短即可。
+- 任务进行中成员发来的消息,是对当前任务的补充说明。
+- 现有组员都不适合时,可以用 team_bot_catalog() 查看可加入的机器人,再用 team_request_bot(bot_id, reason) 申请,由成员审批。
+- 超出限额时任务会暂停,等待成员决定,你不需要处理。
+- 不要自己动手完成本该由组员完成的工作。
+
+# 当前组员的能力卡片
+${cards.length ? cards.map(cardText).join('\n\n') : '(还没有组员。可以用 team_bot_catalog() 查看可加入的机器人,用 team_request_bot 申请)'}`
+}
+
+const WORKER_RULES = `# 子任务
+组长会把子任务分派给你,输入以【子任务 #编号】开头。
+- 完成后调用 task_complete(result, files) 汇报:result 写清楚做了什么、结论是什么,files 是交付文件(outputs/ 下的路径)。
+- 无法完成时调用 task_fail(reason) 说明原因,不要编造结果。
+- 子任务的结果只交给组长;有疑问时在结果中说明,由组长决定。
+成员也可能直接 @ 你做单项工作,这时直接回复成员即可,不需要调用 task_complete。`
+
+/** 不交给机器人的消息:逐步执行与替身的提示卡片 */
+const hiddenFromBots = (m: TeamMessage) => m.card?.kind === 'step' || m.card?.kind === 'stub'
+
 /** 本轮输入:自上次以来与该机器人有关的新消息 */
 export function teamInput(messages: TeamEventOf<'message.created'>[], selfId: string): string {
-  const lines = messages.map(e => {
+  const lines = messages.filter(e => !hiddenFromBots(e.payload)).map(e => {
     const m = e.payload
     const head = `[${clock(e.ts)}] ${senderLabel(m, selfId)}:`
     const files = m.files.length ? `\n  附件:${m.files.map(f => f.path).join('、')}` : ''
@@ -35,13 +66,36 @@ export function teamInput(messages: TeamEventOf<'message.created'>[], selfId: st
   return `【工作组新消息】\n${lines.join('\n\n')}`
 }
 
+/** 组员执行子任务时的输入:说明 + 依赖子任务的结果 */
+export function taskInput(task: TaskRow, job: JobRow, leadName: string, deps: TaskRow[], botName: (id: string) => string): string {
+  const parts = [
+    `【子任务 #${task.no} · 来自组长 ${leadName}】`,
+    `所属任务:${job.title}`,
+    `标题:${task.title}`,
+    `说明:\n${task.instructions}`,
+  ]
+  for (const d of deps) {
+    const files = d.files.length ? `\n文件:${d.files.map(f => f.path).join('、')}` : ''
+    parts.push(`【依赖的子任务 #${d.no} · ${botName(d.botId)} 的结果】\n${d.result ?? ''}${files}`)
+  }
+  parts.push('完成后调用 task_complete(result, files) 汇报;无法完成时调用 task_fail(reason)。')
+  return parts.join('\n\n')
+}
+
 function senderLabel(m: TeamMessage, selfId: string): string {
   const s = m.sender
   if (s.kind === 'human') return `${s.name}(成员)${m.targets.includes(selfId) ? '' : '(不是发给你的)'}`
-  if (s.kind === 'bot') return `${s.name}(机器人)`
+  if (s.kind === 'bot') {
+    const c = m.card
+    // 替身的结果不标注,组长按正常结果处理(替身用来测试组长)
+    if (c?.kind === 'task' && c.event !== 'assigned') return `${s.name}(组员)· 子任务 #${c.no} ${TASK_EVENT[c.event]}`
+    return `${s.name}(机器人)`
+  }
   return '系统'
 }
 
+const TASK_EVENT = { completed: '已完成', failed: '失败', cancelled: '已取消' } as const
+
 function clock(ts: string) {
   return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
 }

+ 25 - 2
apps/server/src/teams/routes.ts

@@ -3,7 +3,7 @@ import fs from 'node:fs'
 import path from 'node:path'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import { z } from 'zod'
-import type { CreateBrowserTokenBody, CreateTeamBody, InteractionResponse, SendTeamMessageBody, TeamStatus } from '@agent-server/shared'
+import type { CreateBrowserTokenBody, CreateTeamBody, InteractionResponse, SendTeamMessageBody, StubTaskBody, TeamStatus } from '@agent-server/shared'
 import type { App } from '../app'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { BROWSER_TOKEN_MAX_TTL } from '../services/browser-tokens'
@@ -13,6 +13,7 @@ import { streamEvents } from '../http/sse'
 
 type IdParams = { Params: { id: string } }
 type BotParams = { Params: { id: string; botId: string } }
+type JobParams = { Params: { id: string; jobId: string } }
 
 const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
   z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
@@ -45,7 +46,7 @@ export default async function botRoutes(f: FastifyInstance, { app }: { app: App
   // 这个 Key 可以使用的机器人(后台不受限)
   f.get('/bots', async req => {
     const allowed = app.botKeys.allowedBots(source(req))
-    return app.bots.list().filter(b => b.enabled && (!allowed || allowed.has(b.id))).map(b => app.bots.summary(b))
+    return app.teamCards.cards(app.bots.list().filter(b => b.enabled && (!allowed || allowed.has(b.id))))
   })
 
   // ---- 工作组 ----
@@ -109,6 +110,28 @@ export default async function botRoutes(f: FastifyInstance, { app }: { app: App
     return { ok: true }
   })
 
+  // ---- 任务、子任务与用量 ----
+  f.get<IdParams>('/teams/:id/jobs', async req => app.teams.jobList(req.params.id, source(req)))
+  f.post<JobParams>('/teams/:id/jobs/:jobId/cancel', async req => {
+    app.teams.cancelJob(req.params.id, source(req), req.params.jobId)
+    return { ok: true }
+  })
+  f.post<JobParams>('/teams/:id/jobs/:jobId/continue', async req => {
+    app.teams.continueJob(req.params.id, source(req), req.params.jobId)
+    return { ok: true }
+  })
+  f.get<IdParams>('/teams/:id/usage', async req => app.teams.usage(req.params.id, source(req)))
+
+  // ---- 调试(仅 admin)----
+  f.post<IdParams & { Params: { taskId: string } }>('/teams/:id/tasks/:taskId/stub', async req => {
+    await app.teams.stubResult(req.params.id, source(req), req.params.taskId, (req.body ?? {}) as StubTaskBody)
+    return { ok: true }
+  })
+  f.post<IdParams & { Params: { stepId: string } }>('/teams/:id/steps/:stepId/continue', async req => {
+    app.teams.continueStep(req.params.id, source(req), req.params.stepId)
+    return { ok: true }
+  })
+
   // ---- 文件 ----
   f.post<IdParams>('/teams/:id/files', async (req, reply) => {
     const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576

+ 665 - 61
apps/server/src/teams/service.ts

@@ -1,36 +1,53 @@
-// 工作组(docs/design/13-bot-teams.md):组成规则、生命周期、消息与唤醒、机器人执行结果回帖、审批
+// 工作组(docs/design/13-bot-teams.md):组成规则、生命周期、消息与唤醒、机器人执行结果回帖、审批,
+// 以及组长的任务与子任务、限额、替身组员和逐步执行(二期)。
 // 每个"机器人 × 工作组"一个引擎会话(来源 team:<id>),工作目录都是工作组的共享目录;同一机器人按顺序执行,
-// 执行期间到达的新消息在下一轮一起交给它
-import { and, desc, eq } from 'drizzle-orm'
+// 执行期间到达的新消息在下一轮一起交给它。组员有排队的子任务时先做子任务
+import { and, desc, eq, inArray } from 'drizzle-orm'
 import { z } from 'zod'
 import type {
-  AgentEvent, BotDto, CreateTeamBody, InteractionResponse, SendTeamMessageBody, TeamBotDto, TeamBotStatus, TeamDetailDto, TeamDto,
-  TeamEventOf, TeamFileRef, TeamInteractionDto, TeamMessage, TeamStatus,
+  AgentEvent, BotDto, CreateTeamBody, InteractionResponse, PatchTeamBody, SendTeamMessageBody, StubTaskBody, TeamBotDto, TeamBotStatus,
+  TeamCard, TeamDebug, TeamDetailDto, TeamDto, TeamEventOf, TeamFileRef, TeamInteractionDto, TeamJobsDto, TeamLimitKey, TeamLimits,
+  TeamMessage, TeamStatus, TeamStepDto, TeamUsageDto, TeamUsageRow,
 } from '@agent-server/shared'
 import type { Readable } from 'node:stream'
 import path from 'node:path'
 import type { DB } from '../db'
-import { sessions, teamBotSessions, teamBots, teams } from '../db/schema'
+import { runs, sessions, teamBotSessions, teamBots, teams } from '../db/schema'
 import { AppError, badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
 import { parse, type ProfileService } from '../services/catalog'
 import type { EventStore } from '../services/event-store'
 import type { FileService } from '../services/files'
 import type { InteractionService } from '../services/interactions'
 import type { RunAttachment, RunManager } from '../services/runs'
 import { isTeamSource, TEAM_SOURCE_PREFIX, type SessionRow, type SessionService } from '../services/sessions'
-import type { SettingsService } from '../services/settings'
+import { teamLimitsSchema, type SettingsService } from '../services/settings'
 import type { BotKeyService, BotService } from './bots'
+import { cardText, type CardService } from './cards'
 import { TeamEventStore } from './events'
-import { teamInput, teamPrompt } from './prompt'
+import { JobStore, LIMIT_LABELS, OPEN_TASK, type JobRow, type TaskRow } from './jobs'
+import { taskInput, teamInput, teamPrompt } from './prompt'
+import { collabTools, LEAD_TOOLS, WORKER_TOOLS, type CollabArgs, type CollabToolName } from './tools'
 
 export type TeamRow = typeof teams.$inferSelect
 
+const limitsPatch = teamLimitsSchema.partial()
+const debugPatch = z.object({ stepMode: z.boolean(), stubBotIds: z.array(z.string()).max(50) }).partial()
+
 const createSchema: z.ZodType<CreateTeamBody> = z.object({
   title: z.string().trim().max(100).optional(),
   human: z.object({ id: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(60) }),
   leadBotId: z.string().optional(),
   workerBotIds: z.array(z.string()).max(20).optional(),
+  limits: limitsPatch.optional(),
+  debug: debugPatch.optional(),
+})
+
+const patchSchema: z.ZodType<PatchTeamBody> = z.object({
+  title: z.string().trim().min(1).max(100).optional(),
+  limits: limitsPatch.optional(),
+  debug: debugPatch.optional(),
 })
 
 const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
@@ -39,6 +56,12 @@ const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
   fileIds: z.array(z.string()).max(50).optional(),
 })
 
+const stubSchema: z.ZodType<StubTaskBody> = z.object({
+  outcome: z.enum(['completed', 'failed']),
+  result: z.string().trim().min(1).max(50_000),
+  fileIds: z.array(z.string()).max(50).optional(),
+})
+
 interface BotRuntime {
   status: TeamBotStatus
   runId: string | null
@@ -49,15 +72,29 @@ interface BotRuntime {
   /** 本轮最后一段回复与改动过的文件(用于回帖) */
   lastText: string
   changedPaths: Set<string>
+  /** 本轮执行的子任务,以及组员调用 task_complete / task_fail 报告的结果 */
+  taskId: string | null
+  taskOutcome: { status: 'completed' | 'failed'; result: string; paths: string[] } | null
+  /** 组长本轮发出了任务卡片(开始、完成等),没有最终回复时不再提示"本轮没有回复" */
+  posted: boolean
 }
 
+/** 机器人下一步要做的事 */
+type Work =
+  | { kind: 'task'; task: TaskRow }
+  | { kind: 'messages'; relevant: TeamEventOf<'message.created'>[]; readUpTo: number }
+
 interface Logger { warn(o: object, m?: string): void; error(o: object, m?: string): void }
 
 export class TeamService {
   readonly events: TeamEventStore
+  readonly jobs: JobStore
   private runtime = new Map<string, BotRuntime>()
   /** 会话 → 工作组与机器人;null 表示不是工作组的当前会话 */
   private sessionIndex = new Map<string, { teamId: string; botId: string } | null>()
+  /** 逐步执行模式下等待"继续"的步骤(不落库,服务重启后由下一次唤醒重新生成) */
+  private steps = new Map<string, TeamStepDto[]>()
+  private scans = new Map<string, Promise<void>>()
 
   constructor(
     private deps: {
@@ -72,14 +109,37 @@ export class TeamService {
       interactions: InteractionService
       bots: BotService
       botKeys: BotKeyService
+      cards: CardService
       /** 删除会话时清理网关的会话级状态 */
       forgetSession: (sessionId: string) => void
     },
   ) {
     this.events = new TeamEventStore(deps.db)
+    this.jobs = new JobStore(deps.db)
     deps.events.subscribe('*', e => this.onSessionEvent(e))
     // 用量记到工作组的来源(Bot Key 或 admin)上
     deps.runs.usageSourceOf = s => (isTeamSource(s.source) ? this.findRow(s.source.slice(TEAM_SOURCE_PREFIX.length))?.source ?? s.source : s.source)
+    // 协作工具:按会话所属机器人的角色提供
+    const leadTools = collabTools(LEAD_TOOLS, (name, args, ctx) => this.collab(name, args, ctx))
+    const workerTools = collabTools(WORKER_TOOLS, (name, args, ctx) => this.collab(name, args, ctx))
+    deps.runs.gatewayToolsOf = s => {
+      const ref = isTeamSource(s.source) ? this.lookup(s.id) : null
+      if (!ref) return []
+      return this.memberRows(ref.teamId).find(m => m.botId === ref.botId)?.role === 'lead' ? leadTools : workerTools
+    }
+    // 服务重启时仍在执行的子任务按失败处理;结果消息发给组长,但不在启动时唤醒它,等下一次有消息时一并处理
+    for (const t of this.jobs.failInterrupted()) {
+      const task = this.jobs.task(t.id)
+      if (!this.findRow(task.teamId)) continue
+      this.emitTask(task)
+      const lead = this.leadOf(task.teamId)
+      const bot = this.findBot(task.botId)
+      this.events.append(task.teamId, 'message.created', {
+        id: newId('tmsg'), sender: { kind: 'bot', botId: task.botId, name: bot?.name ?? '(已删除的机器人)', avatar: bot?.avatar ?? '🤖' },
+        text: `失败:${task.result}`, mentions: [], files: [], targets: lead ? [lead] : [],
+        card: { kind: 'task', taskId: task.id, no: task.no, event: 'failed' },
+      })
+    }
   }
 
   // ---- 工作组 ----
@@ -91,13 +151,15 @@ export class TeamService {
     if (data.leadBotId) members.push({ bot: this.usableBot(source, data.leadBotId, 'lead'), role: 'lead' })
     for (const id of workers) members.push({ bot: this.usableBot(source, id, 'worker'), role: 'worker' })
     if (!members.length) throw badRequest('工作组至少需要一个机器人(组长或组员)')
+    const limits = data.limits ? this.lowerLimits(this.deps.settings.get().teamLimits, data.limits) : null
+    const debug = data.debug ? this.checkDebug(source, { stepMode: false, stubBotIds: [] }, data.debug) : undefined
 
     const id = newId('team')
     const workspaceDir = path.join(this.deps.settings.get().paths.workspaceRoot, id)
     await this.deps.files.createWorkspace(workspaceDir)
     const title = data.title || members.map(m => m.bot.name).join('、')
     this.deps.db.transaction(tx => {
-      tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir }).run()
+      tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir, limits, ...(debug ? { debug } : {}) }).run()
       for (const m of members) tx.insert(teamBots).values({ teamId: id, botId: m.bot.id, role: m.role }).run()
     })
     const lead = members.find(m => m.role === 'lead')
@@ -139,31 +201,33 @@ export class TeamService {
     })
     // 组长排在最前面
     bots.sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === 'lead' ? -1 : 1))
-    return { id: r.id, title: r.title, human: r.human, status: r.status, source: r.source, bots, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt }
+    return {
+      id: r.id, title: r.title, human: r.human, status: r.status, source: r.source, bots, limits: this.limitsOf(r), debug: r.debug,
+      activeJobId: this.jobs.active(r.id)?.id ?? null, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt,
+    }
   }
 
   detail(r: TeamRow): TeamDetailDto {
-    return { ...this.toDto(r), pendingInteractions: this.pendingInteractions(r.id) }
+    return { ...this.toDto(r), pendingInteractions: this.pendingInteractions(r.id), pendingSteps: this.steps.get(r.id) ?? [] }
   }
 
   patch(id: string, source: string, body: unknown): TeamRow {
-    this.getRow(id, source)
-    const { title } = parse(z.object({ title: z.string().trim().min(1).max(100) }), body)
-    this.deps.db.update(teams).set({ title, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
+    const team = this.getRow(id, source)
+    const data = parse(patchSchema, body)
+    const set: Partial<TeamRow> = { updatedAt: nowIso() }
+    if (data.title) set.title = data.title
+    if (data.limits) set.limits = this.lowerLimits(this.deps.settings.get().teamLimits, data.limits)
+    const debug = data.debug ? this.checkDebug(source, team.debug, data.debug) : null
+    if (debug) set.debug = debug
+    this.deps.db.update(teams).set(set).where(eq(teams.id, id)).run()
+    if (debug) this.applyDebug(team, debug)
     this.changed(id)
     return this.getRow(id)
   }
 
   addBot(id: string, source: string, botId: string): TeamRow {
     const team = this.getRow(id, source)
-    if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
-    const members = this.memberRows(id)
-    if (members.some(m => m.botId === botId)) throw conflict('already_member', '这个机器人已经在工作组中')
-    const bot = this.usableBot(team.source, botId)
-    if (bot.kind === 'lead' && members.some(m => m.role === 'lead')) throw badRequest('工作组已经有组长,最多只能有一个')
-    this.deps.db.insert(teamBots).values({ teamId: id, botId, role: bot.kind }).run()
-    this.system(id, `${bot.name}(${bot.kind === 'lead' ? '组长' : '组员'})加入了工作组`)
-    this.changed(id)
+    this.join(team, this.usableBot(team.source, botId))
     return this.getRow(id)
   }
 
@@ -172,15 +236,19 @@ export class TeamService {
     const members = this.memberRows(id)
     if (!members.some(m => m.botId === botId)) throw notFound('工作组中的机器人')
     if (members.length <= 1) throw badRequest('工作组至少需要一个机器人,不能移除最后一个')
-    this.interruptBot(id, botId)
+    this.dropStep(id, botId)
     this.deps.db.delete(teamBots).where(and(eq(teamBots.teamId, id), eq(teamBots.botId, botId))).run()
-    this.runtime.delete(key(id, botId))
+    this.interruptBot(id, botId)
     this.system(id, `${this.botName(botId)} 离开了工作组`)
+    // 它还没有结束的子任务按失败处理,并通知组长
+    for (const t of this.jobs.tasks(id, { botId, status: OPEN_TASK })) {
+      if (t.status !== 'running') this.endTask(t, 'failed', '组员已被移出工作组', [])
+    }
     this.changed(id)
     return this.getRow(id)
   }
 
-  /** 暂停:中断正在执行的工作,新消息只记录不唤醒;恢复后处理暂停期间收到的消息 */
+  /** 暂停:中断正在执行的工作,新消息只记录不唤醒;恢复后处理暂停期间收到的消息和排队的子任务 */
   setStatus(id: string, source: string, action: 'pause' | 'resume' | 'close' | 'reopen'): TeamRow {
     const team = this.getRow(id, source)
     const next: Record<typeof action, { from: TeamStatus[]; to: TeamStatus; text: string }> = {
@@ -192,10 +260,17 @@ export class TeamService {
     const t = next[action]
     if (!t.from.includes(team.status)) throw conflict('invalid_status', `工作组当前状态(${team.status})不能执行这个操作`)
     this.deps.db.update(teams).set({ status: t.to, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
-    if (t.to !== 'active') for (const m of this.memberRows(id)) this.interruptBot(id, m.botId)
+    if (t.to === 'closed') {
+      const job = this.jobs.active(id)
+      if (job) this.endJob(job, 'cancelled', '工作组已关闭')
+    }
+    if (t.to !== 'active') {
+      this.clearSteps(id)
+      for (const m of this.memberRows(id)) this.interruptBot(id, m.botId)
+    }
     this.system(id, t.text)
     this.changed(id)
-    if (t.to === 'active') for (const m of this.memberRows(id)) this.wake(id, m.botId)
+    if (t.to === 'active') this.wakeAll(id)
     return this.getRow(id)
   }
 
@@ -221,7 +296,9 @@ export class TeamService {
       tx.delete(teamBots).where(eq(teamBots.teamId, id)).run()
       tx.delete(teams).where(eq(teams.id, id)).run()
     })
+    this.jobs.removeForTeam(id)
     this.events.remove(id)
+    this.steps.delete(id)
     this.deps.files.deleteForSession(id)
     for (const k of [...this.runtime.keys()]) if (k.startsWith(`${id}/`)) this.runtime.delete(k)
     await this.deps.files.removeWorkspace(team.workspaceDir, this.deps.settings.get().paths.workspaceRoot)
@@ -245,12 +322,7 @@ export class TeamService {
       else throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
     }
     const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
-    const message: TeamMessage = {
-      id: newId('tmsg'), sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets,
-    }
-    this.events.append(id, 'message.created', message)
-    if (team.status === 'active') for (const t of targets) this.wake(id, t)
-    return message
+    return this.post(id, { sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets })
   }
 
   // ---- 文件 ----
@@ -294,10 +366,198 @@ export class TeamService {
       .flatMap(l => this.deps.interactions.pendingFor(l.sessionId).map(request => ({ botId: l.botId, sessionId: l.sessionId, request })))
   }
 
+  // ---- 任务、子任务与用量 ----
+
+  jobList(id: string, source: string): TeamJobsDto {
+    this.getRow(id, source)
+    return { jobs: this.jobs.jobs(id).map(j => this.jobs.jobDto(j)), tasks: this.jobs.tasks(id).map(t => this.jobs.taskDto(t)) }
+  }
+
+  /** 成员取消任务:还没有结束的子任务一并取消,组长下一轮会看到 */
+  cancelJob(id: string, source: string, jobId: string) {
+    this.getRow(id, source)
+    const job = this.jobs.job(jobId, id)
+    if (job.status !== 'running' && job.status !== 'blocked') throw conflict('job_ended', '任务已经结束')
+    this.endJob(job, 'cancelled', `${this.getRow(id).human.name} 取消了任务`)
+  }
+
+  /** 超出限额后继续:这一项限额放宽一倍 */
+  continueJob(id: string, source: string, jobId: string) {
+    this.getRow(id, source)
+    const job = this.jobs.job(jobId, id)
+    if (job.status !== 'blocked' || !job.blockedBy) throw conflict('job_not_blocked', '任务没有因为限额暂停')
+    const key = job.blockedBy
+    const current = job.limits[key]
+    const raised = current === null ? null : key === 'maxCostUsdPerJob' ? Math.round(current * 200) / 100 : current * 2
+    const next = this.jobs.updateJob(job.id, { status: 'running', blockedBy: null, limits: { ...job.limits, [key]: raised } })
+    this.emitJob(next)
+    const lead = this.leadOf(id)
+    this.post(id, {
+      sender: { kind: 'system' }, text: `成员选择继续任务「${job.title}」,${LIMIT_LABELS[key]}上限放宽到 ${raised}`,
+      mentions: [], files: [], targets: lead ? [lead] : [],
+    })
+    this.dispatchTasks(id)
+  }
+
+  /** 替身组员的子任务:由管理员填写结果 */
+  async stubResult(id: string, source: string, taskId: string, body: StubTaskBody) {
+    const team = this.getRow(id, source)
+    if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台可以填写替身结果')
+    const data = parse(stubSchema, body)
+    const task = this.jobs.task(taskId, id)
+    if (task.status !== 'stub') throw conflict('task_not_stub', '这个子任务不在等待替身结果')
+    await this.scanFiles(team)
+    const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
+    this.endTask(task, data.outcome, data.result, files, { stub: true })
+  }
+
+  /** 逐步执行:放行一步 */
+  continueStep(id: string, source: string, stepId: string) {
+    this.getRow(id, source)
+    if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台可以使用逐步执行')
+    const step = (this.steps.get(id) ?? []).find(s => s.id === stepId)
+    if (!step) throw notFound('步骤')
+    this.dropStep(id, step.botId)
+    this.wake(id, step.botId, { approved: true })
+  }
+
+  usage(id: string, source: string): TeamUsageDto {
+    this.getRow(id, source)
+    const links = this.deps.db.select().from(teamBotSessions).where(eq(teamBotSessions.teamId, id)).all()
+    const botOf = new Map(links.map(l => [l.sessionId, l.botId]))
+    const sessionIds = this.teamSessionIds(id)
+    const rows = sessionIds.length ? this.deps.db.select().from(runs).where(inArray(runs.sessionId, sessionIds)).all() : []
+    const byRun = new Map(rows.map(r => [r.id, r]))
+    const sum = (list: typeof rows): TeamUsageRow => ({
+      runs: list.length,
+      inputTokens: list.reduce((s, r) => s + (r.inputTokens ?? 0), 0),
+      outputTokens: list.reduce((s, r) => s + (r.outputTokens ?? 0), 0),
+      costUsd: list.reduce((s, r) => s + (r.costUsd ?? 0), 0),
+    })
+    const pick = (ids: (string | null)[]) => ids.flatMap(i => (i && byRun.has(i) ? [byRun.get(i)!] : []))
+    const botIds = [...new Set(rows.map(r => botOf.get(r.sessionId) ?? 'unknown'))]
+    return {
+      total: sum(rows),
+      bots: botIds.map(botId => ({ botId, ...sum(rows.filter(r => (botOf.get(r.sessionId) ?? 'unknown') === botId)) })),
+      jobs: this.jobs.jobs(id).map(j => ({ jobId: j.id, ...sum(pick(j.runIds)) })),
+      tasks: this.jobs.tasks(id).map(t => ({ taskId: t.id, ...sum(pick([t.runId])) })),
+    }
+  }
+
+  // ---- 协作工具(MCP Gateway 调用,见 tools.ts)----
+
+  private async collab<N extends CollabToolName>(name: N, rawArgs: CollabArgs<N>, ctx: GatewayRunContext): Promise<string> {
+    const ref = this.lookup(ctx.sessionId)
+    const team = ref && this.findRow(ref.teamId)
+    if (!ref || !team) throw new Error('这个会话不属于任何工作组')
+    const role = this.memberRows(team.id).find(m => m.botId === ref.botId)?.role
+    if (!role) throw new Error('你已不在这个工作组中')
+    const rt = this.rt(team.id, ref.botId)
+    const args = rawArgs as Record<string, unknown>
+
+    switch (name) {
+      case 'team_members': {
+        const cards = await this.deps.cards.cards(this.memberBots(team.id).filter(b => this.roleOf(team.id, b.id) === 'worker'))
+        return cards.length ? cards.map(cardText).join('\n\n') : '当前没有组员。'
+      }
+      case 'team_bot_catalog': {
+        const cards = await this.deps.cards.cards(this.catalog(team))
+        return cards.length ? cards.map(cardText).join('\n\n') : '没有可以申请加入的机器人。'
+      }
+      case 'team_request_bot': {
+        const bot = this.catalog(team).find(b => b.id === args.bot_id)
+        if (!bot) throw new Error('这个机器人不能加入本工作组(不存在、已在组中或不在可用范围内),请先调用 team_bot_catalog')
+        const outcome = await ctx.interact({
+          kind: 'approval', tool: 'team_request_bot', title: `组长申请让「${bot.name}」加入工作组`,
+          input: { bot: bot.name, reason: args.reason }, detail: { reason: String(args.reason ?? '') }, options: ['allow_once', 'deny'],
+        })
+        if (outcome.resolution !== 'allowed') return `成员没有批准${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}。`
+        const fresh = this.getRow(team.id)
+        this.join(fresh, this.usableBot(fresh.source, bot.id, 'worker'), '组长申请,成员已批准')
+        return `${bot.name} 已加入工作组。它的能力卡片:\n\n${cardText(await this.deps.cards.card(bot))}`
+      }
+      case 'team_history': {
+        const limit = Number(args.limit ?? 30)
+        const before = args.before === undefined ? Number.MAX_SAFE_INTEGER : Number(args.before)
+        const all = this.events.list(team.id, 0, 100_000, ['message.created']) as TeamEventOf<'message.created'>[]
+        const page = all.filter(e => e.seq < before).slice(-limit)
+        if (!page.length) return '没有更早的消息。'
+        return `${page.map(e => `#${e.seq} ${teamInput([e], ref.botId).replace(/^【工作组新消息】\n/, '')}`).join('\n\n')}\n\n(更早的消息:before=${page[0]!.seq})`
+      }
+      case 'job_start': {
+        const active = this.jobs.active(team.id)
+        if (active) throw new Error(`已有进行中的任务「${active.title}」,请先完成(job_complete)或结束(job_fail)它`)
+        const job = this.jobs.createJob(team.id, String(args.title), String(args.plan ?? ''), this.limitsOf(team), ctx.runId)
+        this.emitJob(job)
+        this.botPost(team.id, ref.botId, { text: `开始任务「${job.title}」${job.plan ? `\n\n${job.plan}` : ''}`, card: { kind: 'job', jobId: job.id, event: 'started' } })
+        rt.posted = true
+        this.changed(team.id)
+        return `任务 #${job.no} 已开始。用 task_create 分派子任务。`
+      }
+      case 'job_complete':
+      case 'job_fail': {
+        const job = this.jobs.active(team.id)
+        if (!job) throw new Error('当前没有进行中的任务')
+        const done = name === 'job_complete'
+        const files = done ? await this.resolvePaths(team, (args.files as string[] | undefined) ?? []) : []
+        this.endJob(job, done ? 'completed' : 'failed', String(done ? args.summary : args.reason), files, ref.botId)
+        rt.posted = true
+        return done ? '任务已完成并交付给成员。' : '已告知成员任务失败。'
+      }
+      case 'task_create': {
+        const job = this.requireRunningJob(team.id)
+        const botId = String(args.bot_id)
+        if (this.roleOf(team.id, botId) !== 'worker') throw new Error('bot_id 不是本工作组的组员,请查看 team_members')
+        const dependsOn = [...new Set((args.depends_on as number[] | undefined) ?? [])]
+        for (const no of dependsOn) {
+          if (this.jobs.taskByNo(team.id, no)?.jobId !== job.id) throw new Error(`依赖的子任务 #${no} 不属于当前任务`)
+        }
+        const count = this.jobs.tasks(team.id, { jobId: job.id }).length
+        const max = job.limits.maxTasksPerJob
+        if (max !== null && count >= max) {
+          this.blockJob(job, 'maxTasksPerJob')
+          throw new Error(`已达到子任务数上限(${max}),任务已暂停,等待成员决定继续或取消。请结束本轮回复。`)
+        }
+        const task = this.jobs.createTask(job, { botId, title: String(args.title), instructions: String(args.instructions), dependsOn })
+        this.emitTask(task)
+        this.botPost(team.id, ref.botId, {
+          text: `#${task.no} → ${this.botName(botId)}:${task.title}${dependsOn.length ? `(等待 ${dependsOn.map(n => `#${n}`).join('、')} 完成)` : ''}\n\n${task.instructions}`,
+          card: { kind: 'task', taskId: task.id, no: task.no, event: 'assigned' },
+        })
+        rt.posted = true
+        this.dispatchTasks(team.id)
+        return `子任务 #${task.no} 已分派给 ${this.botName(botId)}。`
+      }
+      case 'task_cancel': {
+        const task = this.jobs.taskByNo(team.id, Number(args.task_no))
+        if (!task || !OPEN_TASK.includes(task.status)) throw new Error('子任务不存在或已经结束')
+        this.cancelTask(task)
+        return `子任务 #${task.no} 已取消。`
+      }
+      case 'task_list': {
+        const job = this.jobs.active(team.id)
+        if (!job) return '当前没有进行中的任务。'
+        const list = this.jobs.tasks(team.id, { jobId: job.id })
+        if (!list.length) return `任务「${job.title}」还没有子任务。`
+        return list.map(t => `#${t.no} ${this.botName(t.botId)} · ${t.title} · ${TASK_STATUS[t.status]}${t.dependsOn.length ? ` · 依赖 ${t.dependsOn.map(n => `#${n}`).join('、')}` : ''}${t.result ? `\n  结果:${t.result.slice(0, 500)}` : ''}`).join('\n')
+      }
+      case 'task_complete':
+      case 'task_fail': {
+        const task = rt.taskId ? this.jobs.task(rt.taskId) : null
+        if (!task || task.status !== 'running') throw new Error('你当前没有在执行子任务(成员直接 @ 你的工作,直接回复即可)')
+        rt.taskOutcome = name === 'task_complete'
+          ? { status: 'completed', result: String(args.result), paths: (args.files as string[] | undefined) ?? [] }
+          : { status: 'failed', result: String(args.reason), paths: [] }
+        return '已记录,本轮结束后交给组长。'
+      }
+    }
+    throw new Error(`未知的工具:${name}`)
+  }
+
   // ---- 唤醒与执行 ----
 
-  /** 唤醒机器人:空闲时立即开始一轮;正在执行时记下,本轮结束后再处理 */
-  private wake(teamId: string, botId: string) {
+  /** 唤醒机器人:空闲且有事可做时开始一轮;正在执行时记下,本轮结束后再处理 */
+  private wake(teamId: string, botId: string, opts: { approved?: boolean } = {}) {
     const team = this.findRow(teamId)
     if (!team || team.status !== 'active') return
     const rt = this.rt(teamId, botId)
@@ -305,23 +565,74 @@ export class TeamService {
       rt.pending = true
       return
     }
+    const work = this.nextWork(team, botId)
+    if (!work) {
+      rt.pending = false
+      return
+    }
+    // 工作组同时执行的 Run 数:超出时等其他机器人结束后再唤醒
+    if (this.busyCount(teamId) >= this.limitsOf(team).maxConcurrentRuns) {
+      rt.pending = true
+      return
+    }
+    if (this.roleOf(teamId, botId) === 'lead') {
+      const job = this.jobs.active(teamId)
+      // 任务因限额暂停时组长不工作,等成员选择继续或取消
+      if (job?.status === 'blocked') {
+        rt.pending = false
+        return
+      }
+      const over = job && this.overLimit(job, true)
+      if (job && over) {
+        this.blockJob(job, over)
+        rt.pending = false
+        return
+      }
+    }
+    if (team.debug.stepMode && !opts.approved) {
+      this.holdStep(team, botId, work)
+      rt.pending = false
+      return
+    }
     rt.pending = false
     rt.starting = true
     void this.startRun(team, botId)
       .catch(err => {
         this.deps.log.warn({ err, teamId, botId }, 'team bot run failed to start')
-        this.system(teamId, `${this.botName(botId)} 无法开始工作:${(err as Error).message}`, 'error')
+        // 要做的是子任务时,子任务按失败处理并通知组长
+        const task = rt.taskId ? this.jobs.task(rt.taskId) : this.jobs.tasks(teamId, { botId, status: ['queued'] })[0]
+        rt.taskId = null
+        if (task && (task.status === 'queued' || task.status === 'running')) {
+          this.endTask(task, 'failed', `无法开始:${(err as Error).message}`, [])
+        } else {
+          this.system(teamId, `${this.botName(botId)} 无法开始工作:${(err as Error).message}`, 'error')
+        }
       })
       .finally(() => {
         rt.starting = false
-        // 没有开始新一轮(没有发给它的新消息或启动失败)但准备期间又来了消息:再检查一次
-        if (rt.pending && rt.status === 'idle') this.wake(teamId, botId)
+        // 没有开始新一轮(没有新工作或启动失败)但准备期间又来了消息:再检查一次
+        if (rt.status === 'idle') {
+          if (rt.pending) this.wake(teamId, botId)
+          this.wakeWaiting(teamId)
+        }
       })
   }
 
+  /** 机器人的下一项工作:组员先做排队的子任务,再处理发给它的消息 */
+  private nextWork(team: TeamRow, botId: string): Work | null {
+    const role = this.roleOf(team.id, botId)
+    if (!role) return null
+    if (role === 'worker') {
+      const task = this.jobs.tasks(team.id, { botId, status: ['queued'] })[0]
+      if (task) return { kind: 'task', task }
+    }
+    const unread = this.unreadFor(team.id, botId, role)
+    return unread ? { kind: 'messages', ...unread } : null
+  }
+
   /** 自上次以来的新消息;只有发给它的消息才会让它开始一轮。组长能看到组里的全部新消息,组员只看到发给它的 */
   private unreadFor(teamId: string, botId: string, role: BotDto['kind']) {
-    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
+    const link = this.link(teamId, botId)
     const unread = this.events.list(teamId, link?.lastReadSeq ?? 0, 5000, ['message.created']) as TeamEventOf<'message.created'>[]
     if (!unread.some(e => e.payload.targets.includes(botId))) return null
     const relevant = unread.filter(e => !(e.payload.sender.kind === 'bot' && e.payload.sender.botId === botId)
@@ -331,8 +642,8 @@ export class TeamService {
 
   private async startRun(team: TeamRow, botId: string) {
     const member = this.memberRows(team.id).find(m => m.botId === botId)
-    if (!member || !this.unreadFor(team.id, botId, member.role)) return
-    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).get()
+    if (!member || !this.nextWork(team, botId)) return
+    const link = this.link(team.id, botId)
     const lastRead = link?.lastReadSeq ?? 0
 
     const bot = this.deps.bots.get(botId)
@@ -340,7 +651,9 @@ export class TeamService {
     const allowed = this.deps.botKeys.allowedBots(team.source)
     if (allowed && !allowed.has(botId)) throw new Error('这个机器人已不再对当前 Bot Key 开放')
     const profile = this.deps.profiles.get(bot.profileId)
-    const prompt = teamPrompt({ title: team.title, human: team.human, self: bot, bots: this.memberBots(team.id) })
+    const members = this.memberBots(team.id)
+    const cards = member.role === 'lead' ? await this.deps.cards.cards(members.filter(b => this.roleOf(team.id, b.id) === 'worker')) : undefined
+    const prompt = teamPrompt({ title: team.title, human: team.human, self: bot, bots: members, cards })
     const title = `${team.title} · ${bot.name}`
 
     let session: SessionRow | null = null
@@ -371,10 +684,23 @@ export class TeamService {
     // 共享目录中其他机器人产生的文件先登记到这个会话的文件索引,本轮结束时只把本轮改动的文件算作它的产出
     await this.deps.files.scan(session.id, team.workspaceDir)
     const fresh = this.findRow(team.id)
-    const input = this.unreadFor(team.id, botId, member.role)
-    if (!fresh || fresh.status !== 'active' || !input) return
-    const { relevant, readUpTo } = input
+    const work = fresh && fresh.status === 'active' ? this.nextWork(fresh, botId) : null
+    if (!work) return
+    const rt = this.rt(team.id, botId)
 
+    if (work.kind === 'task') {
+      const { task } = work
+      const job = this.jobs.job(task.jobId)
+      const deps = task.dependsOn.flatMap(no => this.jobs.taskByNo(team.id, no) ?? [])
+      rt.taskId = task.id
+      rt.taskOutcome = null
+      const run = this.deps.runs.start(session, taskInput(task, job, this.botName(this.leadOf(team.id) ?? ''), deps, id => this.botName(id)), [])
+      this.emitTask(this.jobs.updateTask(task.id, { status: 'running', runId: run.id, sessionId: session.id, startedAt: nowIso() }))
+      this.jobs.addRun(job.id, run.id, false)
+      return
+    }
+
+    const { relevant, readUpTo } = work
     const attachments: RunAttachment[] = []
     for (const e of relevant) {
       for (const f of e.payload.files) {
@@ -386,9 +712,14 @@ export class TeamService {
         }
       }
     }
-    this.deps.runs.start(session, teamInput(relevant, botId), attachments)
+    const run = this.deps.runs.start(session, teamInput(relevant, botId), attachments)
     this.deps.db.update(teamBotSessions).set({ lastReadSeq: readUpTo })
       .where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).run()
+    const job = member.role === 'lead' ? this.jobs.active(team.id) : undefined
+    if (job) {
+      this.jobs.addRun(job.id, run.id, true)
+      this.emitJob(this.jobs.job(job.id))
+    }
   }
 
   /** 把机器人会话中的事件转成工作组的状态、交互与回帖 */
@@ -398,7 +729,7 @@ export class TeamService {
     const rt = this.rt(ref.teamId, ref.botId)
     switch (e.type) {
       case 'run.queued':
-        Object.assign(rt, { lastText: '', changedPaths: new Set<string>() })
+        Object.assign(rt, { lastText: '', changedPaths: new Set<string>(), posted: false })
         this.setBotStatus(ref, e.sessionId, 'queued', e.runId)
         break
       case 'run.started':
@@ -435,14 +766,12 @@ export class TeamService {
     const outputs = this.deps.files.list(team.id).filter(f => rt.changedPaths.has(f.path) && f.path.startsWith('outputs/'))
     const files: TeamFileRef[] = outputs.map(f => ({ fileId: f.id, path: f.path, mime: f.mime, size: f.size }))
 
-    if (e.type === 'run.completed') {
+    if (rt.taskId) {
+      await this.finishTask(team, rt, e, files)
+    } else if (e.type === 'run.completed') {
       if (rt.lastText || files.length) {
-        const bot = this.findBot(ref.botId)
-        this.events.append(team.id, 'message.created', {
-          id: newId('tmsg'), sender: { kind: 'bot', botId: ref.botId, name, avatar: bot?.avatar ?? '🤖' },
-          text: rt.lastText, mentions: [], files, targets: [], runId: e.runId ?? undefined, sessionId: e.sessionId,
-        })
-      } else {
+        this.botPost(team.id, ref.botId, { text: rt.lastText, files, runId: e.runId ?? undefined, sessionId: e.sessionId })
+      } else if (!rt.posted) {
         this.system(team.id, `${name} 本轮没有回复`, 'warning', e)
       }
     } else if (e.type === 'run.failed') {
@@ -450,12 +779,245 @@ export class TeamService {
     } else if (e.type === 'run.interrupted') {
       this.system(team.id, `${name} 的本轮工作已中断${e.payload.reason === 'restart' ? '(服务重启)' : ''}`, 'warning', e)
     }
+    Object.assign(rt, { taskId: null, taskOutcome: null, posted: false })
     this.setBotStatus(ref, e.sessionId, 'idle', null)
-    if (rt.pending) this.wake(ref.teamId, ref.botId)
+    this.wake(ref.teamId, ref.botId)
+    this.wakeWaiting(ref.teamId)
+  }
+
+  /** 子任务的一轮结束:按 task_complete / task_fail 的报告或本轮结果结束子任务 */
+  private async finishTask(team: TeamRow, rt: BotRuntime, e: AgentEvent, changed: TeamFileRef[]) {
+    const task = this.jobs.task(rt.taskId!)
+    // 已被取消(组长取消、成员取消任务):不再汇报
+    if (task.status !== 'running') return
+    // 工作组暂停导致的中断:恢复后重新执行
+    if (e.type === 'run.interrupted' && team.status === 'paused') {
+      this.emitTask(this.jobs.updateTask(task.id, { status: 'queued', runId: null, startedAt: null }))
+      return
+    }
+    const meta = { runId: e.runId ?? undefined, sessionId: e.sessionId }
+    if (e.type !== 'run.completed') {
+      const reason = e.type === 'run.failed' ? `执行失败:${e.payload.message}` : '本轮工作被中断'
+      this.endTask(task, 'failed', reason, changed, meta)
+      return
+    }
+    const outcome = rt.taskOutcome
+    const reported = outcome ? await this.resolvePaths(team, outcome.paths) : []
+    const files = [...changed, ...reported.filter(f => !changed.some(c => c.path === f.path))]
+    this.endTask(task, outcome?.status ?? 'completed', outcome?.result ?? (rt.lastText || '(没有填写结果)'), files, meta)
+  }
+
+  // ---- 任务流程 ----
+
+  /** 结束子任务:以组员的名义把结果发到工作组,唤醒组长,再看有没有可以开始的子任务 */
+  private endTask(task: TaskRow, status: 'completed' | 'failed', result: string, files: TeamFileRef[], meta: { runId?: string; sessionId?: string; stub?: boolean } = {}) {
+    const next = this.jobs.updateTask(task.id, { status, result, files, endedAt: nowIso() })
+    this.emitTask(next)
+    const job = this.jobs.job(task.jobId)
+    const lead = this.leadOf(task.teamId)
+    const open = job.status === 'running' || job.status === 'blocked'
+    this.botPost(task.teamId, task.botId, {
+      text: status === 'failed' ? `失败:${result}` : result, files,
+      card: { kind: 'task', taskId: task.id, no: task.no, event: status, ...(meta.stub ? { stub: true } : {}) },
+      targets: open && lead ? [lead] : [], runId: meta.runId, sessionId: meta.sessionId,
+    })
+    if (open) this.dispatchTasks(task.teamId)
+  }
+
+  private cancelTask(task: TaskRow) {
+    this.emitTask(this.jobs.updateTask(task.id, { status: 'cancelled', endedAt: nowIso() }))
+    if (task.status === 'running') {
+      const rt = this.rt(task.teamId, task.botId)
+      if (rt.taskId === task.id) this.interruptBot(task.teamId, task.botId)
+    }
+    this.botPost(task.teamId, task.botId, { text: `子任务 #${task.no} 已取消`, card: { kind: 'task', taskId: task.id, no: task.no, event: 'cancelled' } })
+  }
+
+  /** 依赖都已完成的子任务:替身等管理员填写,其余排队并唤醒组员 */
+  private dispatchTasks(teamId: string) {
+    const team = this.findRow(teamId)
+    const job = this.jobs.active(teamId)
+    if (!team || !job) return
+    const stubs = new Set(team.source === 'admin' ? team.debug.stubBotIds : [])
+    for (const t of this.jobs.tasks(teamId, { jobId: job.id, status: ['waiting'] })) {
+      const ready = t.dependsOn.every(no => this.jobs.taskByNo(teamId, no)?.status === 'completed')
+      if (!ready) continue
+      if (stubs.has(t.botId)) {
+        this.emitTask(this.jobs.updateTask(t.id, { status: 'stub', startedAt: nowIso() }))
+        this.post(teamId, {
+          sender: { kind: 'system' }, text: `${this.botName(t.botId)} 是替身:请填写子任务 #${t.no}「${t.title}」的结果`,
+          mentions: [], files: [], targets: [], card: { kind: 'stub', taskId: t.id, no: t.no },
+        })
+      } else {
+        this.emitTask(this.jobs.updateTask(t.id, { status: 'queued' }))
+        this.wake(teamId, t.botId)
+      }
+    }
+  }
+
+  private endJob(job: JobRow, status: 'completed' | 'failed' | 'cancelled', summary: string, files: TeamFileRef[] = [], leadId?: string) {
+    for (const t of this.jobs.tasks(job.teamId, { jobId: job.id, status: OPEN_TASK })) this.cancelTask(t)
+    const next = this.jobs.updateJob(job.id, this.jobs.end(status, { summary, files }))
+    this.emitJob(next)
+    const card: TeamCard = { kind: 'job', jobId: job.id, event: status }
+    const head = { completed: '已完成', failed: '失败', cancelled: '已取消' }[status]
+    const text = `任务「${job.title}」${head}\n\n${summary}`
+    if (leadId) this.botPost(job.teamId, leadId, { text, files, card })
+    else this.post(job.teamId, { sender: { kind: 'system' }, text, mentions: [], files, targets: [], card, level: status === 'completed' ? 'info' : 'warning' })
+    this.changed(job.teamId)
+  }
+
+  private requireRunningJob(teamId: string): JobRow {
+    const job = this.jobs.active(teamId)
+    if (!job) throw new Error('当前没有进行中的任务,请先调用 job_start')
+    if (job.status === 'blocked') throw new Error('任务因超出限额已暂停,等待成员决定。请结束本轮回复')
+    return job
+  }
+
+  /** 超出的限额(子任务数在分派时检查) */
+  private overLimit(job: JobRow, leadRun: boolean): TeamLimitKey | null {
+    const l = job.limits
+    if (leadRun && l.maxLeadRunsPerJob !== null && job.leadRuns >= l.maxLeadRunsPerJob) return 'maxLeadRunsPerJob'
+    if (l.maxMinutesPerJob !== null && Date.now() - Date.parse(job.createdAt) > l.maxMinutesPerJob * 60_000) return 'maxMinutesPerJob'
+    if (l.maxCostUsdPerJob !== null && this.jobs.costOf(job.runIds) >= l.maxCostUsdPerJob) return 'maxCostUsdPerJob'
+    return null
+  }
+
+  private blockJob(job: JobRow, limit: TeamLimitKey) {
+    const next = this.jobs.updateJob(job.id, { status: 'blocked', blockedBy: limit })
+    this.emitJob(next)
+    this.post(job.teamId, {
+      sender: { kind: 'system' }, level: 'warning', mentions: [], files: [], targets: [], card: { kind: 'limit', jobId: job.id, limit },
+      text: `任务「${job.title}」超出${LIMIT_LABELS[limit]}上限(${job.limits[limit]}),已暂停。请选择继续(这一项放宽一倍)或取消任务。`,
+    })
+    this.changed(job.teamId)
+  }
+
+  // ---- 逐步执行 ----
+
+  private holdStep(team: TeamRow, botId: string, work: Work) {
+    const list = this.steps.get(team.id) ?? []
+    if (list.some(s => s.botId === botId)) return
+    const reason = work.kind === 'task' ? `子任务 #${work.task.no}:${work.task.title}` : `处理 ${work.relevant.length} 条新消息`
+    const step: TeamStepDto = { id: newId('step'), botId, reason, createdAt: nowIso() }
+    this.steps.set(team.id, [...list, step])
+    this.events.append(team.id, 'step.updated', { steps: this.steps.get(team.id)! })
+    this.post(team.id, {
+      sender: { kind: 'system' }, text: `下一步:唤醒 ${this.botName(botId)}(${reason})`, mentions: [], files: [], targets: [],
+      card: { kind: 'step', stepId: step.id },
+    })
+  }
+
+  private dropStep(teamId: string, botId: string) {
+    const list = this.steps.get(teamId) ?? []
+    if (!list.some(s => s.botId === botId)) return
+    this.steps.set(teamId, list.filter(s => s.botId !== botId))
+    this.events.append(teamId, 'step.updated', { steps: this.steps.get(teamId)! })
+  }
+
+  private clearSteps(teamId: string) {
+    if (!this.steps.get(teamId)?.length) return
+    this.steps.set(teamId, [])
+    this.events.append(teamId, 'step.updated', { steps: [] })
   }
 
   // ---- 内部工具 ----
 
+  /** 发一条消息并唤醒它的目标 */
+  private post(teamId: string, m: Omit<TeamMessage, 'id'>): TeamMessage {
+    const message: TeamMessage = { id: newId('tmsg'), ...m }
+    this.events.append(teamId, 'message.created', message)
+    if (this.findRow(teamId)?.status === 'active') for (const t of message.targets) this.wake(teamId, t)
+    return message
+  }
+
+  private botPost(teamId: string, botId: string, m: Partial<TeamMessage> & { text: string }) {
+    const bot = this.findBot(botId)
+    return this.post(teamId, {
+      mentions: [], files: [], targets: [], ...m,
+      sender: { kind: 'bot', botId, name: bot?.name ?? '(已删除的机器人)', avatar: bot?.avatar ?? '🤖' },
+    })
+  }
+
+  /** 机器人加入工作组(成员直接添加,或组长申请经批准) */
+  private join(team: TeamRow, bot: BotDto, note?: string) {
+    if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
+    const members = this.memberRows(team.id)
+    if (members.some(m => m.botId === bot.id)) throw conflict('already_member', '这个机器人已经在工作组中')
+    if (bot.kind === 'lead' && members.some(m => m.role === 'lead')) throw badRequest('工作组已经有组长,最多只能有一个')
+    this.deps.db.insert(teamBots).values({ teamId: team.id, botId: bot.id, role: bot.kind }).run()
+    this.system(team.id, `${bot.name}(${bot.kind === 'lead' ? '组长' : '组员'})加入了工作组${note ? `(${note})` : ''}`)
+    this.changed(team.id)
+  }
+
+  /** 组长可以申请加入的机器人:来源可用、已启用、不在本组的组员 */
+  private catalog(team: TeamRow): BotDto[] {
+    const allowed = this.deps.botKeys.allowedBots(team.source)
+    const members = new Set(this.memberRows(team.id).map(m => m.botId))
+    return this.deps.bots.list().filter(b => b.kind === 'worker' && b.enabled && !members.has(b.id) && (!allowed || allowed.has(b.id)))
+  }
+
+  private limitsOf(r: TeamRow): TeamLimits {
+    return { ...this.deps.settings.get().teamLimits, ...r.limits }
+  }
+
+  /** 工作组的限额只能比系统设置低 */
+  private lowerLimits(base: TeamLimits, patch: Partial<TeamLimits>): Partial<TeamLimits> {
+    for (const [k, v] of Object.entries(patch) as [keyof TeamLimits, number | null][]) {
+      const cap = base[k]
+      if (cap !== null && (v === null || v > cap)) throw badRequest(`限额 ${k} 不能超过系统设置(${cap})`)
+    }
+    return patch
+  }
+
+  private checkDebug(source: string, current: TeamDebug, patch: Partial<TeamDebug>): TeamDebug {
+    if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台(Bot Playground)可以设置调试选项')
+    return { ...current, ...patch }
+  }
+
+  /** 调试设置变化:替身开关影响等待中的子任务,关闭逐步执行时放行所有步骤 */
+  private applyDebug(team: TeamRow, debug: TeamDebug) {
+    const stubs = new Set(debug.stubBotIds)
+    for (const t of this.jobs.tasks(team.id, { status: ['stub'] })) {
+      if (!stubs.has(t.botId)) {
+        this.emitTask(this.jobs.updateTask(t.id, { status: 'queued', startedAt: null }))
+        this.wake(team.id, t.botId)
+      }
+    }
+    for (const t of this.jobs.tasks(team.id, { status: ['queued'] })) {
+      if (stubs.has(t.botId)) this.emitTask(this.jobs.updateTask(t.id, { status: 'waiting' }))
+    }
+    this.dispatchTasks(team.id)
+    if (!debug.stepMode) {
+      const held = this.steps.get(team.id) ?? []
+      this.clearSteps(team.id)
+      for (const s of held) this.wake(team.id, s.botId, { approved: true })
+    }
+  }
+
+  /** 交付文件的路径 → 文件引用(只接受共享目录中已登记的文件) */
+  private async resolvePaths(team: TeamRow, paths: string[]): Promise<TeamFileRef[]> {
+    if (!paths.length) return []
+    await this.scanFiles(team)
+    const all = this.deps.files.list(team.id)
+    const out: TeamFileRef[] = []
+    for (const p of paths) {
+      const rel = path.normalize(p.replace(/^\.?\/+/, '')).replace(/^(\.\.(\/|$))+/, '')
+      const f = all.find(x => x.path === rel || path.join(team.workspaceDir, x.path) === p)
+      if (!f) throw new Error(`共享目录中没有这个文件:${p}`)
+      out.push({ fileId: f.id, path: f.path, mime: f.mime, size: f.size })
+    }
+    return out
+  }
+
+  private emitJob(j: JobRow) {
+    this.events.append(j.teamId, 'job.updated', this.jobs.jobDto(j))
+  }
+
+  private emitTask(t: TaskRow) {
+    this.events.append(t.teamId, 'task.updated', this.jobs.taskDto(t))
+  }
+
   private setBotStatus(ref: { teamId: string; botId: string }, sessionId: string, status: TeamBotStatus, runId: string | null) {
     const rt = this.rt(ref.teamId, ref.botId)
     rt.status = status
@@ -464,15 +1026,34 @@ export class TeamService {
   }
 
   private interruptBot(teamId: string, botId: string) {
-    const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
+    const link = this.link(teamId, botId)
     const active = link && this.deps.runs.activeForSession(link.sessionId)
     if (active) this.deps.runs.interrupt(active.runId)
     this.rt(teamId, botId).pending = false
   }
 
+  /** 正在执行或准备执行的机器人数 */
+  private busyCount(teamId: string) {
+    let n = 0
+    for (const [k, r] of this.runtime) if (k.startsWith(`${teamId}/`) && (r.status !== 'idle' || r.starting)) n++
+    return n
+  }
+
+  /** 因并发上限而等待的机器人 */
+  private wakeWaiting(teamId: string) {
+    for (const m of this.memberRows(teamId)) {
+      const r = this.rt(teamId, m.botId)
+      if (r.pending && r.status === 'idle' && !r.starting) this.wake(teamId, m.botId)
+    }
+  }
+
+  private wakeAll(teamId: string) {
+    for (const m of this.memberRows(teamId)) this.wake(teamId, m.botId)
+  }
+
   private system(teamId: string, text: string, level: TeamMessage['level'] = 'info', e?: AgentEvent) {
-    this.events.append(teamId, 'message.created', {
-      id: newId('tmsg'), sender: { kind: 'system' }, text, mentions: [], files: [], targets: [], level,
+    this.post(teamId, {
+      sender: { kind: 'system' }, text, mentions: [], files: [], targets: [], level,
       ...(e ? { runId: e.runId ?? undefined, sessionId: e.sessionId } : {}),
     })
   }
@@ -482,8 +1063,17 @@ export class TeamService {
     if (row) this.events.append(teamId, 'team.updated', this.toDto(row))
   }
 
-  private async scanFiles(team: TeamRow) {
-    for (const c of await this.deps.files.scan(team.id, team.workspaceDir)) this.events.append(team.id, 'file.changed', c)
+  /** 同一工作组的扫描依次进行:并发扫描会把同一个新文件登记两次 */
+  private scanFiles(team: TeamRow): Promise<void> {
+    const prev = this.scans.get(team.id) ?? Promise.resolve()
+    const next = prev.catch(() => {}).then(async () => {
+      for (const c of await this.deps.files.scan(team.id, team.workspaceDir)) this.events.append(team.id, 'file.changed', c)
+    })
+    this.scans.set(team.id, next)
+    void next.finally(() => {
+      if (this.scans.get(team.id) === next) this.scans.delete(team.id)
+    }).catch(() => {})
+    return next
   }
 
   private fileRef(teamId: string, fileId: string): TeamFileRef {
@@ -506,10 +1096,22 @@ export class TeamService {
     return this.deps.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.source, `${TEAM_SOURCE_PREFIX}${teamId}`)).all().map(r => r.id)
   }
 
+  private link(teamId: string, botId: string) {
+    return this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
+  }
+
   private memberRows(teamId: string) {
     return this.deps.db.select().from(teamBots).where(eq(teamBots.teamId, teamId)).all()
   }
 
+  private roleOf(teamId: string, botId: string): BotDto['kind'] | undefined {
+    return this.memberRows(teamId).find(m => m.botId === botId)?.role
+  }
+
+  private leadOf(teamId: string): string | undefined {
+    return this.memberRows(teamId).find(m => m.role === 'lead')?.botId
+  }
+
   private memberBots(teamId: string): BotDto[] {
     const ids = this.memberRows(teamId).map(m => m.botId)
     return ids.length ? this.deps.bots.list().filter(b => ids.includes(b.id)) : []
@@ -534,7 +1136,7 @@ export class TeamService {
   private rt(teamId: string, botId: string): BotRuntime {
     let r = this.runtime.get(key(teamId, botId))
     if (!r) {
-      r = { status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set() }
+      r = { status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set(), taskId: null, taskOutcome: null, posted: false }
       this.runtime.set(key(teamId, botId), r)
     }
     return r
@@ -550,3 +1152,5 @@ export class TeamService {
 }
 
 const key = (teamId: string, botId: string) => `${teamId}/${botId}`
+
+const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' } as const

+ 71 - 0
apps/server/src/teams/tools.ts

@@ -0,0 +1,71 @@
+// 工作组的协作工具(docs/design/13-bot-teams.md §5.4):由 MCP Gateway 提供给工作组中的机器人会话。
+// 工具列表按角色固定(组长一套、组员一套),Codex 会话沿用第一次连接时的工具列表也不受影响;
+// 调用时再按当前状态检查(例如组员不在子任务中时调用 task_complete 会失败)
+import { z } from 'zod'
+import { fail, ok, type BuiltinTool, type ToolResult } from '../mcp-gateway/builtin'
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
+
+const filesArg = z.array(z.string()).max(50).optional().describe('交付文件:共享目录中 outputs/ 下的相对路径')
+
+const schemas = {
+  team_members: z.object({}),
+  team_bot_catalog: z.object({}),
+  team_request_bot: z.object({ bot_id: z.string().describe('机器人 id(取自 team_bot_catalog)'), reason: z.string().max(2000).describe('为什么需要它,会显示给成员') }),
+  team_history: z.object({
+    before: z.number().int().optional().describe('只返回 seq 小于它的消息;省略时从最新的开始'),
+    limit: z.number().int().min(1).max(100).optional().describe('条数,默认 30'),
+  }),
+  job_start: z.object({ title: z.string().min(1).max(100).describe('任务标题'), plan: z.string().max(10_000).describe('计划:打算怎么拆分、交给谁') }),
+  job_complete: z.object({ summary: z.string().min(1).max(50_000).describe('交给成员的总结'), files: filesArg }),
+  job_fail: z.object({ reason: z.string().min(1).max(10_000) }),
+  task_create: z.object({
+    bot_id: z.string().describe('组员的机器人 id(见能力卡片)'),
+    title: z.string().min(1).max(100),
+    instructions: z.string().min(1).max(50_000).describe('完整说明:输入、期望产出、验收标准'),
+    depends_on: z.array(z.number().int()).max(20).optional().describe('需要先完成的子任务编号'),
+  }),
+  task_cancel: z.object({ task_no: z.number().int().describe('子任务编号') }),
+  task_list: z.object({}),
+  task_complete: z.object({ result: z.string().min(1).max(50_000).describe('结果:做了什么、结论'), files: filesArg }),
+  task_fail: z.object({ reason: z.string().min(1).max(10_000) }),
+}
+
+export type CollabToolName = keyof typeof schemas
+export type CollabArgs<N extends CollabToolName> = z.infer<(typeof schemas)[N]>
+
+/** 工作组服务实现的工具处理:返回文字结果;抛出的错误转成工具失败 */
+export type CollabHandler = <N extends CollabToolName>(name: N, args: CollabArgs<N>, ctx: GatewayRunContext) => Promise<string>
+
+const DESCRIPTIONS: Record<CollabToolName, string> = {
+  team_members: '查看当前组员的能力卡片(技能、外部服务、资料库)。',
+  team_bot_catalog: '查看可以申请加入本工作组的机器人及其能力卡片。',
+  team_request_bot: '申请让一个机器人加入工作组。工作组里会显示审批卡片,成员批准后它才会加入;调用会等待成员处理。',
+  team_history: '读取工作组中更早的消息。',
+  job_start: '开始一项任务。同一时间只能有一项进行中的任务;闲聊和简单问题不需要开任务。',
+  job_complete: '完成当前任务并交付给成员。',
+  job_fail: '当前任务无法完成,说明原因。',
+  task_create: '把一个子任务分派给组员。依赖的子任务完成后它才会开始;互不依赖的子任务并行执行。',
+  task_cancel: '取消一个还没有结束的子任务(正在执行的会被中断)。',
+  task_list: '列出当前任务的全部子任务及其状态、结果。',
+  task_complete: '汇报当前子任务的结果(只在执行组长分派的子任务时使用)。',
+  task_fail: '当前子任务无法完成,说明原因。',
+}
+
+export const LEAD_TOOLS: CollabToolName[] = ['team_members', 'team_bot_catalog', 'team_request_bot', 'team_history', 'job_start', 'job_complete', 'job_fail', 'task_create', 'task_cancel', 'task_list']
+export const WORKER_TOOLS: CollabToolName[] = ['team_history', 'task_complete', 'task_fail']
+
+export function collabTools(names: CollabToolName[], handle: CollabHandler): BuiltinTool[] {
+  return names.map(name => ({
+    name,
+    description: DESCRIPTIONS[name],
+    schema: schemas[name],
+    readOnly: ['team_members', 'team_bot_catalog', 'team_history', 'task_list'].includes(name),
+    async handler(args, ctx): Promise<ToolResult> {
+      try {
+        return ok(await handle(name, args as CollabArgs<typeof name>, ctx))
+      } catch (err) {
+        return fail((err as Error).message)
+      }
+    },
+  }))
+}

+ 1 - 0
apps/server/test/api-reference.test.ts

@@ -39,6 +39,7 @@ describe('API 协议文档', () => {
   it('Bot API 事件类型与工作组事件模型一致', () => {
     const all: Record<keyof TeamEventPayloads, true> = {
       'message.created': true, 'bot.status': true, 'interaction.required': true, 'interaction.resolved': true, 'file.changed': true, 'team.updated': true,
+      'job.updated': true, 'task.updated': true, 'step.updated': true,
     }
     expect(BOT_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
   })

+ 342 - 0
apps/server/test/team-jobs.test.ts

@@ -0,0 +1,342 @@
+// Bot 工作组二期(docs/design/13-bot-teams.md §5、§7、§12.2):能力卡片、组长申请加入机器人、任务与子任务、限额、替身组员、逐步执行
+import fs from 'node:fs'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { JobDto, ProviderStatusDto, TaskDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+
+type Call = (tool: string, args: Record<string, unknown>) => Promise<{ text: string; isError: boolean }>
+/** 按机器人名称编排的假引擎:脚本可以调用协作工具(与引擎经 MCP Gateway 调用的是同一组工具) */
+type Script = (input: string, call: Call, ctx: RunContext) => Promise<string>
+
+class ScriptedProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  scripts: Record<string, Script> = {}
+  inputs: { bot: string; text: string; prompt: string }[] = []
+  async run(ctx: RunContext) {
+    ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
+    const bot = app.sessions.getRow(ctx.sessionId).title.split(' · ').at(-1)!
+    this.inputs.push({ bot, text: ctx.input.text, prompt: ctx.systemPromptAppend })
+    const gctx = app.gatewayTokens.get(ctx.gateway.token)!
+    const call: Call = async (tool, args) => {
+      const t = gctx.extraTools?.find(x => x.name === tool)
+      if (!t) return { text: `没有工具 ${tool}`, isError: true }
+      const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
+      return { text: String(r.content[0]?.text ?? ''), isError: !!r.isError }
+    }
+    const reply = await (this.scripts[bot] ?? (async () => `${bot} 收到`))(ctx.input.text, call, ctx)
+    ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
+    return { usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.01 } }
+  }
+  async status(): Promise<ProviderStatusDto> {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  }
+}
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+const fake = new ScriptedProvider()
+let adminCookie: Record<string, string>
+let botKey: string
+const ids: Record<string, string> = {}
+const bk = () => ({ authorization: `Bearer ${botKey}` })
+const human = { id: 'u1', name: '张三' }
+
+async function waitFor<T>(fn: () => T | undefined | false | null, ms = 4000): Promise<T> {
+  const end = Date.now() + ms
+  while (Date.now() < end) {
+    const v = fn()
+    if (v) return v
+    await new Promise(r => setTimeout(r, 20))
+  }
+  throw new Error('timeout')
+}
+
+const messages = (teamId: string) =>
+  app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
+const jobsOf = async (teamId: string, cookies?: Record<string, string>) =>
+  (await server.inject({ method: 'GET', url: `/bot/v1/teams/${teamId}/jobs`, ...(cookies ? { cookies } : { headers: bk() }) })).json() as { jobs: JobDto[]; tasks: TaskDto[] }
+const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  server = await buildServer(app, { logger: log })
+  const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+  const c = login.cookies[0]!
+  adminCookie = { [c.name]: c.value }
+  const profileId = app.profiles.getBuiltin().id
+  for (const [name, kind] of [['组长', 'lead'], ['数据员', 'worker'], ['文档员', 'worker'], ['禁用员', 'worker']] as const) {
+    const res = await server.inject({
+      method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
+      payload: { name, avatar: 'kl:bot:blue', kind, description: `${name}:负责${name.slice(0, 2)}相关工作`, profileId, enabled: true },
+    })
+    expect(res.statusCode).toBe(201)
+    ids[name] = res.json().id
+  }
+  const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['组长'], ids['数据员'], ids['文档员']] } })
+  botKey = key.json().key
+})
+afterAll(() => server.close())
+
+describe('能力卡片', () => {
+  it('GET /bots 返回能力卡片;组长的提示词中带有组员的卡片', async () => {
+    const cards = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json()
+    const data = cards.find((b: { name: string }) => b.name === '数据员')
+    expect(data).toMatchObject({ kind: 'worker', permissionMode: expect.any(String) })
+    expect(Array.isArray(data.skills)).toBe(true)
+
+    fake.scripts['组长'] = async () => '你好'
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '在吗' } })
+    await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
+    const prompt = fake.inputs.filter(i => i.bot === '组长').at(-1)!.prompt
+    expect(prompt).toContain('当前组员的能力卡片')
+    expect(prompt).toContain(`### 数据员(id: ${ids['数据员']})`)
+  })
+
+  it('工作组的限额只能调低;Bot Key 不能设置调试选项', async () => {
+    const tooHigh = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], limits: { maxTasksPerJob: 999 } } })
+    expect(tooHigh.statusCode).toBe(400)
+    const debug = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], debug: { stepMode: true } } })
+    expect(debug.statusCode).toBe(403)
+  })
+})
+
+describe('任务流程', () => {
+  it('只有组长:申请加入两个机器人(经审批),拆成有依赖的子任务,组员交付文件,组长汇总交付', async () => {
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, title: '周报', leadBotId: ids['组长'] } })).json() as TeamDetailDto
+    let leadTurn = 0
+    fake.scripts['组长'] = async (input, call) => {
+      leadTurn++
+      if (leadTurn === 1) {
+        const catalog = await call('team_bot_catalog', {})
+        expect(catalog.text).toContain('数据员')
+        expect(catalog.text).not.toContain('禁用员') // 不在 Bot Key 的范围内
+        for (const name of ['数据员', '文档员']) {
+          const r = await call('team_request_bot', { bot_id: ids[name], reason: `需要${name}` })
+          expect(r.text).toContain('已加入')
+        }
+        await call('job_start', { title: '周报', plan: '先统计再写报告' })
+        await call('task_create', { bot_id: ids['数据员'], title: '统计', instructions: '统计销量,写到 outputs/data.csv' })
+        const t2 = await call('task_create', { bot_id: ids['文档员'], title: '写报告', instructions: '根据 #1 写报告', depends_on: [1] })
+        expect(t2.isError).toBe(false)
+        return '已安排:数据员统计,文档员写报告'
+      }
+      if (input.includes('子任务 #2 已完成')) {
+        const r = await call('job_complete', { summary: '周报已完成', files: ['outputs/report.md'] })
+        expect(r.isError).toBe(false)
+        return '交付完毕'
+      }
+      return '收到,等待其他子任务'
+    }
+    fake.scripts['数据员'] = async (input, call, ctx) => {
+      if (!input.includes('【子任务')) return '我是数据员'
+      fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
+      fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/data.csv'), 'a,b\n1,2\n')
+      await call('task_complete', { result: '销量 100', files: ['outputs/data.csv'] })
+      return '完成'
+    }
+    fake.scripts['文档员'] = async (input, call, ctx) => {
+      // 依赖的子任务结果随输入一起交给它
+      expect(input).toContain('【依赖的子任务 #')
+      expect(input).toContain('销量 100')
+      fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 周报')
+      await call('task_complete', { result: '报告已写好', files: ['outputs/report.md'] })
+      return '完成'
+    }
+
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '帮我做本周周报' } })
+    // 两次申请都显示审批卡片,由成员批准
+    for (let i = 0; i < 2; i++) {
+      const pending = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingInteractions[0])
+      expect(pending.request.kind === 'approval' && pending.request.title).toContain('申请让')
+      const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
+      expect(r.statusCode).toBe(200)
+    }
+
+    const done = await waitFor(() => {
+      const j = app.teams.jobs.jobs(team.id)[0]
+      return j?.status === 'completed' && idle(team.id) ? j : null
+    })
+    expect(done.summary).toBe('周报已完成')
+    expect(done.files.map(f => f.path)).toEqual(['outputs/report.md'])
+    const { jobs, tasks } = await jobsOf(team.id)
+    expect(jobs[0]).toMatchObject({ status: 'completed', taskCount: 2 })
+    expect(jobs[0]!.leadRuns).toBeGreaterThanOrEqual(2)
+    expect(tasks.map(t => [t.no, t.status])).toEqual([[1, 'completed'], [2, 'completed']])
+    expect(tasks[0]!.files.map(f => f.path)).toEqual(['outputs/data.csv'])
+    expect(tasks[1]!.dependsOn).toEqual([1])
+
+    const msgs = messages(team.id)
+    expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'started')).toBe(true)
+    expect(msgs.filter(m => m.card?.kind === 'task' && m.card.event === 'assigned')).toHaveLength(2)
+    // 组员的结果发给组长(唤醒组长)
+    const result = msgs.find(m => m.card?.kind === 'task' && m.card.event === 'completed' && m.card.no === 1)!
+    expect(result.targets).toEqual([ids['组长']])
+    expect(result.files.map(f => f.path)).toEqual(['outputs/data.csv'])
+    expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'completed' && m.files.length === 1)).toBe(true)
+    expect(msgs.filter(m => m.sender.kind === 'system').some(m => m.text.includes('成员已批准'))).toBe(true)
+
+    // 用量按机器人、任务、子任务统计
+    const usage = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/usage`, headers: bk() })).json()
+    expect(usage.total.runs).toBeGreaterThanOrEqual(4)
+    expect(usage.jobs[0].runs).toBe(usage.total.runs)
+    expect(usage.tasks).toHaveLength(2)
+    expect(usage.bots.map((b: { botId: string }) => b.botId).sort()).toEqual([ids['组长'], ids['数据员'], ids['文档员']].sort())
+  })
+
+  it('同一时间只有一个任务;组员不在子任务中时 task_complete 失败', async () => {
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
+    let second: { text: string; isError: boolean } | undefined
+    fake.scripts['组长'] = async (_input, call) => {
+      await call('job_start', { title: 'A', plan: '' })
+      second = await call('job_start', { title: 'B', plan: '' })
+      return 'ok'
+    }
+    let direct: { text: string; isError: boolean } | undefined
+    fake.scripts['数据员'] = async (_input, call) => {
+      direct = await call('task_complete', { result: 'x' })
+      return '直接回复'
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
+    await waitFor(() => second)
+    expect(second).toMatchObject({ isError: true })
+    expect(second!.text).toContain('已有进行中的任务')
+    await waitFor(() => idle(team.id) && messages(team.id).some(m => m.sender.kind === 'bot' && m.text === 'ok'))
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '单项工作', mentions: [ids['数据员']] } })
+    await waitFor(() => direct)
+    expect(direct).toMatchObject({ isError: true })
+    // 成员取消任务
+    const job = app.teams.jobs.active(team.id)!
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(200)
+    expect(app.teams.jobs.job(job.id).status).toBe('cancelled')
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(409)
+  })
+})
+
+describe('限额', () => {
+  it('超出子任务数上限时任务暂停,成员选择继续后放宽一倍、组长被唤醒;也可以取消', async () => {
+    const team = (await server.inject({
+      method: 'POST', url: '/bot/v1/teams', headers: bk(),
+      payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], limits: { maxTasksPerJob: 1 } },
+    })).json() as TeamDetailDto
+    expect(team.limits.maxTasksPerJob).toBe(1)
+    const results: boolean[] = []
+    let turn = 0
+    fake.scripts['组长'] = async (_input, call) => {
+      turn++
+      if (turn === 1) {
+        await call('job_start', { title: '大任务', plan: '' })
+        results.push((await call('task_create', { bot_id: ids['数据员'], title: 't1', instructions: 'x' })).isError)
+        results.push((await call('task_create', { bot_id: ids['数据员'], title: 't2', instructions: 'x' })).isError)
+        return '分派中'
+      }
+      return `第 ${turn} 轮`
+    }
+    fake.scripts['数据员'] = async (_input, call) => {
+      await call('task_complete', { result: 'ok' })
+      return 'done'
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
+    const blocked = await waitFor(() => {
+      const j = app.teams.jobs.active(team.id)
+      return j?.status === 'blocked' && idle(team.id) ? j : null
+    })
+    expect(results).toEqual([false, true])
+    expect(blocked.blockedBy).toBe('maxTasksPerJob')
+    expect(messages(team.id).some(m => m.card?.kind === 'limit')).toBe(true)
+    // 暂停期间组员的结果不会唤醒组长
+    const leadTurns = turn
+    await new Promise(r => setTimeout(r, 100))
+    expect(turn).toBe(leadTurns)
+
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(200)
+    await waitFor(() => turn > leadTurns && idle(team.id))
+    const job = app.teams.jobs.job(blocked.id)
+    expect(job).toMatchObject({ status: 'running', blockedBy: null })
+    expect(job.limits.maxTasksPerJob).toBe(2)
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(409)
+  })
+
+  it('组长执行轮数达到上限时不再唤醒组长', async () => {
+    const team = (await server.inject({
+      method: 'POST', url: '/bot/v1/teams', headers: bk(),
+      payload: { human, leadBotId: ids['组长'], limits: { maxLeadRunsPerJob: 1 } },
+    })).json() as TeamDetailDto
+    let turns = 0
+    fake.scripts['组长'] = async (_input, call) => {
+      turns++
+      if (turns === 1) await call('job_start', { title: 'x', plan: '' })
+      return 'ok'
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '一' } })
+    await waitFor(() => turns === 1 && idle(team.id))
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '二' } })
+    const job = await waitFor(() => app.teams.jobs.active(team.id)?.status === 'blocked' && app.teams.jobs.active(team.id))
+    expect(job.blockedBy).toBe('maxLeadRunsPerJob')
+    expect(turns).toBe(1)
+  })
+})
+
+describe('Bot Playground 的调试功能', () => {
+  it('全部组员设为替身时,由管理员填写结果完成一项任务', async () => {
+    const created = await server.inject({
+      method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
+      payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], debug: { stubBotIds: [ids['数据员']] } },
+    })
+    expect(created.statusCode).toBe(201)
+    const team = created.json() as TeamDetailDto
+    fake.scripts['组长'] = async (input, call) => {
+      if (input.includes('子任务 #')) {
+        expect(input).not.toContain('替身')
+        await call('job_complete', { summary: `汇总:${input.includes('算出 42') ? '42' : '?'}` })
+        return '完成'
+      }
+      await call('job_start', { title: '替身测试', plan: '' })
+      await call('task_create', { bot_id: ids['数据员'], title: '算数', instructions: '算 6×7' })
+      return '已分派'
+    }
+    const before = fake.inputs.filter(i => i.bot === '数据员').length
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '开始' } })
+    const tasks = await waitFor(() => {
+      const t = app.teams.jobs.tasks(team.id, { status: ['stub'] })
+      return t.length ? t : null
+    })
+    expect(messages(team.id).some(m => m.card?.kind === 'stub')).toBe(true)
+    // Bot Key 不能填写替身结果
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${tasks[0]!.id}/stub`, headers: bk(), payload: { outcome: 'completed', result: 'x' } })).statusCode).toBe(404)
+    const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${tasks[0]!.id}/stub`, cookies: adminCookie, payload: { outcome: 'completed', result: '算出 42' } })
+    expect(r.statusCode).toBe(200)
+    const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
+    expect(job.summary).toBe('汇总:42')
+    // 替身的子任务没有真正执行
+    expect(fake.inputs.filter(i => i.bot === '数据员').length).toBe(before)
+  })
+
+  it('逐步执行:每次唤醒前停下,点"继续"后才执行;关闭后放行', async () => {
+    const team = (await server.inject({
+      method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
+      payload: { human, workerBotIds: [ids['数据员']], debug: { stepMode: true } },
+    })).json() as TeamDetailDto
+    fake.scripts['数据员'] = async input => `做了:${input.split('\n').at(-1)}`
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第一步' } })
+    const step = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
+    expect(step.reason).toContain('1 条新消息')
+    await new Promise(r => setTimeout(r, 50))
+    expect(messages(team.id).some(m => m.sender.kind === 'bot')).toBe(false)
+    expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/steps/${step.id}/continue`, cookies: adminCookie })).statusCode).toBe(200)
+    await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
+    expect(app.teams.detail(app.teams.getRow(team.id)).pendingSteps).toEqual([])
+
+    await waitFor(() => idle(team.id))
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第二步' } })
+    await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
+    const patched = await server.inject({ method: 'PATCH', url: `/bot/v1/teams/${team.id}`, cookies: adminCookie, payload: { debug: { stepMode: false } } })
+    expect(patched.json().debug.stepMode).toBe(false)
+    await waitFor(() => messages(team.id).filter(m => m.sender.kind === 'bot').length === 2)
+  })
+})

+ 1 - 1
docs/README.md

@@ -23,7 +23,7 @@ docs/
 │  ├─ 10-deployment.md        部署与运维
 │  ├─ 11-roadmap.md           里程碑与待决事项
 │  ├─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
-│  └─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期已实施)
+│  └─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期、二期已实施)
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```

+ 2 - 2
docs/design/11-roadmap.md

@@ -30,9 +30,9 @@
 - 工作区自动清理、回归用例批量重放
 - 网络加强(nftables 按 cgroup 拦截局域网)
 
-### M4:Bot 工作组(一期 ✅ 2026-10-26,见 13、ADR-0023/0024)
+### M4:Bot 工作组(一期 ✅ 2026-10-26,二期 ✅ 2026-10-27,见 13、ADR-0023/0024)
 - ✅ 一期:机器人定义、Bot Key 与 `/bot/v1`、工作组与消息、@ 组员的单项工作、审批卡片;`BotClient`、`<TeamChat>`;Bot Playground 基本形态
-- 二期:组长、能力卡片、任务与子任务、限额与暂停、组长申请加入机器人;Playground 的替身组员、逐步执行、任务 / 输入 / 用量页签
+- ✅ 二期:组长、能力卡片、任务与子任务、限额与暂停、组长申请加入机器人;Playground 的替身组员、逐步执行、任务 / 输入 / 用量页签
 - 三期:`BotClient`、`<TeamChat>` 随 SDK 发布与接入示例;复制到 Playground、回归用例
 
 ### 以后(按需)

+ 23 - 1
docs/design/13-bot-teams.md

@@ -1,6 +1,6 @@
 # 13 Bot 工作组
 
-> 状态:**一期已实施(2026-10-26)**,二期、三期待做;实施中的调整见 §15。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
+> 状态:**一期(2026-10-26)、二期(2026-10-27)已实施**,三期待做;实施中的调整见 §15、§16。决策见 ADR-0023(工作组协作模型)、ADR-0024(Bot API 与独立的 Bot Key)。
 
 ## 1. 背景与目标
 
@@ -376,3 +376,25 @@ SSE 事件:
 - 每轮开始前先把共享目录同步到该机器人会话的文件索引,本轮结束时只有本轮改动的 `outputs/` 文件挂到它的回复上。两个机器人同时改动文件时仍可能互相计入,二期再细化。
 - 服务重启时未完成的工作被标记为中断;没有交给机器人的消息不会自动重新处理,成员再次发消息时一并交给它。
 - 已用真实 Claude(haiku)验收:两个组员并行处理、其中一个触发审批并在工作组中批准、交付文件以卡片显示、续接会话保留记忆;Bot Key 访问 `/v1` 返回 401。
+
+## 16. 二期实施记录(2026-10-27)
+
+已实现:能力卡片(`GET /bots` 返回,组长的提示词中注入组员的卡片)、任务与子任务(§5.2,含依赖与并行)、§5.4 的全部协作工具、组长申请加入机器人(审批卡片)、限额与"继续 / 取消"、工作组同时执行的 Run 数;Bot Playground 的任务、输入、用量页签,替身组员,逐步执行,能力卡片预览;机器人页面"测试"组长时新建"组长 + 全部可用组员(替身)"的工作组。系统设置增加"工作组限额"。
+
+与前文设计相比的调整:
+
+| 设计 | 实施 | 原因 |
+|---|---|---|
+| Run 令牌带上 teamId、botId、role、jobId、taskId | 令牌只带会话;协作工具按会话所属机器人的角色固定提供(组长一套、组员一套),调用时再按当前状态检查 | Codex 会话沿用第一次连接时的工具列表,按 Run 变化的工具会失效;按会话查状态也不需要改令牌 |
+| `task_cancel(taskId)` | `task_cancel(task_no)`,子任务在消息中以 `#编号` 出现 | 模型更容易引用编号 |
+| `job_complete` 之后组长的最终回复照常回帖 | 同左;提示词要求之后的回复简短,组长本轮发过任务卡片时不再提示"本轮没有回复" | 避免与任务总结重复 |
+| 子任务的分派消息唤醒组员 | 分派消息不唤醒任何人(targets 为空),组员由子任务队列唤醒;结果消息的 targets 是组长 | 组员的单项工作(@ 它的消息)与子任务分开:先做排队的子任务,单项工作在之后处理 |
+| 后台可改的限额 | `settings.teamLimits`;工作组创建或修改时只能调低 | 与设计一致,写明存放位置 |
+
+实现要点:
+- 子任务的状态:waiting(等依赖)→ queued → running → completed / failed / cancelled;替身的子任务为 stub,由管理员填写结果。依赖的子任务失败时,后续子任务保持 waiting,由组长决定取消或重新分派。
+- 组员在子任务这一轮没有调用 `task_complete` / `task_fail` 时,以最后的回复作为结果按完成处理;Run 失败或被中断按失败处理;工作组暂停导致的中断会在恢复后重新执行。
+- 交给组长的输入中不标注替身(替身用来测试组长,标注后组长会区别对待);逐步执行与替身的提示卡片不交给机器人。
+- 任务暂停(超出限额)期间不唤醒组长,组员的结果先记录,成员选择继续后组长一并看到。
+- 同一工作组的文件扫描依次进行(并发扫描会把同一个新文件登记两次)。
+- 已用真实 Claude(haiku)验收:组长调用 `job_start`、`task_create`(含依赖)分派给两个替身组员,管理员填写结果后组长被依次唤醒,核对文件时触发的审批在工作组中处理,最后结束任务。

+ 166 - 0
packages/chat-ui/src/components/TeamCard.vue

@@ -0,0 +1,166 @@
+<script setup lang="ts">
+// 工作组消息上的卡片(docs/design/13-bot-teams.md §5、§12.2):任务、子任务的状态标签,限额、替身、逐步执行的操作。
+// 状态取 jobs / tasks / steps 的最新值,历史消息上的卡片也会随之更新
+import { computed, ref } from 'vue'
+import type { JobStatus, TaskStatus, TeamCard } from '@agent-server/shared'
+import type { TeamSession } from '../team-store'
+
+const props = defineProps<{ card: TeamCard; session: TeamSession; readonly?: boolean }>()
+
+const state = computed(() => props.session.state)
+const job = computed(() => ('jobId' in props.card ? state.value.jobs[props.card.jobId] : undefined))
+const task = computed(() => ('taskId' in props.card ? state.value.tasks[props.card.taskId] : undefined))
+const botName = (id: string) => state.value.team?.bots.find(b => b.id === id)?.name ?? '组员'
+
+const JOB_STATUS: Record<JobStatus, string> = { running: '进行中', blocked: '已暂停(超出限额)', completed: '已完成', failed: '失败', cancelled: '已取消' }
+const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' }
+const tone = (s: string) => (s === 'completed' ? 'ok' : s === 'failed' ? 'bad' : s === 'cancelled' ? 'muted' : s === 'blocked' || s === 'stub' ? 'warn' : 'busy')
+
+const busy = ref(false)
+const error = ref('')
+async function act(fn: () => Promise<unknown>) {
+  busy.value = true
+  error.value = ''
+  try {
+    await fn()
+  } catch (err) {
+    error.value = (err as Error).message
+  } finally {
+    busy.value = false
+  }
+}
+
+const jobId = () => ('jobId' in props.card ? props.card.jobId : '')
+const taskId = () => ('taskId' in props.card ? props.card.taskId : '')
+const continueJob = () => act(() => props.session.continueJob(jobId()))
+const cancelJob = () => act(() => props.session.cancelJob(jobId()))
+const stubDone = (outcome: 'completed' | 'failed') => act(() => props.session.stubResult(taskId(), { outcome, result: stubText.value.trim() }))
+const continueStep = () => act(() => props.session.continueStep(props.card.kind === 'step' ? props.card.stepId : ''))
+
+// 替身结果
+const stubText = ref('')
+const stubPending = computed(() => props.card.kind === 'stub' && task.value?.status === 'stub')
+const stepPending = computed(() => props.card.kind === 'step' && state.value.steps.some(s => s.id === (props.card as { stepId: string }).stepId))
+const limitPending = computed(() => props.card.kind === 'limit' && job.value?.status === 'blocked')
+</script>
+
+<template>
+  <!-- 任务:开始 / 结束 -->
+  <div v-if="card.kind === 'job'" class="chip-line">
+    <span class="chip" :class="tone(job?.status ?? card.event)">
+      <span class="codicon" :class="card.event === 'started' ? 'codicon-tasklist' : card.event === 'completed' ? 'codicon-pass' : 'codicon-circle-slash'" />
+      {{ card.event === 'started' ? '开始任务' : '任务结束' }}{{ job ? ` #${job.no}` : '' }}
+    </span>
+    <span v-if="job && card.event === 'started'" class="state">{{ JOB_STATUS[job.status] }}<template v-if="job.taskCount"> · {{ job.taskCount }} 个子任务</template><template v-if="job.costUsd"> · ${{ job.costUsd.toFixed(3) }}</template></span>
+  </div>
+
+  <!-- 子任务:分派 / 结果 -->
+  <div v-else-if="card.kind === 'task'" class="chip-line">
+    <span class="chip" :class="tone(card.event === 'assigned' ? task?.status ?? 'queued' : card.event)">
+      <span class="codicon" :class="card.event === 'assigned' ? 'codicon-arrow-right' : card.event === 'completed' ? 'codicon-check' : 'codicon-close'" />
+      子任务 #{{ card.no }}<template v-if="card.event !== 'assigned'"> {{ TASK_STATUS[card.event] }}</template>
+    </span>
+    <span v-if="card.event === 'assigned' && task" class="state">{{ botName(task.botId) }} · {{ TASK_STATUS[task.status] }}</span>
+    <span v-if="card.stub" class="chip warn">替身</span>
+  </div>
+
+  <!-- 超出限额 -->
+  <div v-else-if="card.kind === 'limit'" class="action-card">
+    <slot />
+    <div v-if="limitPending && !readonly" class="actions">
+      <button type="button" class="btn primary" :disabled="busy" @click="continueJob">继续(放宽一倍)</button>
+      <button type="button" class="btn" :disabled="busy" @click="cancelJob">取消任务</button>
+    </div>
+    <div v-else-if="job" class="state">{{ JOB_STATUS[job.status] }}</div>
+  </div>
+
+  <!-- 替身组员:管理员填写结果 -->
+  <div v-else-if="card.kind === 'stub'" class="action-card">
+    <slot />
+    <template v-if="stubPending && !readonly">
+      <div v-if="task" class="instructions">{{ task.instructions }}</div>
+      <textarea v-model="stubText" class="stub-input" rows="3" placeholder="填写这个子任务的结果,组长会收到它" />
+      <div class="actions">
+        <button type="button" class="btn primary" :disabled="busy || !stubText.trim()" @click="stubDone('completed')">完成</button>
+        <button type="button" class="btn" :disabled="busy || !stubText.trim()" @click="stubDone('failed')">标记失败</button>
+      </div>
+    </template>
+    <div v-else-if="task" class="state">{{ TASK_STATUS[task.status] }}</div>
+  </div>
+
+  <!-- 逐步执行 -->
+  <div v-else-if="card.kind === 'step'" class="action-card step">
+    <slot />
+    <button v-if="stepPending && !readonly" type="button" class="btn primary" :disabled="busy" @click="continueStep">
+      <span class="codicon codicon-debug-continue" />继续
+    </button>
+    <span v-else class="state">已执行</span>
+  </div>
+  <div v-if="error" class="err">{{ error }}</div>
+</template>
+
+<style scoped>
+.chip-line { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 2px 0 4px; font-size: 12px; }
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 0 8px;
+  border-radius: 999px;
+  line-height: 20px;
+  background: color-mix(in srgb, var(--app-primary-foreground) 10%, transparent);
+  font-weight: 600;
+}
+.chip.ok { background: color-mix(in srgb, var(--app-success, #3fb950) 20%, transparent); }
+.chip.bad { background: color-mix(in srgb, var(--app-error-foreground) 18%, transparent); }
+.chip.warn { background: color-mix(in srgb, var(--app-warning) 22%, transparent); }
+.chip.busy { background: color-mix(in srgb, var(--app-link-foreground) 18%, transparent); }
+.chip.muted { opacity: 0.7; }
+.state { color: var(--app-secondary-foreground); font-size: 12px; }
+
+.action-card {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 6px;
+  max-width: 560px;
+  margin: 0 auto;
+  padding: 8px 12px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-medium, 6px);
+  background: var(--app-input-secondary-background);
+  text-align: left;
+  font-size: 12.5px;
+  color: var(--app-primary-foreground);
+}
+.action-card.step { flex-direction: row; align-items: center; justify-content: space-between; }
+.instructions { width: 100%; max-height: 120px; overflow-y: auto; white-space: pre-wrap; color: var(--app-secondary-foreground); }
+.stub-input {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 4px 6px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 4px;
+  background: var(--app-input-background);
+  color: inherit;
+  font: inherit;
+  resize: vertical;
+}
+.actions { display: flex; gap: 6px; }
+.btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 2px 10px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 4px;
+  background: transparent;
+  color: inherit;
+  cursor: pointer;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.btn.primary { border-color: transparent; background: var(--app-button-background, #0e639c); color: var(--app-button-foreground, #fff); }
+.btn:disabled { opacity: 0.5; cursor: default; }
+.err { margin-top: 4px; font-size: 12px; color: var(--app-error-foreground); text-align: center; }
+</style>

+ 52 - 1
packages/chat-ui/src/components/TeamChat.vue

@@ -9,6 +9,7 @@ import Composer from './Composer.vue'
 import FileCard from './FileCard.vue'
 import InteractionCard from './InteractionCard.vue'
 import MarkdownView from './MarkdownView.vue'
+import TeamCard from './TeamCard.vue'
 
 type OpenedFile = { fileId: string; path: string; mime: string; size: number }
 
@@ -35,6 +36,21 @@ const busy = computed(() => bots.value.filter(b => b.status !== 'idle'))
 const closed = computed(() => team.value?.status === 'closed')
 const botById = (id: string) => bots.value.find(b => b.id === id)
 
+/** 进行中的任务(显示在输入框上方) */
+const activeJob = computed(() => (team.value?.activeJobId ? state.value.jobs[team.value.activeJobId] : undefined))
+const jobProgress = computed(() => {
+  const job = activeJob.value
+  if (!job) return ''
+  const list = Object.values(state.value.tasks).filter(t => t.jobId === job.id)
+  return list.length ? `子任务 ${list.filter(t => t.status === 'completed').length}/${list.length} 完成` : '还没有子任务'
+})
+/** 需要操作的系统卡片:限额、替身、逐步执行 */
+const actionCard = (m: TeamMessage) => m.card && ['limit', 'stub', 'step'].includes(m.card.kind)
+/** 分派子任务的消息:第一行是"#编号 → 组员:标题",说明默认折叠 */
+const isAssign = (m: TeamMessage) => m.card?.kind === 'task' && m.card.event === 'assigned'
+const firstLine = (t: string) => (t.split('\n')[0] ?? '').replace(/^#\d+ → /, '')
+const restLines = (t: string) => t.split('\n').slice(1).join('\n').trim()
+
 const STATUS_TEXT: Record<TeamBotDto['status'], string> = { idle: '空闲', queued: '排队中', working: '工作中', waiting_input: '等待你的确认' }
 
 // @ 选择:点选机器人,或在文字中输入"@名称"
@@ -132,13 +148,25 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
                     <span class="codicon codicon-list-tree" />过程
                   </button>
                 </div>
-                <MarkdownView v-if="item.message.text" :source="item.message.text" />
+                <TeamCard v-if="item.message.card" :card="item.message.card" :session="session" :readonly="readonly" />
+                <details v-if="isAssign(item.message)" class="assign">
+                  <summary>{{ firstLine(item.message.text) }}</summary>
+                  <MarkdownView :source="restLines(item.message.text)" />
+                </details>
+                <MarkdownView v-else-if="item.message.text" :source="item.message.text" />
                 <div v-if="item.message.files.length" class="files">
                   <FileCard v-for="f in item.message.files" :key="f.fileId" :file-id="f.fileId" :path="f.path" :size="f.size" :mime="f.mime" @open="openFile" />
                 </div>
               </div>
             </div>
 
+            <!-- 需要操作的系统卡片 -->
+            <div v-else-if="item.kind === 'message' && actionCard(item.message)" class="row">
+              <TeamCard :card="item.message.card!" :session="session" :readonly="readonly">
+                <span :class="['card-text', item.message.level]">{{ item.message.text }}</span>
+              </TeamCard>
+            </div>
+
             <!-- 系统消息 -->
             <div v-else-if="item.kind === 'message'" class="row system" :class="item.message.level">
               <span>{{ item.message.text }}</span>
@@ -172,6 +200,12 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
       <span class="codicon codicon-arrow-down" />回到最新
     </button>
     <div v-if="!readonly" class="composer-wrap">
+      <div v-if="activeJob" class="job-bar">
+        <span class="codicon codicon-tasklist" />
+        <span class="job-title">任务 #{{ activeJob.no }} {{ activeJob.title }}</span>
+        <span class="job-meta">{{ activeJob.status === 'blocked' ? '已暂停(超出限额)' : jobProgress }}</span>
+        <button type="button" class="link" title="取消任务:还没有结束的子任务一并取消" @click="session.cancelJob(activeJob.id)">取消任务</button>
+      </div>
       <div v-if="state.error" class="error">{{ state.error }}</div>
       <div v-if="state.connection === 'reconnecting'" class="conn">连接中断,正在重连…</div>
       <div v-if="team?.status === 'paused'" class="conn">工作组已暂停:消息会先记录,恢复后再处理</div>
@@ -246,6 +280,23 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 }
 .link:hover { color: var(--app-primary-foreground); }
 
+.assign summary { cursor: pointer; font-weight: 600; }
+.assign summary::marker { color: var(--app-secondary-foreground); }
+.card-text.warning { color: var(--app-warning); }
+.card-text.error { color: var(--app-error-foreground); }
+.job-bar {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 6px;
+  padding: 3px 8px;
+  border: 1px solid var(--app-input-border);
+  border-radius: var(--corner-radius-medium);
+  font-size: 12px;
+}
+.job-title { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.job-meta { flex: 1; color: var(--app-secondary-foreground); white-space: nowrap; }
+
 .working { display: flex; align-items: center; gap: 6px; padding: 4px 0 0 34px; color: var(--app-secondary-foreground); font-size: 12px; }
 .composer-wrap { max-width: 760px; width: 100%; margin: 0 auto; padding: 0 16px 16px; }
 .error { margin-bottom: 6px; font-size: 12px; color: var(--app-error-foreground); }

+ 1 - 0
packages/chat-ui/src/index.ts

@@ -8,6 +8,7 @@ export { default as FileCard } from './components/FileCard.vue'
 export { default as InteractionCard } from './components/InteractionCard.vue'
 export { default as MarkdownView } from './components/MarkdownView.vue'
 export { default as Timeline } from './components/Timeline.vue'
+export { default as TeamCard } from './components/TeamCard.vue'
 export { default as TeamChat } from './components/TeamChat.vue'
 export { default as ToolCall } from './components/ToolCall.vue'
 export { downloadFile, provideClient, useClient, useFileObjectUrl, type FileClient } from './context'

+ 21 - 2
packages/chat-ui/src/team-store.ts

@@ -2,7 +2,7 @@
 import { reactive } from 'vue'
 import type { BotClient } from '@agent-server/client'
 import type {
-  FileDto, InteractionRequest, InteractionResolution, InteractionResponse, TeamDetailDto, TeamEvent, TeamMessage,
+  FileDto, InteractionRequest, InteractionResolution, InteractionResponse, JobDto, StubTaskBody, TaskDto, TeamDetailDto, TeamEvent, TeamMessage, TeamStepDto,
 } from '@agent-server/shared'
 
 export type TeamItem =
@@ -16,6 +16,11 @@ export interface TeamState {
   team: TeamDetailDto | null
   items: TeamItem[]
   lastSeq: number
+  /** 任务与子任务的最新状态(消息中的卡片据此显示) */
+  jobs: Record<string, JobDto>
+  tasks: Record<string, TaskDto>
+  /** 逐步执行时等待继续的步骤 */
+  steps: TeamStepDto[]
   /** 共享目录有文件变化时递增,文件列表据此刷新 */
   filesVersion: number
   connection: 'connecting' | 'open' | 'reconnecting' | 'closed'
@@ -52,12 +57,21 @@ export function applyTeamEvent(s: TeamState, e: TeamEvent) {
     case 'file.changed':
       s.filesVersion++
       break
+    case 'job.updated':
+      s.jobs[e.payload.id] = e.payload
+      break
+    case 'task.updated':
+      s.tasks[e.payload.id] = e.payload
+      break
+    case 'step.updated':
+      s.steps = e.payload.steps
+      break
   }
 }
 
 /** 连接一个工作组:加载详情与历史 → 订阅实时事件 → 提供操作方法 */
 export function useTeamSession(client: BotClient, teamId: string) {
-  const state = reactive<TeamState>({ team: null, items: [], lastSeq: 0, filesVersion: 0, connection: 'connecting', loading: true, error: null }) as TeamState
+  const state = reactive<TeamState>({ team: null, items: [], lastSeq: 0, jobs: {}, tasks: {}, steps: [], filesVersion: 0, connection: 'connecting', loading: true, error: null }) as TeamState
   let unsubscribe: (() => void) | null = null
   let disposed = false
 
@@ -75,6 +89,7 @@ export function useTeamSession(client: BotClient, teamId: string) {
       // 历史中未处理的交互:以详情中的待处理列表为准(服务重启后旧交互已取消)
       const pending = new Set(state.team!.pendingInteractions.map(p => p.request.interactionId))
       for (const i of state.items) if (i.kind === 'interaction' && !i.resolution && !pending.has(i.id)) i.resolution = 'cancelled'
+      state.steps = state.team!.pendingSteps
     } catch (err) {
       state.error = (err as Error).message
     } finally {
@@ -105,6 +120,10 @@ export function useTeamSession(client: BotClient, teamId: string) {
     upload: (files: File[]) => client.teams.upload(teamId, files),
     reply: (interactionId: string, body: InteractionResponse) => client.teams.reply(teamId, interactionId, body),
     interrupt: (botId: string) => client.teams.interruptBot(teamId, botId),
+    cancelJob: (jobId: string) => client.teams.cancelJob(teamId, jobId),
+    continueJob: (jobId: string) => client.teams.continueJob(teamId, jobId),
+    stubResult: (taskId: string, body: StubTaskBody) => client.teams.stubResult(teamId, taskId, body),
+    continueStep: (stepId: string) => client.teams.continueStep(teamId, stepId),
     dispose() {
       disposed = true
       unsubscribe?.()

+ 17 - 4
packages/client/src/bot.ts

@@ -1,30 +1,35 @@
 // Bot API 客户端(/bot/v1,docs/design/13-bot-teams.md §9)。使用 Bot Key 或 Bot 浏览器令牌,与 AgentClient 的 Key 互不通用
 import type {
-  BotSummary,
+  BotCard,
   BrowserTokenDto,
   CreateTeamBody,
   FileDto,
   InteractionResponse,
+  PatchTeamBody,
   SendTeamMessageBody,
+  StubTaskBody,
   TeamDetailDto,
   TeamDto,
   TeamEvent,
+  TeamJobsDto,
   TeamMessage,
   TeamStatus,
+  TeamUsageDto,
 } from '@agent-server/shared'
 import { AgentApiError, HttpClient, query, type StreamHandlers } from './http'
 
 export class BotClient extends HttpClient {
   /** 平台后端调用(需要 Bot Key):签发交给浏览器使用的短期令牌 */
   issueBrowserToken = (ttlSeconds?: number) => this.request<BrowserTokenDto>('POST', '/bot/v1/browser-tokens', ttlSeconds === undefined ? {} : { ttlSeconds })
-  /** 这个 Key 可以使用的机器人 */
-  bots = () => this.request<BotSummary[]>('GET', '/bot/v1/bots')
+  /** 这个 Key 可以使用的机器人及其能力卡片 */
+  bots = () => this.request<BotCard[]>('GET', '/bot/v1/bots')
 
   teams = {
     create: (body: CreateTeamBody) => this.request<TeamDetailDto>('POST', '/bot/v1/teams', body),
     list: (q: { scope?: 'all'; status?: TeamStatus; limit?: number } = {}) => this.request<TeamDto[]>('GET', `/bot/v1/teams${query(q)}`),
     get: (id: string) => this.request<TeamDetailDto>('GET', `/bot/v1/teams/${id}`),
-    update: (id: string, body: { title: string }) => this.request<TeamDetailDto>('PATCH', `/bot/v1/teams/${id}`, body),
+    /** 标题、限额;debug 只有后台可以设置 */
+    update: (id: string, body: PatchTeamBody) => this.request<TeamDetailDto>('PATCH', `/bot/v1/teams/${id}`, body),
     remove: (id: string) => this.request<void>('DELETE', `/bot/v1/teams/${id}`),
     pause: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/pause`),
     resume: (id: string) => this.request<TeamDetailDto>('POST', `/bot/v1/teams/${id}/resume`),
@@ -44,6 +49,14 @@ export class BotClient extends HttpClient {
     },
     reply: (id: string, interactionId: string, body: InteractionResponse) =>
       this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/interactions/${interactionId}`, body),
+    jobs: (id: string) => this.request<TeamJobsDto>('GET', `/bot/v1/teams/${id}/jobs`),
+    cancelJob: (id: string, jobId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/jobs/${jobId}/cancel`),
+    continueJob: (id: string, jobId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/jobs/${jobId}/continue`),
+    usage: (id: string) => this.request<TeamUsageDto>('GET', `/bot/v1/teams/${id}/usage`),
+    /** 仅后台:替身组员的子任务结果 */
+    stubResult: (id: string, taskId: string, body: StubTaskBody) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/tasks/${taskId}/stub`, body),
+    /** 仅后台:逐步执行时放行一步 */
+    continueStep: (id: string, stepId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/steps/${stepId}/continue`),
   }
 
   /** 文件下载地址(同源 Cookie 鉴权时可直接用于 <img>/<a>) */

+ 3 - 0
packages/shared/src/settings.ts

@@ -1,4 +1,5 @@
 // 后台可设定的全局配置(docs/design/05-data-model.md §3)
+import type { TeamLimits } from './teams'
 
 export interface AppSettings {
   paths: {
@@ -27,6 +28,8 @@ export interface AppSettings {
     workspaceMaxMB: number
   }
   retention: { workspaceDays: number }
+  /** 工作组的默认限额(创建工作组时可以调低,docs/design/13-bot-teams.md §7) */
+  teamLimits: TeamLimits
   providers: {
     claude: { enabled: boolean; cliPath: string; defaultModel: string; models: string[] }
     codex: { enabled: boolean; cliPath: string; defaultModel: string; models: string[]; configOverrides: string[] }

+ 153 - 2
packages/shared/src/teams.ts

@@ -1,5 +1,5 @@
 // Bot 工作组(docs/design/13-bot-teams.md):机器人、Bot Key、工作组与工作组事件
-import type { FileDto } from './api'
+import type { FileDto, PermissionMode } from './api'
 import type { InteractionRequest, InteractionResolution, ProviderId } from './events'
 
 /** 组长(lead)负责拆分和分派任务;组员(worker)执行具体工作 */
@@ -32,6 +32,17 @@ export interface BotSummary {
   model: string | null
 }
 
+/** 能力卡片:组长据此了解组员会什么(由服务端根据机器人配置生成,docs/design/13-bot-teams.md §5.1) */
+export interface BotCard extends BotSummary {
+  /** Profile 中的技能(name + SKILL.md 中的 description) */
+  skills: { name: string; description: string }[]
+  /** 第三方 MCP 服务(内置工具不列出) */
+  mcpServers: { name: string; title: string; description: string }[]
+  /** 可访问的资料库名称 */
+  knowledge: string[]
+  permissionMode: PermissionMode | null
+}
+
 export interface BotKeyDto {
   id: string
   name: string
@@ -59,6 +70,39 @@ export interface TeamBotDto extends BotSummary {
   joinedAt: string
 }
 
+/** 每个任务的限额(docs/design/13-bot-teams.md §7);超出后任务暂停,由成员选择继续或取消 */
+export interface TeamLimits {
+  /** 每个任务的子任务数 */
+  maxTasksPerJob: number
+  /** 每个任务中组长的执行轮数 */
+  maxLeadRunsPerJob: number
+  /** 每个任务的成本上限(美元),null 表示不限 */
+  maxCostUsdPerJob: number | null
+  /** 每个任务的时长上限(分钟) */
+  maxMinutesPerJob: number
+  /** 工作组同时执行的 Run 数 */
+  maxConcurrentRuns: number
+}
+
+export type TeamLimitKey = Exclude<keyof TeamLimits, 'maxConcurrentRuns'>
+
+/** 调试设置:只有来源为 admin 的工作组(Bot Playground)可以设置 */
+export interface TeamDebug {
+  /** 逐步执行:每次唤醒机器人之前先停下,由管理员点"继续" */
+  stepMode: boolean
+  /** 替身组员:分派给它们的子任务不执行,由管理员手动填写结果 */
+  stubBotIds: string[]
+}
+
+/** 逐步执行模式下等待"继续"的一步 */
+export interface TeamStepDto {
+  id: string
+  botId: string
+  /** 例如"子任务 #2:清洗数据"或"处理 2 条新消息" */
+  reason: string
+  createdAt: string
+}
+
 export interface TeamDto {
   id: string
   title: string
@@ -67,6 +111,10 @@ export interface TeamDto {
   /** Bot Key id 或 'admin' */
   source: string
   bots: TeamBotDto[]
+  limits: TeamLimits
+  debug: TeamDebug
+  /** 进行中(含因限额暂停)的任务 */
+  activeJobId: string | null
   lastSeq: number
   createdAt: string
   updatedAt: string
@@ -80,6 +128,7 @@ export interface TeamInteractionDto {
 
 export interface TeamDetailDto extends TeamDto {
   pendingInteractions: TeamInteractionDto[]
+  pendingSteps: TeamStepDto[]
 }
 
 export interface CreateTeamBody {
@@ -87,6 +136,96 @@ export interface CreateTeamBody {
   human: TeamHuman
   leadBotId?: string
   workerBotIds?: string[]
+  /** 只能调低默认限额 */
+  limits?: Partial<TeamLimits>
+  /** 仅 admin */
+  debug?: Partial<TeamDebug>
+}
+
+export interface PatchTeamBody {
+  title?: string
+  limits?: Partial<TeamLimits>
+  /** 仅 admin */
+  debug?: Partial<TeamDebug>
+}
+
+// ---- 任务与子任务(docs/design/13-bot-teams.md §5.2)----
+
+/** blocked:超出限额,等待成员选择继续或取消 */
+export type JobStatus = 'running' | 'blocked' | 'completed' | 'failed' | 'cancelled'
+
+export interface JobDto {
+  id: string
+  teamId: string
+  /** 工作组内的序号,从 1 开始 */
+  no: number
+  title: string
+  plan: string
+  status: JobStatus
+  /** 完成时的总结或失败、取消的原因 */
+  summary: string | null
+  files: TeamFileRef[]
+  /** 因哪项限额暂停 */
+  blockedBy: TeamLimitKey | null
+  /** 已放宽的限额(成员选择"继续"后加倍) */
+  limits: Record<TeamLimitKey, number | null>
+  leadRuns: number
+  taskCount: number
+  costUsd: number
+  createdAt: string
+  endedAt: string | null
+}
+
+/** waiting:等待依赖的子任务完成;stub:分派给替身,等待管理员填写结果 */
+export type TaskStatus = 'waiting' | 'queued' | 'running' | 'stub' | 'completed' | 'failed' | 'cancelled'
+
+export interface TaskDto {
+  id: string
+  jobId: string
+  teamId: string
+  /** 工作组内的序号,从 1 开始 */
+  no: number
+  botId: string
+  title: string
+  instructions: string
+  /** 依赖的子任务序号 */
+  dependsOn: number[]
+  status: TaskStatus
+  /** 结果或失败原因 */
+  result: string | null
+  files: TeamFileRef[]
+  runId: string | null
+  sessionId: string | null
+  costUsd: number
+  createdAt: string
+  startedAt: string | null
+  endedAt: string | null
+}
+
+export interface TeamJobsDto {
+  jobs: JobDto[]
+  tasks: TaskDto[]
+}
+
+/** 替身组员的子任务结果(仅 admin) */
+export interface StubTaskBody {
+  outcome: 'completed' | 'failed'
+  result: string
+  fileIds?: string[]
+}
+
+export interface TeamUsageRow {
+  runs: number
+  inputTokens: number
+  outputTokens: number
+  costUsd: number
+}
+
+export interface TeamUsageDto {
+  total: TeamUsageRow
+  bots: (TeamUsageRow & { botId: string })[]
+  jobs: (TeamUsageRow & { jobId: string })[]
+  tasks: (TeamUsageRow & { taskId: string })[]
 }
 
 export interface SendTeamMessageBody {
@@ -109,6 +248,14 @@ export interface TeamFileRef {
   size: number
 }
 
+/** 消息上附带的卡片:任务、子任务、限额、替身、逐步执行(状态以 jobs / tasks / steps 的最新值为准) */
+export type TeamCard =
+  | { kind: 'job'; jobId: string; event: 'started' | 'completed' | 'failed' | 'cancelled' }
+  | { kind: 'task'; taskId: string; no: number; event: 'assigned' | 'completed' | 'failed' | 'cancelled'; stub?: boolean }
+  | { kind: 'limit'; jobId: string; limit: TeamLimitKey }
+  | { kind: 'stub'; taskId: string; no: number }
+  | { kind: 'step'; stepId: string }
+
 export interface TeamMessage {
   id: string
   sender: TeamSender
@@ -122,6 +269,7 @@ export interface TeamMessage {
   sessionId?: string
   /** 系统消息的级别 */
   level?: 'info' | 'warning' | 'error'
+  card?: TeamCard
 }
 
 export interface TeamEventPayloads {
@@ -131,6 +279,9 @@ export interface TeamEventPayloads {
   'interaction.resolved': { botId: string; sessionId: string; interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string }
   'file.changed': { file: FileDto; kind: 'created' | 'modified' }
   'team.updated': TeamDto
+  'job.updated': JobDto
+  'task.updated': TaskDto
+  'step.updated': { steps: TeamStepDto[] }
 }
 
 export type TeamEventType = keyof TeamEventPayloads
@@ -146,4 +297,4 @@ export interface TeamEventOf<T extends TeamEventType> {
 export type TeamEvent = { [T in TeamEventType]: TeamEventOf<T> }[TeamEventType]
 
 /** 不落库、只保留在内存缓冲中的事件(状态类,断线续传后以接口返回的最新状态为准) */
-export const TRANSIENT_TEAM_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set(['bot.status', 'file.changed', 'team.updated'])
+export const TRANSIENT_TEAM_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set(['bot.status', 'file.changed', 'team.updated', 'step.updated'])