Przeglądaj źródła

feat: 定时任务(对话中由 AI 创建并确认,会话或工作组到点自动执行;后台总览;SDK 0.4.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 tydzień temu
rodzic
commit
364dd812ea
44 zmienionych plików z 4120 dodań i 27 usunięć
  1. 1 0
      apps/admin/src/components/Workbench.vue
  2. 5 1
      apps/admin/src/lib/api.ts
  3. 1 0
      apps/admin/src/router.ts
  4. 118 0
      apps/admin/src/views/SchedulesView.vue
  5. 15 0
      apps/admin/src/views/SettingsView.vue
  6. 33 0
      apps/server/drizzle/0011_schedules.sql
  7. 2569 0
      apps/server/drizzle/meta/0011_snapshot.json
  8. 7 0
      apps/server/drizzle/meta/_journal.json
  9. 18 0
      apps/server/src/api-reference/bot-reference.ts
  10. 18 0
      apps/server/src/api-reference/reference.ts
  11. 33 0
      apps/server/src/api-reference/schedule-reference.ts
  12. 3 1
      apps/server/src/app.ts
  13. 32 1
      apps/server/src/db/schema.ts
  14. 16 0
      apps/server/src/http/routes/admin.ts
  15. 17 0
      apps/server/src/http/routes/v1.ts
  16. 1 1
      apps/server/src/lib/ids.ts
  17. 332 0
      apps/server/src/schedules/service.ts
  18. 146 0
      apps/server/src/schedules/spec.ts
  19. 58 0
      apps/server/src/schedules/tools.ts
  20. 1 0
      apps/server/src/services/prompt.ts
  21. 3 3
      apps/server/src/services/runs.ts
  22. 8 0
      apps/server/src/services/settings.ts
  23. 20 0
      apps/server/src/teams/routes.ts
  24. 32 10
      apps/server/src/teams/service.ts
  25. 266 0
      apps/server/test/schedules.test.ts
  26. 2 1
      docs/README.md
  27. 4 0
      docs/design/11-roadmap.md
  28. 100 0
      docs/design/14-schedules.md
  29. 21 3
      docs/integration.md
  30. 3 3
      examples/platform-demo/package.json
  31. 1 1
      packages/chat-ui/package.json
  32. 2 0
      packages/chat-ui/src/components/AgentChat.vue
  33. 137 0
      packages/chat-ui/src/components/ScheduleBar.vue
  34. 4 0
      packages/chat-ui/src/components/TeamChat.vue
  35. 1 0
      packages/chat-ui/src/index.ts
  36. 4 0
      packages/chat-ui/src/store.ts
  37. 4 0
      packages/chat-ui/src/team-store.ts
  38. 1 1
      packages/client/package.json
  39. 8 0
      packages/client/src/bot.ts
  40. 11 0
      packages/client/src/index.ts
  41. 1 1
      packages/shared/package.json
  42. 1 0
      packages/shared/src/index.ts
  43. 60 0
      packages/shared/src/schedules.ts
  44. 2 0
      packages/shared/src/settings.ts

+ 1 - 0
apps/admin/src/components/Workbench.vue

@@ -14,6 +14,7 @@ const nav = [
   { to: '/bot-playground', icon: 'codicon-organization', label: 'Bot Playground' },
   { to: '/bot-cases', icon: 'codicon-beaker', label: '回归用例' },
   { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
+  { to: '/schedules', icon: 'codicon-watch', label: '定时任务' },
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
   { to: '/bots', icon: 'codicon-robot', label: '机器人' },

+ 5 - 1
apps/admin/src/lib/api.ts

@@ -2,7 +2,7 @@
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
   ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
-  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto,
+  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -155,6 +155,10 @@ export const admin = {
   botKeyDebugToken: (id: string) => req<BrowserTokenDto>('POST', `/bot-keys/${id}/debug-token`),
   /** 复制到 Playground:按相同成员组成新建后台工作组 */
   copyTeam: (id: string) => req<TeamDto>('POST', `/teams/${id}/copy`),
+  schedules: () => req<ScheduleOverviewDto[]>('GET', '/schedules'),
+  scheduleRuns: (id: string) => req<ScheduleRunDto[]>('GET', `/schedules/${id}/runs`),
+  setScheduleStatus: (id: string, status: 'active' | 'paused') => req<ScheduleDto>('PATCH', `/schedules/${id}`, { status }),
+  deleteSchedule: (id: string) => req<void>('DELETE', `/schedules/${id}`),
   teamCases: () => req<TeamCaseDto[]>('GET', '/team-cases'),
   createTeamCase: (name: string, teamId: string) => req<TeamCaseDto>('POST', '/team-cases', { name, teamId }),
   updateTeamCase: (id: string, body: { name?: string; text?: string }) => req<TeamCaseDto>('PATCH', `/team-cases/${id}`, body),

+ 1 - 0
apps/admin/src/router.ts

@@ -10,6 +10,7 @@ export const router = createRouter({
     { path: '/sessions/:sessionId?', name: 'sessions', component: () => import('./views/SessionsView.vue') },
     { path: '/bot-playground/:teamId?', name: 'bot-playground', component: () => import('./views/BotPlaygroundView.vue') },
     { path: '/bot-cases/:caseId?', name: 'bot-cases', component: () => import('./views/BotCasesView.vue') },
+    { path: '/schedules', component: () => import('./views/SchedulesView.vue') },
     { path: '/bots', component: () => import('./views/BotsView.vue') },
     { path: '/overview', component: () => import('./views/OverviewView.vue') },
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },

+ 118 - 0
apps/admin/src/views/SchedulesView.vue

@@ -0,0 +1,118 @@
+<script setup lang="ts">
+// 定时任务总览(docs/design/14-schedules.md §9):全部会话与工作组的定时任务。定时任务由 AI 在对话中创建,这里只查看、暂停、恢复、删除
+import { computed, onMounted, ref } from 'vue'
+import type { ScheduleOverviewDto, ScheduleRunDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const rows = ref<ScheduleOverviewDto[]>([])
+const filter = ref<'all' | 'active' | 'paused' | 'ended'>('all')
+const keyNames = ref<Record<string, string>>({})
+const expanded = ref<string | null>(null)
+const runs = ref<ScheduleRunDto[]>([])
+
+const STATUS = { active: { text: '进行中', cls: 'text-success' }, paused: { text: '已暂停', cls: 'text-warning' }, ended: { text: '已结束', cls: 'text-fg-muted' } } as const
+const OUTCOME = { running: '执行中', completed: '完成', failed: '失败', skipped: '跳过', sent: '已发到工作组' } as const
+const shown = computed(() => rows.value.filter(r => filter.value === 'all' || r.status === filter.value))
+const sourceName = (s: string) => (s === 'admin' ? '后台' : s.startsWith('team:') ? '工作组' : keyNames.value[s] ?? s)
+
+/** 会话:后台的会话在 Playground 中打开,平台的在会话记录中;工作组在 Bot Playground 中打开 */
+function targetLink(r: ScheduleOverviewDto) {
+  if (r.target.kind === 'team') return `/bot-playground/${r.target.teamId}`
+  return r.source === 'admin' ? `/playground/${r.target.sessionId}` : `/sessions/${r.target.sessionId}`
+}
+
+async function load() {
+  try {
+    rows.value = await admin.schedules()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function toggleRuns(r: ScheduleOverviewDto) {
+  if (expanded.value === r.id) {
+    expanded.value = null
+    return
+  }
+  expanded.value = r.id
+  runs.value = await admin.scheduleRuns(r.id).catch(() => [])
+}
+
+async function setStatus(r: ScheduleOverviewDto, status: 'active' | 'paused') {
+  try {
+    await admin.setScheduleStatus(r.id, status)
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function remove(r: ScheduleOverviewDto) {
+  if (!confirm(`删除定时任务「${r.title}」?`)) return
+  try {
+    await admin.deleteSchedule(r.id)
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+onMounted(async () => {
+  await load()
+  const [keys, botKeys] = await Promise.all([admin.apiKeys().catch(() => []), admin.botKeys().catch(() => [])])
+  keyNames.value = Object.fromEntries([...keys, ...botKeys].map(k => [k.id, k.name]))
+})
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="定时任务" subtitle="用户在对话中让 AI 创建的定时任务;到点时服务端把指令发到对应的会话或工作组">
+      <select v-model="filter" class="field w-32" aria-label="状态">
+        <option value="all">全部</option>
+        <option value="active">进行中</option>
+        <option value="paused">已暂停</option>
+        <option value="ended">已结束</option>
+      </select>
+      <button type="button" class="btn-secondary" title="刷新" @click="load"><span class="codicon codicon-refresh" /></button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto">
+      <div v-if="!shown.length" class="p-6 hint">没有定时任务。在 Playground 或 Bot Playground 中对 AI 说"每天 9 点帮我……"即可创建。</div>
+      <div v-for="r in shown" :key="r.id" class="border-b border-b-solid border-border">
+        <div class="flex items-center gap-3 px-6 py-2">
+          <span class="codicon flex-none text-fg-muted" :class="r.target.kind === 'team' ? 'codicon-organization' : 'codicon-comment-discussion'" />
+          <div class="flex-1 min-w-0">
+            <div class="flex items-center gap-2">
+              <span class="font-600 truncate">{{ r.title }}</span>
+              <span class="hint">{{ r.description }}</span>
+              <span class="text-[12px]" :class="STATUS[r.status].cls">{{ STATUS[r.status].text }}</span>
+            </div>
+            <div class="hint truncate">
+              <RouterLink :to="targetLink(r)" class="text-link">{{ r.target.kind === 'team' ? '工作组' : '会话' }}「{{ r.targetTitle || '未命名' }}」</RouterLink>
+              · {{ sourceName(r.source) }}
+              <template v-if="r.nextRunAt"> · 下次 {{ formatTime(r.nextRunAt) }}</template>
+              <template v-if="r.lastRunAt"> · 上次 {{ formatTime(r.lastRunAt) }}{{ r.lastOutcome ? ` ${OUTCOME[r.lastOutcome]}` : '' }}</template>
+              · {{ r.runCount }} 次
+            </div>
+            <div v-if="r.statusNote" class="text-warning text-[12px]">{{ r.statusNote }}</div>
+          </div>
+          <button type="button" class="btn-ghost" @click="toggleRuns(r)"><span class="codicon codicon-history" />记录</button>
+          <button v-if="r.status === 'active'" type="button" class="btn-ghost" @click="setStatus(r, 'paused')">暂停</button>
+          <button v-else-if="r.status === 'paused'" type="button" class="btn-ghost" @click="setStatus(r, 'active')">恢复</button>
+          <button type="button" class="icon-btn" title="删除" @click="remove(r)"><span class="codicon codicon-trash" /></button>
+        </div>
+        <div v-if="expanded === r.id" class="px-12 pb-3 flex flex-col gap-1 text-[12.5px]">
+          <div class="whitespace-pre-wrap p-2 rounded-md bg-code">{{ r.prompt }}</div>
+          <div v-if="!runs.length" class="hint">还没有执行过</div>
+          <div v-for="x in runs" :key="x.id" class="flex gap-3">
+            <span class="hint w-36 flex-none">{{ formatTime(x.ts) }}</span>
+            <span class="w-24 flex-none" :class="x.outcome === 'failed' ? 'text-error' : x.outcome === 'skipped' ? 'text-warning' : ''">{{ OUTCOME[x.outcome] }}</span>
+            <span class="hint flex-1 min-w-0 truncate">{{ x.note ?? '' }}</span>
+            <span v-if="x.costUsd !== null" class="hint">${{ x.costUsd.toFixed(4) }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 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="s-tz">时区</label><input id="s-tz" v-model.trim="draft.schedules.timezone" class="field" placeholder="Asia/Shanghai"></div>
+            <div><label class="label" for="s-per">每个会话 / 工作组的定时任务数</label><input id="s-per" v-model.number="draft.schedules.maxPerTarget" type="number" min="1" class="field"></div>
+            <div><label class="label" for="s-int">最小间隔(小时)</label><input id="s-int" v-model.number="draft.schedules.minIntervalHours" type="number" min="0.25" step="0.25" class="field"></div>
+            <div><label class="label" for="s-min">每次执行的时长上限(分钟)</label><input id="s-min" v-model.number="draft.schedules.maxMinutesPerRun" type="number" min="1" class="field"></div>
+            <div>
+              <label class="label" for="s-cost">每次执行的成本上限(美元,留空不限)</label>
+              <input id="s-cost" class="field" type="number" min="0.01" step="0.01" :value="draft.schedules.maxCostUsdPerRun ?? ''" @change="draft.schedules.maxCostUsdPerRun = ($event.target as HTMLInputElement).value ? Number(($event.target as HTMLInputElement).value) : null">
+            </div>
+          </div>
+          <div class="hint">定时任务由 AI 在对话中创建(用户确认后生效)。会话中的执行超时会被中断,花费超过上限时自动暂停;工作组中的执行受工作组限额约束。</div>
+        </section>
+
         <section class="flex flex-col gap-3">
           <h2 class="section-title m-0">工作组限额</h2>
           <div class="grid grid-cols-3 gap-3">

+ 33 - 0
apps/server/drizzle/0011_schedules.sql

@@ -0,0 +1,33 @@
+CREATE TABLE `schedule_runs` (
+	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+	`schedule_id` text NOT NULL,
+	`ts` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`outcome` text NOT NULL,
+	`run_id` text,
+	`cost_usd` real,
+	`note` text
+);
+--> statement-breakpoint
+CREATE INDEX `schedule_runs_idx` ON `schedule_runs` (`schedule_id`,`id`);--> statement-breakpoint
+CREATE TABLE `schedules` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text,
+	`team_id` text,
+	`created_by_bot_id` text,
+	`title` text NOT NULL,
+	`prompt` text NOT NULL,
+	`spec` text NOT NULL,
+	`timezone` text NOT NULL,
+	`status` text DEFAULT 'active' NOT NULL,
+	`status_note` text,
+	`next_run_at` text,
+	`last_run_at` text,
+	`last_outcome` text,
+	`run_count` integer DEFAULT 0 NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `schedules_due_idx` ON `schedules` (`status`,`next_run_at`);--> statement-breakpoint
+CREATE INDEX `schedules_session_idx` ON `schedules` (`session_id`);--> statement-breakpoint
+CREATE INDEX `schedules_team_idx` ON `schedules` (`team_id`);

+ 2569 - 0
apps/server/drizzle/meta/0011_snapshot.json

@@ -0,0 +1,2569 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "305d48b6-563e-4dbb-b0c8-8f1b276cdfc5",
+  "prevId": "15f0e075-8654-4542-8539-fbbde3abd913",
+  "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
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": 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
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "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": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "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": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_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
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "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_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "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": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "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
+        },
+        "case_id": {
+          "name": "case_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'))"
+        },
+        "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
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "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

@@ -78,6 +78,13 @@
       "when": 1793086786895,
       "tag": "0010_session_renewal",
       "breakpoints": true
+    },
+    {
+      "idx": 11,
+      "version": "6",
+      "when": 1793089168549,
+      "tag": "0011_schedules",
+      "breakpoints": true
     }
   ]
 }

+ 18 - 0
apps/server/src/api-reference/bot-reference.ts

@@ -2,6 +2,7 @@
 // test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /bot/v1 路由完全一致,新增或删除接口时必须同步修改。
 
 import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
+import { SCHEDULE_DESC, SCHEDULE_TYPES } from './schedule-reference'
 
 export const BOT_OVERVIEW: ApiOverview = {
   points: [
@@ -261,9 +262,25 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
       },
     ],
   },
+  {
+    id: 'schedules',
+    title: '定时任务',
+    desc: `${SCHEDULE_DESC}工作组中由组员创建的定时任务,到点时 @ 这个组员;由组长创建的交给组长。工作组还有进行中的任务或机器人在工作时跳过本次。`,
+    endpoints: [
+      { method: 'GET', path: team('/schedules'), summary: '工作组的定时任务', response: { status: 200, type: 'Schedule[]' } },
+      {
+        method: 'PATCH', path: team('/schedules/:scheduleId'), summary: '暂停或恢复',
+        body: [{ name: 'status', type: '"active" | "paused"', required: true, desc: '' }],
+        response: { status: 200, type: 'Schedule' }, errors: ['409 schedule_ended'],
+      },
+      { method: 'DELETE', path: team('/schedules/:scheduleId'), summary: '删除定时任务', response: { status: 204, type: '—' } },
+      { method: 'GET', path: team('/schedules/:scheduleId/runs'), summary: '执行记录', response: { status: 200, type: 'ScheduleRun[]' } },
+    ],
+  },
 ]
 
 export const BOT_TYPES: ApiTypeDef[] = [
+  ...SCHEDULE_TYPES,
   {
     name: 'BrowserToken', fields: [
       { name: 'token', type: 'string', desc: '以 bt_ 开头,只能用于 /bot/v1' },
@@ -409,6 +426,7 @@ export const BOT_EVENTS: ApiEventDef[] = [
 
 export const BOT_ERRORS: ApiErrorDef[] = [
   { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
+  { status: 409, code: 'schedule_ended', desc: '定时任务已结束(只执行一次的已执行,或执行时间已经过去)' },
   { status: 400, code: 'mention_required', desc: '工作组有多个组员且没有组长,消息必须 @ 机器人' },
   { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },
   { status: 401, code: 'invalid_api_key', desc: 'Bot Key 无效或已停用(Agent Key 也会得到这个错误)' },

+ 18 - 0
apps/server/src/api-reference/reference.ts

@@ -3,6 +3,7 @@
 
 import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiSpec, ApiTypeDef } from '@agent-server/shared'
 import { BOT_ERRORS, BOT_EVENTS, BOT_GROUPS, BOT_OVERVIEW, BOT_TYPES } from './bot-reference'
+import { SCHEDULE_DESC, SCHEDULE_TYPES } from './schedule-reference'
 
 export type { ApiEndpoint as Endpoint, ApiField as Field } from '@agent-server/shared'
 
@@ -221,6 +222,21 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
       },
     ],
   },
+  {
+    id: 'schedules',
+    title: '定时任务',
+    desc: SCHEDULE_DESC,
+    endpoints: [
+      { method: 'GET', path: '/v1/sessions/:id/schedules', summary: '会话的定时任务', response: { status: 200, type: 'Schedule[]' } },
+      {
+        method: 'PATCH', path: '/v1/schedules/:id', summary: '暂停或恢复',
+        desc: '恢复时从现在起计算下次执行时间。', body: [{ name: 'status', type: '"active" | "paused"', required: true, desc: '' }],
+        response: { status: 200, type: 'Schedule' }, errors: ['409 schedule_ended'],
+      },
+      { method: 'DELETE', path: '/v1/schedules/:id', summary: '删除定时任务', response: { status: 204, type: '—' } },
+      { method: 'GET', path: '/v1/schedules/:id/runs', summary: '执行记录', desc: '最近 50 次,新的在前。', response: { status: 200, type: 'ScheduleRun[]' } },
+    ],
+  },
   {
     id: 'usage',
     title: '用量',
@@ -236,6 +252,7 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
 ]
 
 export const API_TYPES: ApiTypeDef[] = [
+  ...SCHEDULE_TYPES,
   {
     name: 'BrowserToken', fields: [
       { name: 'token', type: 'string', desc: '以 bt_ 开头,放在 Authorization: Bearer 中使用' },
@@ -371,6 +388,7 @@ export const API_ERRORS: ApiErrorDef[] = [
   { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期,或签发它的 Agent Key 已停用(SDK 会自动刷新一次)' },
   { status: 403, code: 'forbidden', desc: '无权执行该操作' },
   { status: 403, code: 'profile_not_allowed', desc: 'Profile 不对本平台开放' },
+  { status: 409, code: 'schedule_ended', desc: '定时任务已结束(只执行一次的已执行,或执行时间已经过去)' },
   { status: 404, code: 'not_found', desc: '资源不存在,或不属于本平台' },
   { status: 409, code: 'session_busy', desc: '会话正在运行' },
   { status: 409, code: 'provider_unavailable', desc: '引擎当前不可用(未登录或已停用)' },

+ 33 - 0
apps/server/src/api-reference/schedule-reference.ts

@@ -0,0 +1,33 @@
+// 定时任务的协议参考(docs/design/14-schedules.md §8),Agent API 与 Bot API 共用类型
+import type { ApiTypeDef } from '@agent-server/shared'
+
+export const SCHEDULE_DESC = '定时任务由 AI 在对话中创建(用户在确认卡片中确认后生效),到点时服务端把指令作为一条新消息发到同一个会话或工作组。平台可以查看、暂停、恢复和删除。'
+
+export const SCHEDULE_TYPES: ApiTypeDef[] = [
+  {
+    name: 'Schedule', fields: [
+      { name: 'id', type: 'string', desc: 'sch_ 开头' },
+      { name: 'target', type: '{ kind: "session", sessionId } | { kind: "team", teamId }', desc: '所属的会话或工作组' },
+      { name: 'title / prompt', type: 'string', desc: '名称;到点时发出的指令' },
+      { name: 'spec', type: 'ScheduleSpec', desc: '时间表:once(at)、daily / weekdays(time)、weekly(days、time)、monthly(day、time)、interval(hours)' },
+      { name: 'description', type: 'string', desc: '时间表的中文描述,如"每个工作日(周一到周五)09:00"' },
+      { name: 'timezone', type: 'string', desc: '如 Asia/Shanghai' },
+      { name: 'status', type: '"active" | "paused" | "ended"', desc: 'ended:只执行一次的已执行' },
+      { name: 'statusNote', type: 'string | null', desc: '自动暂停的原因(例如成本超出上限)' },
+      { name: 'nextRunAt / lastRunAt', type: 'string | null', desc: '' },
+      { name: 'lastOutcome', type: '"running" | "completed" | "failed" | "skipped" | "sent" | null', desc: 'sent:已发到工作组' },
+      { name: 'runCount', type: 'number', desc: '' },
+      { name: 'createdByBotId', type: 'string | null', desc: '工作组中创建它的机器人' },
+    ],
+  },
+  {
+    name: 'ScheduleRun', fields: [
+      { name: 'id', type: 'number', desc: '' },
+      { name: 'ts', type: 'string', desc: '执行时间' },
+      { name: 'outcome', type: '"running" | "completed" | "failed" | "skipped" | "sent"', desc: '' },
+      { name: 'runId', type: 'string | null', desc: '会话中执行时的 Run' },
+      { name: 'costUsd', type: 'number | null', desc: '' },
+      { name: 'note', type: 'string | null', desc: '跳过或失败的原因' },
+    ],
+  },
+]

+ 3 - 1
apps/server/src/app.ts

@@ -30,6 +30,7 @@ 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 { ScheduleService } from './schedules/service'
 import { TeamCaseService } from './teams/cases'
 import { ensureLeadProfiles } from './teams/lead-profile'
 import { CardService } from './teams/cards'
@@ -96,8 +97,9 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     },
   })
   const teamCases = new TeamCaseService({ db, teams, dir: teamCasesDir() })
+  const schedules = new ScheduleService({ db, log, settings, sessions, runs, events, teams, bots, profiles })
 
-  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, teamCases, skillCatalog }
+  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, teamCases, schedules, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

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

@@ -1,7 +1,7 @@
 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, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
+  FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
 } from '@agent-server/shared'
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
@@ -380,3 +380,34 @@ export const teamCases = sqliteTable('team_cases', {
   createdAt: createdAt(),
   updatedAt: updatedAt(),
 })
+
+/** 定时任务(docs/design/14-schedules.md):属于一个会话或一个工作组 */
+export const schedules = sqliteTable('schedules', {
+  id: text('id').primaryKey(),
+  sessionId: text('session_id'),
+  teamId: text('team_id'),
+  /** 工作组中创建它的机器人(到点的消息 @ 组员时使用) */
+  createdByBotId: text('created_by_bot_id'),
+  title: text('title').notNull(),
+  prompt: text('prompt').notNull(),
+  spec: text('spec', { mode: 'json' }).$type<ScheduleSpec>().notNull(),
+  timezone: text('timezone').notNull(),
+  status: text('status', { enum: ['active', 'paused', 'ended'] }).notNull().default('active'),
+  statusNote: text('status_note'),
+  nextRunAt: text('next_run_at'),
+  lastRunAt: text('last_run_at'),
+  lastOutcome: text('last_outcome').$type<ScheduleOutcome>(),
+  runCount: integer('run_count').notNull().default(0),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+}, t => [index('schedules_due_idx').on(t.status, t.nextRunAt), index('schedules_session_idx').on(t.sessionId), index('schedules_team_idx').on(t.teamId)])
+
+export const scheduleRuns = sqliteTable('schedule_runs', {
+  id: integer('id').primaryKey({ autoIncrement: true }),
+  scheduleId: text('schedule_id').notNull(),
+  ts: text('ts').notNull().default(now),
+  outcome: text('outcome').$type<ScheduleOutcome>().notNull(),
+  runId: text('run_id'),
+  costUsd: real('cost_usd'),
+  note: text('note'),
+}, t => [index('schedule_runs_idx').on(t.scheduleId, t.id)])

+ 16 - 0
apps/server/src/http/routes/admin.ts

@@ -519,6 +519,22 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.code(204)
     })
 
+    // 定时任务总览(docs/design/14-schedules.md §9)
+    g.get('/schedules', async () => app.schedules.overview())
+    g.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(app.schedules.get(req.params.id).id))
+    g.patch<IdParams>('/schedules/:id', async req => {
+      const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
+      const s = app.schedules.setStatus(req.params.id, status)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `${status === 'active' ? '恢复' : '暂停'}定时任务:${s.title}` })
+      return s
+    })
+    g.delete<IdParams>('/schedules/:id', async (req, reply) => {
+      const s = app.schedules.get(req.params.id)
+      app.schedules.delete(s.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除定时任务:${s.title}` })
+      reply.code(204)
+    })
+
     // 工作组:复制到 Playground;回归用例(docs/design/13-bot-teams.md §12.1、§12.2)
     g.post<IdParams>('/teams/:id/copy', async (req, reply) => {
       const team = await app.teamCases.copy(req.params.id)

+ 17 - 0
apps/server/src/http/routes/v1.ts

@@ -116,11 +116,28 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   f.delete<IdParams>('/sessions/:id', async (req, reply) => {
     await app.sessions.delete(req.params.id, principal(req).source)
+    app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id })
     app.gatewayTokens.forgetSession(req.params.id)
     forgetGatewaySession(req.params.id)
     reply.code(204)
   })
 
+  // ---- 定时任务(docs/design/14-schedules.md):由 AI 在对话中创建,平台可以查看、暂停、恢复、删除 ----
+  f.get<IdParams>('/sessions/:id/schedules', async req => app.schedules.list({ kind: 'session', sessionId: sessionFor(req).id }))
+  const ownSchedule = (req: FastifyRequest<IdParams>) => app.schedules.getFor(req.params.id, t => {
+    if (t.kind !== 'session') throw notFound('定时任务')
+    app.sessions.getRow(t.sessionId, principal(req).source)
+  })
+  f.patch<IdParams>('/schedules/:id', async req => {
+    const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
+    return app.schedules.setStatus(ownSchedule(req).id, status)
+  })
+  f.delete<IdParams>('/schedules/:id', async (req, reply) => {
+    app.schedules.delete(ownSchedule(req).id)
+    reply.code(204)
+  })
+  f.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(ownSchedule(req).id))
+
   f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/sessions/:id/events', async req => {
     const row = sessionFor(req)
     return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }

+ 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' | 'job' | 'task' | 'step' | 'case'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 332 - 0
apps/server/src/schedules/service.ts

@@ -0,0 +1,332 @@
+// 定时任务(docs/design/14-schedules.md):在对话中由 AI 创建(用户确认),属于一个会话或一个工作组;
+// 调度器到点往同一个会话发出指令(开始一轮),或以系统消息发到工作组(按工作组的规则交给机器人)
+import { and, desc, eq, inArray, isNotNull, lte } from 'drizzle-orm'
+import type { AgentEvent, ScheduleDto, ScheduleOutcome, ScheduleOverviewDto, ScheduleRunDto, ScheduleSpec, ScheduleTarget } from '@agent-server/shared'
+import type { AppLogger } from '../app'
+import type { DB } from '../db'
+import { runs, scheduleRuns, schedules, sessions, teams } from '../db/schema'
+import { badRequest, conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
+import type { ProfileService } from '../services/catalog'
+import type { EventStore } from '../services/event-store'
+import type { RunManager } from '../services/runs'
+import { isTeamSource, type SessionService } from '../services/sessions'
+import type { SettingsService } from '../services/settings'
+import type { BotService } from '../teams/bots'
+import type { TeamService } from '../teams/service'
+import { describeSpec, formatLocal, nextRun, parseSpec, upcoming, type ScheduleInput } from './spec'
+import { scheduleTools } from './tools'
+
+type ScheduleRow = typeof schedules.$inferSelect
+
+const TICK_MS = 30_000
+
+export class ScheduleService {
+  /** 会话中正在执行的定时任务:runId → 执行记录 */
+  private running = new Map<string, { scheduleId: string; recordId: number; timer: NodeJS.Timeout; timedOut: boolean }>()
+  private timer: NodeJS.Timeout
+
+  constructor(private deps: {
+    db: DB; log: AppLogger; settings: SettingsService; sessions: SessionService; runs: RunManager; events: EventStore
+    teams: TeamService; bots: BotService; profiles: ProfileService
+  }) {
+    // 服务重启时还在执行的记录:Run 已被标记为中断
+    deps.db.update(scheduleRuns).set({ outcome: 'failed', note: '服务重启,本次执行中断' }).where(eq(scheduleRuns.outcome, 'running')).run()
+    // 工具的说明中写明时区(设置修改后的新 Run 生效)
+    deps.runs.gatewayToolProviders.push(() => scheduleTools(deps.settings.get().schedules.timezone, (name, args, ctx) => this.tool(name, args, ctx)))
+    deps.events.subscribe('*', e => this.onEvent(e))
+    this.timer = setInterval(() => void this.tick().catch(err => deps.log.error({ err }, 'schedule tick failed')), TICK_MS)
+    this.timer.unref()
+  }
+
+  stop() {
+    clearInterval(this.timer)
+  }
+
+  // ---- 查询与管理 ----
+
+  list(target: ScheduleTarget): ScheduleDto[] {
+    const where = target.kind === 'session' ? eq(schedules.sessionId, target.sessionId) : eq(schedules.teamId, target.teamId)
+    return this.deps.db.select().from(schedules).where(where).orderBy(schedules.createdAt).all().map(r => this.toDto(r))
+  }
+
+  /** 后台总览 */
+  overview(): ScheduleOverviewDto[] {
+    const rows = this.deps.db.select().from(schedules).orderBy(desc(schedules.updatedAt)).all()
+    const sessionIds = rows.flatMap(r => (r.sessionId ? [r.sessionId] : []))
+    const teamIds = rows.flatMap(r => (r.teamId ? [r.teamId] : []))
+    const sessionMap = new Map(sessionIds.length ? this.deps.db.select({ id: sessions.id, title: sessions.title, source: sessions.source }).from(sessions).where(inArray(sessions.id, sessionIds)).all().map(s => [s.id, s]) : [])
+    const teamMap = new Map(teamIds.length ? this.deps.db.select({ id: teams.id, title: teams.title, source: teams.source }).from(teams).where(inArray(teams.id, teamIds)).all().map(t => [t.id, t]) : [])
+    return rows.map(r => {
+      const t = r.sessionId ? sessionMap.get(r.sessionId) : teamMap.get(r.teamId!)
+      return { ...this.toDto(r), targetTitle: t?.title || '(已删除)', source: t?.source ?? '' }
+    })
+  }
+
+  get(id: string): ScheduleRow {
+    const row = this.deps.db.select().from(schedules).where(eq(schedules.id, id)).get()
+    if (!row) throw notFound('定时任务')
+    return row
+  }
+
+  /** 平台访问:只能操作自己的会话或工作组的定时任务 */
+  getFor(id: string, check: (target: ScheduleTarget) => void): ScheduleRow {
+    const row = this.get(id)
+    check(this.targetOf(row))
+    return row
+  }
+
+  runs(id: string, limit = 50): ScheduleRunDto[] {
+    return this.deps.db.select().from(scheduleRuns).where(eq(scheduleRuns.scheduleId, id)).orderBy(desc(scheduleRuns.id)).limit(limit).all()
+      .map(r => ({ id: r.id, scheduleId: r.scheduleId, ts: r.ts, outcome: r.outcome, runId: r.runId, costUsd: r.costUsd, note: r.note }))
+  }
+
+  /** 暂停或恢复;恢复时从现在起计算下次执行时间 */
+  setStatus(id: string, status: 'active' | 'paused'): ScheduleDto {
+    const row = this.get(id)
+    if (row.status === 'ended') throw conflict('schedule_ended', '定时任务已结束')
+    const next = status === 'active' ? nextRun(row.spec, row.timezone, Date.now()) : null
+    if (status === 'active' && next === null) throw conflict('schedule_ended', '执行时间已经过去')
+    this.patch(id, { status, statusNote: null, ...(status === 'active' ? { nextRunAt: new Date(next!).toISOString() } : {}) })
+    return this.toDto(this.get(id))
+  }
+
+  delete(id: string) {
+    this.get(id)
+    this.deps.db.delete(schedules).where(eq(schedules.id, id)).run()
+    this.deps.db.delete(scheduleRuns).where(eq(scheduleRuns.scheduleId, id)).run()
+  }
+
+  /** 会话或工作组删除时一起删除 */
+  deleteFor(target: ScheduleTarget) {
+    for (const s of this.list(target)) this.delete(s.id)
+  }
+
+  // ---- 调度 ----
+
+  /** 执行到期的定时任务;停机期间错过的只补跑一次 */
+  async tick(now = Date.now()) {
+    const due = this.deps.db.select().from(schedules)
+      .where(and(eq(schedules.status, 'active'), isNotNull(schedules.nextRunAt), lte(schedules.nextRunAt, new Date(now).toISOString()))).all()
+    for (const row of due) {
+      try {
+        this.execute(row, now)
+      } catch (err) {
+        this.deps.log.error({ err, scheduleId: row.id }, 'schedule execution failed')
+      }
+    }
+  }
+
+  private execute(row: ScheduleRow, now: number) {
+    const next = nextRun(row.spec, row.timezone, now)
+    const text = `【定时任务「${row.title}」· ${formatLocal(now, row.timezone)}】\n${row.prompt}`
+    let outcome: ScheduleOutcome
+    let note: string | null = null
+    let runId: string | null = null
+
+    if (row.sessionId) {
+      const session = this.deps.db.select().from(sessions).where(eq(sessions.id, row.sessionId)).get()
+      if (!session) {
+        this.delete(row.id)
+        return
+      }
+      try {
+        runId = this.deps.runs.start(session, text, []).id
+        outcome = 'running'
+      } catch (err) {
+        const busy = (err as { code?: string }).code === 'session_busy'
+        outcome = busy ? 'skipped' : 'failed'
+        note = busy ? '会话正在执行,跳过本次' : (err as Error).message
+      }
+    } else {
+      const team = this.deps.db.select().from(teams).where(eq(teams.id, row.teamId!)).get()
+      if (!team) {
+        this.delete(row.id)
+        return
+      }
+      if (team.status !== 'active') {
+        outcome = 'skipped'
+        note = team.status === 'paused' ? '工作组已暂停,跳过本次' : '工作组已关闭,跳过本次'
+      } else if (this.deps.teams.isBusy(team.id)) {
+        outcome = 'skipped'
+        note = '工作组还有进行中的任务或机器人在工作,跳过本次'
+      } else {
+        try {
+          this.deps.teams.postScheduled(team.id, text, row.createdByBotId ? [row.createdByBotId] : [])
+          outcome = 'sent'
+        } catch (err) {
+          outcome = 'failed'
+          note = (err as Error).message
+        }
+      }
+    }
+
+    const record = this.deps.db.insert(scheduleRuns).values({ scheduleId: row.id, ts: new Date(now).toISOString(), outcome, runId, note }).returning({ id: scheduleRuns.id }).get()
+    this.patch(row.id, {
+      lastRunAt: new Date(now).toISOString(), lastOutcome: outcome, runCount: row.runCount + 1,
+      nextRunAt: next === null ? null : new Date(next).toISOString(),
+      ...(next === null ? { status: 'ended' as const } : {}),
+    })
+    if (runId) {
+      const minutes = this.deps.settings.get().schedules.maxMinutesPerRun
+      const entry = { scheduleId: row.id, recordId: record.id, timedOut: false, timer: setTimeout(() => {
+        entry.timedOut = true
+        this.deps.runs.interrupt(runId!, row.sessionId!)
+      }, minutes * 60_000) }
+      entry.timer.unref()
+      this.running.set(runId, entry)
+    }
+  }
+
+  /** 会话中的执行结束:记录结果和成本;成本超出上限时暂停 */
+  private onEvent(e: AgentEvent) {
+    if (e.type !== 'run.completed' && e.type !== 'run.failed' && e.type !== 'run.interrupted') return
+    const entry = e.runId ? this.running.get(e.runId) : undefined
+    if (!entry) return
+    this.running.delete(e.runId!)
+    clearTimeout(entry.timer)
+    const cost = this.deps.db.select({ costUsd: runs.costUsd }).from(runs).where(eq(runs.id, e.runId!)).get()?.costUsd ?? null
+    const outcome: ScheduleOutcome = e.type === 'run.completed' ? 'completed' : 'failed'
+    const minutes = this.deps.settings.get().schedules.maxMinutesPerRun
+    const note = entry.timedOut ? `超过 ${minutes} 分钟,已中断` : e.type === 'run.failed' ? e.payload.message : e.type === 'run.interrupted' ? '执行被中断' : null
+    this.deps.db.update(scheduleRuns).set({ outcome, costUsd: cost, note }).where(eq(scheduleRuns.id, entry.recordId)).run()
+    const row = this.deps.db.select().from(schedules).where(eq(schedules.id, entry.scheduleId)).get()
+    if (!row) return
+    const max = this.deps.settings.get().schedules.maxCostUsdPerRun
+    const over = max !== null && cost !== null && cost > max
+    this.patch(row.id, {
+      lastOutcome: outcome,
+      ...(over && row.status === 'active' ? { status: 'paused' as const, statusNote: `本次执行花费 $${cost!.toFixed(2)},超过每次的上限 $${max},已自动暂停` } : {}),
+    })
+  }
+
+  // ---- 工具(AI 在对话中调用) ----
+
+  private async tool(name: string, args: Record<string, unknown>, ctx: GatewayRunContext): Promise<string> {
+    const target = this.targetOfSession(ctx.sessionId)
+    const createdBy = target.kind === 'team' ? this.deps.teams.teamOfSession(ctx.sessionId)?.botId ?? null : null
+    const s = this.deps.settings.get().schedules
+    switch (name) {
+      case 'schedule_list': {
+        const list = this.list(target)
+        const now = `当前时间:${formatLocal(Date.now(), s.timezone)}(${s.timezone})`
+        return `${now}\n\n${list.length ? list.map(x => this.summary(x)).join('\n\n') : '当前没有定时任务。'}`
+      }
+      case 'schedule_create': {
+        if (this.list(target).filter(x => x.status !== 'ended').length >= s.maxPerTarget) throw new Error(`最多 ${s.maxPerTarget} 个定时任务,请先删除不需要的`)
+        const title = String(args.title ?? '').trim()
+        const prompt = String(args.prompt ?? '').trim()
+        const spec = parseSpec(args.schedule as ScheduleInput, { minIntervalHours: s.minIntervalHours, timezone: s.timezone })
+        const ok = await this.confirm(ctx, target, 'schedule_create', `创建定时任务「${title}」`, title, prompt, spec, s.timezone)
+        if (!ok.allowed) return `用户没有确认,定时任务没有创建${ok.reason ? `:${ok.reason}` : ''}。`
+        const next = nextRun(spec, s.timezone, Date.now())
+        const id = newId('sch')
+        this.deps.db.insert(schedules).values({
+          id, title, prompt, spec, timezone: s.timezone, nextRunAt: next === null ? null : new Date(next).toISOString(),
+          createdByBotId: createdBy,
+          ...(target.kind === 'session' ? { sessionId: target.sessionId } : { teamId: target.teamId }),
+        }).run()
+        return `已创建定时任务(id: ${id}):${describeSpec(spec)},下次执行 ${next === null ? '—' : formatLocal(next, s.timezone)}。`
+      }
+      case 'schedule_update': {
+        const row = this.ownRow(String(args.schedule_id), target)
+        const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : row.title
+        const prompt = typeof args.prompt === 'string' && args.prompt.trim() ? args.prompt.trim() : row.prompt
+        const spec = args.schedule ? parseSpec(args.schedule as ScheduleInput, { minIntervalHours: s.minIntervalHours, timezone: row.timezone }) : row.spec
+        const changed = title !== row.title || prompt !== row.prompt || JSON.stringify(spec) !== JSON.stringify(row.spec)
+        if (changed) {
+          if (row.status === 'ended') throw new Error('定时任务已结束,请重新创建')
+          const ok = await this.confirm(ctx, target, 'schedule_update', `修改定时任务「${row.title}」`, title, prompt, spec, row.timezone)
+          if (!ok.allowed) return `用户没有确认,定时任务没有修改${ok.reason ? `:${ok.reason}` : ''}。`
+          const next = nextRun(spec, row.timezone, Date.now())
+          this.patch(row.id, { title, prompt, spec, nextRunAt: next === null ? null : new Date(next).toISOString() })
+        }
+        if (args.status === 'active' || args.status === 'paused') this.setStatus(row.id, args.status)
+        return `已更新:\n${this.summary(this.toDto(this.get(row.id)))}`
+      }
+      case 'schedule_delete': {
+        const row = this.ownRow(String(args.schedule_id), target)
+        this.delete(row.id)
+        return `已删除定时任务「${row.title}」。`
+      }
+    }
+    throw new Error(`未知的工具:${name}`)
+  }
+
+  /** 确认卡片:时间表、接下来的执行时间、指令;无人值守时会遇到审批的给出提示 */
+  private async confirm(ctx: GatewayRunContext, target: ScheduleTarget, tool: string, heading: string, title: string, prompt: string, spec: ScheduleSpec, timezone: string) {
+    if (!title) throw new Error('请填写 title')
+    if (!prompt) throw new Error('请填写 prompt:到点时要执行的完整指令')
+    const next = upcoming(spec, timezone, Date.now())
+    const body = [
+      `时间:${describeSpec(spec)}(${timezone})`,
+      `接下来:${next.length ? next.map(t => formatLocal(t, timezone)).join('、') : '—'}`,
+      '',
+      `到点时发出的指令:\n${prompt}`,
+    ].join('\n')
+    const outcome = await ctx.interact({
+      kind: 'approval', tool, title: `${heading}:${describeSpec(spec)}`, input: { title, prompt, schedule: spec },
+      detail: { command: body, ...(this.needsApproval(target) ? { reason: `无人值守时,需要审批的操作会在 ${Math.round(this.deps.settings.get().limits.interactionTimeoutSec / 60)} 分钟后被拒绝` } : {}) },
+      options: ['allow_once', 'deny'],
+    })
+    return { allowed: outcome.resolution === 'allowed', reason: outcome.resolution === 'denied' ? outcome.reason : undefined }
+  }
+
+  /** 执行时可能要人审批:会话的 Profile(或工作组中任一机器人的 Profile)为"需审批" */
+  private needsApproval(target: ScheduleTarget): boolean {
+    if (target.kind === 'session') {
+      const session = this.deps.sessions.getRow(target.sessionId)
+      return this.deps.sessions.effectiveProfile(session).permissionMode === 'ask'
+    }
+    return this.deps.teams.toDto(this.deps.teams.getRow(target.teamId)).bots.some(b => {
+      try {
+        return this.deps.profiles.get(this.deps.bots.get(b.id).profileId).permissionMode === 'ask'
+      } catch {
+        return false
+      }
+    })
+  }
+
+  private ownRow(id: string, target: ScheduleTarget): ScheduleRow {
+    const row = this.deps.db.select().from(schedules).where(eq(schedules.id, id)).get()
+    const t = row && this.targetOf(row)
+    const same = t && (t.kind === 'session' ? target.kind === 'session' && t.sessionId === target.sessionId : target.kind === 'team' && t.teamId === target.teamId)
+    if (!row || !same) throw new Error('定时任务不存在,请先调用 schedule_list 查看')
+    return row
+  }
+
+  private summary(x: ScheduleDto): string {
+    const status = { active: '进行中', paused: `已暂停${x.statusNote ? `(${x.statusNote})` : ''}`, ended: '已结束' }[x.status]
+    const next = x.nextRunAt && x.status === 'active' ? `,下次 ${formatLocal(Date.parse(x.nextRunAt), x.timezone)}` : ''
+    return `- ${x.title}(id: ${x.id}):${x.description} · ${status}${next} · 已执行 ${x.runCount} 次\n  指令:${x.prompt}`
+  }
+
+  // ---- 内部 ----
+
+  private targetOfSession(sessionId: string): ScheduleTarget {
+    const session = this.deps.sessions.getRow(sessionId)
+    if (isTeamSource(session.source)) {
+      const ref = this.deps.teams.teamOfSession(sessionId)
+      if (!ref) throw badRequest('这个会话不属于任何工作组')
+      return { kind: 'team', teamId: ref.teamId }
+    }
+    return { kind: 'session', sessionId }
+  }
+
+  private targetOf(r: ScheduleRow): ScheduleTarget {
+    return r.sessionId ? { kind: 'session', sessionId: r.sessionId } : { kind: 'team', teamId: r.teamId! }
+  }
+
+  private patch(id: string, set: Partial<ScheduleRow>) {
+    this.deps.db.update(schedules).set({ ...set, updatedAt: nowIso() }).where(eq(schedules.id, id)).run()
+  }
+
+  private toDto(r: ScheduleRow): ScheduleDto {
+    return {
+      id: r.id, target: this.targetOf(r), title: r.title, prompt: r.prompt, spec: r.spec, timezone: r.timezone, description: describeSpec(r.spec),
+      status: r.status, statusNote: r.statusNote, nextRunAt: r.status === 'active' ? r.nextRunAt : null, lastRunAt: r.lastRunAt, lastOutcome: r.lastOutcome,
+      runCount: r.runCount, createdByBotId: r.createdByBotId, createdAt: r.createdAt, updatedAt: r.updatedAt,
+    }
+  }
+}

+ 146 - 0
apps/server/src/schedules/spec.ts

@@ -0,0 +1,146 @@
+// 定时任务的时间表(docs/design/14-schedules.md §3):校验、中文描述、计算下次执行时间。
+// 时区换算只用 Intl,按指定时区的本地日期逐日查找下一个匹配的时间点
+import type { ScheduleSpec } from '@agent-server/shared'
+
+/** 工具传入的时间表(扁平字段,模型更容易填写) */
+export interface ScheduleInput {
+  kind: string
+  time?: string
+  days?: number[]
+  day?: number
+  at?: string
+  /** once:多少分钟后(不知道当前时间时使用) */
+  after_minutes?: number
+  hours?: number
+}
+
+const WEEKDAYS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+const TIME_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/
+const AT_RE = /^(\d{4})-(\d{2})-(\d{2})[ T]([01]?\d|2[0-3]):([0-5]\d)$/
+
+/** 校验并规范化;不合法时抛出给模型看的错误 */
+export function parseSpec(input: ScheduleInput, opts: { minIntervalHours: number; timezone: string; now?: number }): ScheduleSpec {
+  const time = () => {
+    const m = TIME_RE.exec(String(input.time ?? '').trim())
+    if (!m) throw new Error('time 的格式是 HH:mm(24 小时制),例如 09:00')
+    return `${m[1]!.padStart(2, '0')}:${m[2]}`
+  }
+  switch (input.kind) {
+    case 'once': {
+      if (input.after_minutes !== undefined && !input.at) {
+        const minutes = Number(input.after_minutes)
+        if (!Number.isFinite(minutes) || minutes < 1) throw new Error('after_minutes 至少为 1')
+        const p = zonedParts((opts.now ?? Date.now()) + Math.ceil(minutes) * 60_000, opts.timezone)
+        return { kind: 'once', at: `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}` }
+      }
+      const m = AT_RE.exec(String(input.at ?? '').trim())
+      if (!m) throw new Error('at 的格式是 YYYY-MM-DD HH:mm,例如 2026-11-03 09:00')
+      const at = `${m[1]}-${m[2]}-${m[3]} ${m[4]!.padStart(2, '0')}:${m[5]}`
+      const t = localToUtc(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), opts.timezone)
+      const now = opts.now ?? Date.now()
+      const back = zonedParts(t, opts.timezone)
+      if (back.d !== Number(m[3]) || back.m !== Number(m[2])) throw new Error(`日期 ${at} 不存在`)
+      if (Number.isNaN(t) || t <= now) throw new Error(`执行时间 ${at} 已经过去(当前时间 ${formatLocal(now, opts.timezone)},时区 ${opts.timezone})`)
+      if (t > now + 366 * 86_400_000) throw new Error('只支持一年以内的时间')
+      return { kind: 'once', at }
+    }
+    case 'daily':
+    case 'weekdays':
+      return { kind: input.kind, time: time() }
+    case 'weekly': {
+      const days = [...new Set(input.days ?? [])].sort((a, b) => a - b)
+      if (!days.length || days.some(d => !Number.isInteger(d) || d < 1 || d > 7)) throw new Error('days 是 1–7 的数字列表(1=周一 … 7=周日),不能为空')
+      return { kind: 'weekly', days, time: time() }
+    }
+    case 'monthly': {
+      const day = Number(input.day)
+      if (!Number.isInteger(day) || day < 1 || day > 31) throw new Error('day 是 1–31 的数字(超过当月天数时取最后一天)')
+      return { kind: 'monthly', day, time: time() }
+    }
+    case 'interval': {
+      const hours = Number(input.hours)
+      if (!Number.isFinite(hours) || hours < opts.minIntervalHours) throw new Error(`hours 不能小于 ${opts.minIntervalHours}`)
+      if (hours > 24 * 30) throw new Error('hours 不能超过 720(30 天)')
+      return { kind: 'interval', hours }
+    }
+    default:
+      throw new Error('kind 只能是 once、daily、weekdays、weekly、monthly、interval 之一')
+  }
+}
+
+export function describeSpec(spec: ScheduleSpec): string {
+  switch (spec.kind) {
+    case 'once': return `${spec.at} 执行一次`
+    case 'daily': return `每天 ${spec.time}`
+    case 'weekdays': return `每个工作日(周一到周五)${spec.time}`
+    case 'weekly': return `每周${spec.days.map(d => WEEKDAYS[d]!.slice(1)).join('、')} ${spec.time}`
+    case 'monthly': return `每月 ${spec.day} 日 ${spec.time}`
+    case 'interval': return `每 ${spec.hours} 小时`
+  }
+}
+
+/** after 之后的下一次执行时间(毫秒);只执行一次且已过去时返回 null */
+export function nextRun(spec: ScheduleSpec, timezone: string, after: number): number | null {
+  if (spec.kind === 'interval') return after + spec.hours * 3_600_000
+  if (spec.kind === 'once') {
+    const m = AT_RE.exec(spec.at)!
+    const t = localToUtc(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), timezone)
+    return t > after ? t : null
+  }
+  const [hh, mm] = spec.time.split(':').map(Number) as [number, number]
+  const start = zonedParts(after, timezone)
+  // 按本地日期逐日查找(每月某天最多需要两个月)
+  for (let i = 0; i < 70; i++) {
+    const date = new Date(Date.UTC(start.y, start.m - 1, start.d + i))
+    const y = date.getUTCFullYear(), m = date.getUTCMonth() + 1, d = date.getUTCDate()
+    const weekday = date.getUTCDay() || 7
+    const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate()
+    const match = spec.kind === 'daily'
+      || (spec.kind === 'weekdays' && weekday <= 5)
+      || (spec.kind === 'weekly' && spec.days.includes(weekday))
+      || (spec.kind === 'monthly' && d === Math.min(spec.day, lastDay))
+    if (!match) continue
+    const t = localToUtc(y, m, d, hh, mm, timezone)
+    if (t > after) return t
+  }
+  return null
+}
+
+/** 接下来的几次执行时间(确认卡片中显示) */
+export function upcoming(spec: ScheduleSpec, timezone: string, from: number, count = 3): number[] {
+  const out: number[] = []
+  let t = nextRun(spec, timezone, from)
+  while (t !== null && out.length < count) {
+    out.push(t)
+    t = nextRun(spec, timezone, t)
+  }
+  return out
+}
+
+/** 例如"11月3日 周一 09:00" */
+export function formatLocal(ms: number, timezone: string): string {
+  const p = zonedParts(ms, timezone)
+  const weekday = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() || 7
+  return `${p.m}月${p.d}日 ${WEEKDAYS[weekday]} ${String(p.h).padStart(2, '0')}:${String(p.mi).padStart(2, '0')}`
+}
+
+const pad = (n: number) => String(n).padStart(2, '0')
+
+function zonedParts(ms: number, timezone: string) {
+  const parts = new Intl.DateTimeFormat('en-US', {
+    timeZone: timezone, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', hourCycle: 'h23',
+  }).formatToParts(new Date(ms))
+  const get = (type: string) => Number(parts.find(p => p.type === type)?.value)
+  return { y: get('year'), m: get('month'), d: get('day'), h: get('hour'), mi: get('minute') }
+}
+
+/** 指定时区的本地时间 → UTC 毫秒(按该时刻的时区偏移修正两次,覆盖夏令时切换) */
+function localToUtc(y: number, m: number, d: number, h: number, mi: number, timezone: string): number {
+  const want = Date.UTC(y, m - 1, d, h, mi)
+  let guess = want
+  for (let i = 0; i < 2; i++) {
+    const p = zonedParts(guess, timezone)
+    guess += want - Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi)
+  }
+  return guess
+}

+ 58 - 0
apps/server/src/schedules/tools.ts

@@ -0,0 +1,58 @@
+// 定时任务工具(docs/design/14-schedules.md §4):由 MCP Gateway 提供给所有会话(普通会话与工作组中的机器人)
+import { z } from 'zod'
+import { fail, ok, type BuiltinTool, type ToolResult } from '../mcp-gateway/builtin'
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
+
+const scheduleArg = z.object({
+  kind: z.enum(['once', 'daily', 'weekdays', 'weekly', 'monthly', 'interval'])
+    .describe('once:只执行一次;daily:每天;weekdays:周一到周五;weekly:每周的某几天;monthly:每月某天;interval:每 N 小时'),
+  time: z.string().optional().describe('daily / weekdays / weekly / monthly 的执行时间,HH:mm(24 小时制)'),
+  days: z.array(z.number().int()).optional().describe('weekly 的星期几,1=周一 … 7=周日'),
+  day: z.number().int().optional().describe('monthly 的日期 1–31,超过当月天数时取最后一天'),
+  at: z.string().optional().describe('once 的执行时间,YYYY-MM-DD HH:mm'),
+  after_minutes: z.number().optional().describe('once 的另一种写法:多少分钟后执行(用户说"两小时后"等相对时间时使用)'),
+  hours: z.number().optional().describe('interval 的间隔小时数'),
+})
+
+const schemasFor = (timezone: string) => ({
+  schedule_create: z.object({
+    title: z.string().min(1).max(60).describe('简短的名称,如"销售日报"'),
+    prompt: z.string().min(1).max(20_000).describe('到点时发给你的完整指令:要能独立执行,写清楚做什么、数据从哪里来、产出放在哪里'),
+    schedule: scheduleArg.describe(`时间表,时间按 ${timezone} 时区`),
+  }),
+  schedule_list: z.object({}),
+  schedule_update: z.object({
+    schedule_id: z.string().describe('定时任务 id(见 schedule_list)'),
+    title: z.string().max(60).optional(),
+    prompt: z.string().max(20_000).optional(),
+    schedule: scheduleArg.describe(`时间表,时间按 ${timezone} 时区`).optional(),
+    status: z.enum(['active', 'paused']).optional().describe('暂停或恢复'),
+  }),
+  schedule_delete: z.object({ schedule_id: z.string() }),
+})
+
+type ToolName = keyof ReturnType<typeof schemasFor>
+
+const DESCRIPTIONS: Record<ToolName, string> = {
+  schedule_create: '用户要求定期执行或在将来某个时间执行某项工作时,创建定时任务。会显示确认卡片,用户确认后生效;到点时系统把 prompt 作为一条新消息发到当前会话(工作组中发到工作组),由你执行。',
+  schedule_list: '列出当前会话(或工作组)的定时任务。',
+  schedule_update: '修改定时任务的名称、指令或时间表(需要用户确认),或者暂停、恢复(不需要确认)。',
+  schedule_delete: '删除定时任务。',
+}
+
+export function scheduleTools(timezone: string, handle: (name: ToolName, args: Record<string, unknown>, ctx: GatewayRunContext) => Promise<string>): BuiltinTool[] {
+  const schemas = schemasFor(timezone)
+  return (Object.keys(schemas) as ToolName[]).map(name => ({
+    name,
+    description: DESCRIPTIONS[name],
+    schema: schemas[name],
+    readOnly: name === 'schedule_list',
+    async handler(args, ctx): Promise<ToolResult> {
+      try {
+        return ok(await handle(name, args as Record<string, unknown>, ctx))
+      } catch (err) {
+        return fail((err as Error).message)
+      }
+    },
+  }))
+}

+ 1 - 0
apps/server/src/services/prompt.ts

@@ -24,6 +24,7 @@ export function buildSystemPrompt(opts: {
   - 格式转换(docx/xlsx/pptx → pdf 等):\`convert_document\`
   - Excel 公式重算与错误检查:\`recalc_workbook\`
 - 抓取网页内容用 \`web_fetch\`(返回 Markdown;PDF 等文件会下载到工作区);查找本地资料前用 \`kb_list_roots\` 查看可用的资料库。
+- 用户要求定期执行或在将来某个时间执行("每天 9 点…""两小时后…")时,用 \`schedule_create\` 创建定时任务:到点时系统会把指令作为新消息发到当前会话(工作组中发到工作组),由你执行。不要用 sleep、后台进程等方式等待。时间按 ${opts.settings.schedules.timezone} 时区;查看、修改、暂停、删除用 \`schedule_list\`、\`schedule_update\`、\`schedule_delete\`。
 - Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
 - matplotlib 已配置中文字体,图表可以直接使用中文。
 - 你只能访问被授权的路径;访问被拒绝时向用户说明,不要尝试绕过限制。${opts.permissionMode === 'readonly' ? '\n- 当前会话是只读模式:不能修改文件或执行命令。' : ''}

+ 3 - 3
apps/server/src/services/runs.ts

@@ -57,8 +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[] = () => []
+  /** 额外的网关工具(工作组的协作工具、定时任务工具),按会话提供 */
+  gatewayToolProviders: ((session: SessionRow) => BuiltinTool[])[] = []
 
   constructor(
     private deps: {
@@ -212,7 +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),
+        extraTools: this.gatewayToolProviders.flatMap(f => f(sessionRow)),
       }, { stablePerSession: session.provider === 'codex' })
 
       const result = await provider.run({

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

@@ -31,6 +31,7 @@ export function defaultSettings(): AppSettings {
       workspaceMaxMB: 2048,
     },
     retention: { workspaceDays: 30 },
+    schedules: { timezone: 'Asia/Shanghai', minIntervalHours: 1, maxPerTarget: 5, maxMinutesPerRun: 60, maxCostUsdPerRun: 2 },
     teamLimits: { maxTasksPerJob: 20, maxLeadRunsPerJob: 30, maxCostUsdPerJob: null, maxMinutesPerJob: 120, maxConcurrentRuns: 4, sessionMaxContextTokens: 100_000, sessionIdleHours: 24 },
     providers: {
       claude: {
@@ -83,6 +84,13 @@ const settingsSchema: z.ZodType<AppSettings> = z.object({
   }),
   retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
   teamLimits: teamLimitsSchema,
+  schedules: z.object({
+    timezone: z.string().refine(tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true } catch { return false } }, '无效的时区'),
+    minIntervalHours: z.number().min(0.25).max(24 * 30),
+    maxPerTarget: z.number().int().min(1).max(100),
+    maxMinutesPerRun: z.number().int().min(1).max(24 * 60),
+    maxCostUsdPerRun: z.number().min(0.01).max(10000).nullable(),
+  }),
   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({

+ 20 - 0
apps/server/src/teams/routes.ts

@@ -63,9 +63,29 @@ export default async function botRoutes(f: FastifyInstance, { app }: { app: App
   f.patch<IdParams>('/teams/:id', async req => app.teams.detail(app.teams.patch(req.params.id, source(req), req.body)))
   f.delete<IdParams>('/teams/:id', async (req, reply) => {
     await app.teams.delete(req.params.id, source(req))
+    app.schedules.deleteFor({ kind: 'team', teamId: req.params.id })
     reply.code(204)
   })
 
+  // ---- 定时任务(docs/design/14-schedules.md):由机器人在对话中创建,平台可以查看、暂停、恢复、删除 ----
+  type ScheduleParams = { Params: { id: string; scheduleId: string } }
+  f.get<IdParams>('/teams/:id/schedules', async req => app.schedules.list({ kind: 'team', teamId: teamFor(req).id }))
+  const ownSchedule = (req: FastifyRequest<ScheduleParams>) => {
+    const team = app.teams.getRow(req.params.id, source(req))
+    return app.schedules.getFor(req.params.scheduleId, t => {
+      if (t.kind !== 'team' || t.teamId !== team.id) throw notFound('定时任务')
+    })
+  }
+  f.patch<ScheduleParams>('/teams/:id/schedules/:scheduleId', async req => {
+    const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
+    return app.schedules.setStatus(ownSchedule(req).id, status)
+  })
+  f.delete<ScheduleParams>('/teams/:id/schedules/:scheduleId', async (req, reply) => {
+    app.schedules.delete(ownSchedule(req).id)
+    reply.code(204)
+  })
+  f.get<ScheduleParams>('/teams/:id/schedules/:scheduleId/runs', async req => app.schedules.runs(ownSchedule(req).id))
+
   for (const action of ['pause', 'resume', 'close', 'reopen'] as const) {
     f.post<IdParams>(`/teams/:id/${action}`, async req => app.teams.detail(app.teams.setStatus(req.params.id, source(req), action)))
   }

+ 32 - 10
apps/server/src/teams/service.ts

@@ -132,11 +132,11 @@ export class TeamService {
     // 协作工具:按会话所属机器人的角色提供
     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 => {
+    deps.runs.gatewayToolProviders.push(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)
@@ -323,18 +323,40 @@ export class TeamService {
     const members = this.memberRows(id)
     const mentions = [...new Set(data.mentions ?? [])]
     for (const m of mentions) if (!members.some(x => x.botId === m)) throw badRequest(`被 @ 的机器人不在工作组中:${m}`)
-
-    let targets = mentions
-    if (!targets.length) {
-      const lead = members.find(m => m.role === 'lead')
-      if (lead) targets = [lead.botId]
-      else if (members.length === 1) targets = [members[0]!.botId]
-      else throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
-    }
+    const targets = this.targetsFor(id, mentions)
     const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
     return this.post(id, { sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets })
   }
 
+  /** 消息交给谁:@ 的机器人;没有 @ 时交给组长,只有一个机器人时交给它 */
+  private targetsFor(teamId: string, mentions: string[]): string[] {
+    if (mentions.length) return mentions
+    const members = this.memberRows(teamId)
+    const lead = members.find(m => m.role === 'lead')
+    if (lead) return [lead.botId]
+    if (members.length === 1) return [members[0]!.botId]
+    throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
+  }
+
+  // ---- 定时任务(docs/design/14-schedules.md)使用 ----
+
+  /** 机器人会话所属的工作组 */
+  teamOfSession(sessionId: string): { teamId: string; botId: string } | null {
+    return this.lookup(sessionId)
+  }
+
+  /** 还有进行中的任务或机器人在工作 */
+  isBusy(teamId: string): boolean {
+    return !!this.jobs.active(teamId) || this.toDto(this.getRow(teamId)).bots.some(b => b.status !== 'idle')
+  }
+
+  /** 定时任务到点:以系统消息发到工作组,按 @ 或组长交给机器人;@ 的机器人已不在组中时交给组长 */
+  postScheduled(teamId: string, text: string, mentions: string[]): TeamMessage {
+    const members = this.memberRows(teamId)
+    const present = mentions.filter(m => members.some(x => x.botId === m))
+    return this.post(teamId, { sender: { kind: 'system' }, text, mentions: present, files: [], targets: this.targetsFor(teamId, present) })
+  }
+
   // ---- 文件 ----
 
   async upload(id: string, source: string, parts: AsyncIterable<{ filename: string; file: Readable & { truncated: boolean } }>) {

+ 266 - 0
apps/server/test/schedules.test.ts

@@ -0,0 +1,266 @@
+// 定时任务(docs/design/14-schedules.md):时间表计算;在对话中创建(确认卡片)、到点执行、跳过、成本超限暂停;工作组中的定时任务
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { InteractionRequest, ProviderStatusDto, ScheduleDto, 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'
+import { describeSpec, nextRun, parseSpec } from '../src/schedules/spec'
+
+type Call = (tool: string, args: Record<string, unknown>) => Promise<{ text: string; isError: boolean }>
+
+/** 假引擎:输入中带"【定时任务"时按 onScheduled 执行,否则按 script 执行(可以调用网关工具) */
+class ScriptedProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  script: (input: string, call: Call) => Promise<string> = async () => 'ok'
+  inputs: { sessionId: string; text: string }[] = []
+  cost = 0.01
+  /** 为 true 时一直执行到被中断 */
+  hang = false
+  async run(ctx: RunContext) {
+    ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
+    this.inputs.push({ sessionId: ctx.sessionId, text: ctx.input.text })
+    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 }
+    }
+    if (this.hang) {
+      await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
+    }
+    const reply = await this.script(ctx.input.text, call)
+    ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
+    return { usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: this.cost } }
+  }
+  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 apiKey: string
+let otherKey: string
+let botKey: string
+const auth = (key = apiKey) => ({ authorization: `Bearer ${key}` })
+
+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 DAILY = { kind: 'daily', time: '09:00' }
+const later = (hours: number) => Date.now() + hours * 3_600_000
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  server = await buildServer(app, { logger: log })
+  const builtin = app.profiles.getBuiltin().id
+  apiKey = app.apiKeys.create('平台A', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
+  otherKey = app.apiKeys.create('平台B', { allowedProfileIds: [builtin], defaultProfileId: builtin }).key
+  const bot = app.bots.create({ name: '日报员', avatar: 'kl:bot:blue', kind: 'worker', description: '写日报', profileId: builtin, enabled: true })
+  botKey = app.botKeys.create('平台A', [bot.id]).key
+})
+afterAll(() => {
+  app.schedules.stop()
+  return server.close()
+})
+
+describe('时间表', () => {
+  const tz = 'Asia/Shanghai'
+  // 2026-10-27 是周二;北京时间 10:00 = UTC 02:00
+  const now = Date.parse('2026-10-27T02:00:00Z')
+  const at = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString())
+
+  it('按时区计算下次执行时间', () => {
+    expect(at(nextRun({ kind: 'daily', time: '09:00' }, tz, now))).toBe('2026-10-28T01:00:00.000Z')
+    expect(at(nextRun({ kind: 'daily', time: '11:30' }, tz, now))).toBe('2026-10-27T03:30:00.000Z')
+    // 周五之后的工作日是下周一
+    expect(at(nextRun({ kind: 'weekdays', time: '09:00' }, tz, Date.parse('2026-10-30T02:00:00Z')))).toBe('2026-11-02T01:00:00.000Z')
+    expect(at(nextRun({ kind: 'weekly', days: [1, 5], time: '09:00' }, tz, now))).toBe('2026-10-30T01:00:00.000Z')
+    // 每月 31 日:11 月没有 31 日,取 30 日
+    expect(at(nextRun({ kind: 'monthly', day: 31, time: '09:00' }, tz, Date.parse('2026-11-01T00:00:00Z')))).toBe('2026-11-30T01:00:00.000Z')
+    expect(at(nextRun({ kind: 'interval', hours: 2 }, tz, now))).toBe('2026-10-27T04:00:00.000Z')
+    expect(at(nextRun({ kind: 'once', at: '2026-10-27 18:00' }, tz, now))).toBe('2026-10-27T10:00:00.000Z')
+    expect(nextRun({ kind: 'once', at: '2026-10-27 08:00' }, tz, now)).toBeNull()
+    // 其他时区
+    expect(at(nextRun({ kind: 'daily', time: '09:00' }, 'America/New_York', now))).toBe('2026-10-27T13:00:00.000Z')
+  })
+
+  it('校验与描述', () => {
+    const opts = { minIntervalHours: 1, timezone: tz, now }
+    expect(parseSpec({ kind: 'daily', time: '9:05' }, opts)).toEqual({ kind: 'daily', time: '09:05' })
+    // 相对时间:北京时间 10:00 的 90 分钟后
+    expect(parseSpec({ kind: 'once', after_minutes: 90 }, opts)).toEqual({ kind: 'once', at: '2026-10-27 11:30' })
+    expect(() => parseSpec({ kind: 'daily', time: '25:00' }, opts)).toThrow('HH:mm')
+    expect(() => parseSpec({ kind: 'interval', hours: 0.5 }, opts)).toThrow('不能小于 1')
+    expect(() => parseSpec({ kind: 'once', at: '2026-10-27 08:00' }, opts)).toThrow('已经过去')
+    expect(() => parseSpec({ kind: 'once', at: '2026-02-30 08:00' }, { ...opts, now: Date.parse('2026-01-01T00:00:00Z') })).toThrow('不存在')
+    expect(() => parseSpec({ kind: 'weekly', days: [], time: '09:00' }, opts)).toThrow('days')
+    expect(describeSpec({ kind: 'weekly', days: [1, 3, 5], time: '09:00' })).toBe('每周一、三、五 09:00')
+    expect(describeSpec({ kind: 'weekdays', time: '09:00' })).toBe('每个工作日(周一到周五)09:00')
+  })
+})
+
+describe('会话中的定时任务', () => {
+  let sessionId: string
+  let created: { text: string; isError: boolean } | undefined
+
+  async function approveNext(decision: 'allow_once' | 'deny', headers = auth()) {
+    const itx = await waitFor(() => app.interactions.pendingFor(sessionId)[0]) as InteractionRequest
+    expect(itx.kind === 'approval' && itx.tool).toBe('schedule_create')
+    expect(itx.kind === 'approval' && itx.detail?.command).toContain('接下来:')
+    const r = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers, payload: { decision } })
+    expect(r.statusCode).toBe(200)
+  }
+
+  it('AI 创建定时任务时显示确认卡片,拒绝时不创建,确认后生效', async () => {
+    sessionId = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id
+    fake.script = async (_input, call) => {
+      created = await call('schedule_create', { title: '销售日报', prompt: '汇总前一天的销售数据,写到 outputs/daily.md', schedule: DAILY })
+      return '好的'
+    }
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '每天 9 点帮我出日报' } })
+    await approveNext('deny')
+    await waitFor(() => created)
+    expect(created!.text).toContain('没有确认')
+    expect(app.schedules.list({ kind: 'session', sessionId })).toHaveLength(0)
+
+    created = undefined
+    await waitFor(() => !app.runs.activeForSession(sessionId))
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '确认,每天 9 点' } })
+    await approveNext('allow_once')
+    await waitFor(() => created)
+    expect(created!.text).toContain('已创建定时任务')
+    const list = (await server.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/schedules`, headers: auth() })).json() as ScheduleDto[]
+    expect(list).toHaveLength(1)
+    expect(list[0]).toMatchObject({ title: '销售日报', description: '每天 09:00', status: 'active', timezone: 'Asia/Shanghai', target: { kind: 'session', sessionId } })
+    expect(list[0]!.nextRunAt).toBeTruthy()
+    // 其他平台看不到、改不了
+    expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/schedules`, headers: auth(otherKey) })).statusCode).toBe(404)
+    expect((await server.inject({ method: 'DELETE', url: `/v1/schedules/${list[0]!.id}`, headers: auth(otherKey) })).statusCode).toBe(404)
+  })
+
+  it('到点时往同一个会话发出指令;会话正在执行时跳过;成本超出上限时自动暂停', async () => {
+    await waitFor(() => !app.runs.activeForSession(sessionId))
+    const [s] = app.schedules.list({ kind: 'session', sessionId })
+    fake.script = async () => '日报已生成'
+    const before = fake.inputs.length
+    await app.schedules.tick(later(25))
+    const input = await waitFor(() => fake.inputs.length > before && fake.inputs.at(-1))
+    expect(input.sessionId).toBe(sessionId)
+    expect(input.text).toMatch(/^【定时任务「销售日报」· .+】\n汇总前一天的销售数据/)
+    await waitFor(() => app.schedules.runs(s!.id)[0]?.outcome === 'completed')
+    expect(app.schedules.runs(s!.id)[0]).toMatchObject({ outcome: 'completed', costUsd: 0.01 })
+    const after = app.schedules.list({ kind: 'session', sessionId })[0]!
+    expect(after).toMatchObject({ runCount: 1, lastOutcome: 'completed', status: 'active' })
+    expect(Date.parse(after.nextRunAt!)).toBeGreaterThan(Date.now())
+
+    // 会话正在执行时跳过
+    fake.hang = true
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '做别的' } })
+    await waitFor(() => app.runs.activeForSession(sessionId))
+    await app.schedules.tick(later(50))
+    expect(app.schedules.runs(s!.id)[0]).toMatchObject({ outcome: 'skipped', note: '会话正在执行,跳过本次' })
+    app.runs.interrupt(app.runs.activeForSession(sessionId)!.runId, sessionId)
+    fake.hang = false
+    await waitFor(() => !app.runs.activeForSession(sessionId))
+
+    // 成本超出上限:执行完成后暂停
+    app.settings.update({ schedules: { maxCostUsdPerRun: 0.05 } })
+    fake.cost = 0.2
+    await app.schedules.tick(later(75))
+    const paused = await waitFor(() => app.schedules.list({ kind: 'session', sessionId }).find(x => x.status === 'paused'))
+    expect(paused.statusNote).toContain('超过每次的上限 $0.05')
+    expect(paused.nextRunAt).toBeNull()
+    fake.cost = 0.01
+
+    // 平台可以恢复、删除
+    const resumed = await server.inject({ method: 'PATCH', url: `/v1/schedules/${s!.id}`, headers: auth(), payload: { status: 'active' } })
+    expect(resumed.json()).toMatchObject({ status: 'active', statusNote: null })
+    const runsRes = (await server.inject({ method: 'GET', url: `/v1/schedules/${s!.id}/runs`, headers: auth() })).json()
+    expect(runsRes.map((r: { outcome: string }) => r.outcome)).toEqual(['completed', 'skipped', 'completed'])
+    expect((await server.inject({ method: 'DELETE', url: `/v1/schedules/${s!.id}`, headers: auth() })).statusCode).toBe(204)
+    expect(app.schedules.list({ kind: 'session', sessionId })).toHaveLength(0)
+  })
+
+  it('AI 可以列出、暂停和删除;删除会话时定时任务一起删除', async () => {
+    await waitFor(() => !app.runs.activeForSession(sessionId))
+    const results: Record<string, string> = {}
+    fake.script = async (input, call) => {
+      if (input.includes('建一个')) results.create = (await call('schedule_create', { title: '周报', prompt: '写周报', schedule: { kind: 'weekly', days: [1], time: '10:00' } })).text
+      if (input.includes('暂停')) {
+        const id = app.schedules.list({ kind: 'session', sessionId })[0]!.id
+        results.pause = (await call('schedule_update', { schedule_id: id, status: 'paused' })).text
+        results.list = (await call('schedule_list', {})).text
+      }
+      return 'ok'
+    }
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '建一个周报' } })
+    await approveNext('allow_once')
+    await waitFor(() => results.create && !app.runs.activeForSession(sessionId))
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/messages`, headers: auth(), payload: { text: '暂停周报' } })
+    await waitFor(() => results.list)
+    // 暂停不需要确认
+    expect(results.pause).toContain('已暂停')
+    expect(results.list).toContain('每周一 10:00')
+    await waitFor(() => !app.runs.activeForSession(sessionId))
+    expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sessionId}`, headers: auth() })).statusCode).toBe(204)
+    expect(app.schedules.overview().filter(x => x.target.kind === 'session' && x.target.sessionId === sessionId)).toHaveLength(0)
+  })
+})
+
+describe('工作组中的定时任务', () => {
+  it('组员创建的定时任务到点时 @ 它;工作组忙时跳过;后台可以查看全部', async () => {
+    const bot = app.bots.list()[0]!
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: auth(botKey), payload: { human: { id: 'u1', name: '张三' }, workerBotIds: [bot.id] } })).json() as TeamDetailDto
+    let created: { text: string; isError: boolean } | undefined
+    fake.script = async (input, call) => {
+      if (input.includes('每天')) created = await call('schedule_create', { title: '日报', prompt: '写日报', schedule: DAILY })
+      return input.includes('【定时任务') ? '日报写好了' : '好的'
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '每天 9 点写日报' } })
+    const pending = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingInteractions[0])
+    expect(pending.request.kind === 'approval' && pending.request.tool).toBe('schedule_create')
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: auth(botKey), payload: { decision: 'allow_once' } })
+    await waitFor(() => created)
+    const list = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/schedules`, headers: auth(botKey) })).json() as ScheduleDto[]
+    expect(list[0]).toMatchObject({ title: '日报', target: { kind: 'team', teamId: team.id }, createdByBotId: bot.id })
+    await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.every(b => b.status === 'idle'))
+
+    const messages = () => app.teams.events.list(team.id, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
+    await app.schedules.tick(later(25))
+    const sent = messages().find(m => m.text.startsWith('【定时任务「日报」'))!
+    expect(sent).toMatchObject({ sender: { kind: 'system' }, mentions: [bot.id], targets: [bot.id] })
+    await waitFor(() => messages().some(m => m.text === '日报写好了'))
+    expect(app.schedules.runs(list[0]!.id)[0]!.outcome).toBe('sent')
+
+    // 机器人在工作时跳过
+    fake.hang = true
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '先做别的' } })
+    await waitFor(() => app.teams.isBusy(team.id))
+    await app.schedules.tick(later(50))
+    expect(app.schedules.runs(list[0]!.id)[0]!.outcome).toBe('skipped')
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/bots/${bot.id}/interrupt`, headers: auth(botKey) })
+    fake.hang = false
+
+    const overview = (await server.inject({ method: 'GET', url: '/admin/v1/schedules', cookies: await adminCookie() })).json()
+    expect(overview.find((x: { id: string }) => x.id === list[0]!.id)).toMatchObject({ targetTitle: team.title, source: expect.any(String) })
+    expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${team.id}/schedules/${list[0]!.id}`, headers: auth(botKey) })).statusCode).toBe(204)
+  })
+})
+
+async function adminCookie() {
+  const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+  const c = login.cookies[0]!
+  return { [c.name]: c.value }
+}

+ 2 - 1
docs/README.md

@@ -23,7 +23,8 @@ 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(一期至三期已实施)
+│  └─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```

+ 4 - 0
docs/design/11-roadmap.md

@@ -35,6 +35,10 @@
 - ✅ 二期:组长、能力卡片、任务与子任务、限额与暂停、组长申请加入机器人;Playground 的替身组员、逐步执行、任务 / 输入 / 用量页签
 - ✅ 三期:`BotClient`、`<TeamChat>` 随 SDK 0.3.0 发布,platform-demo 增加工作组页面;子任务重试与依赖失败的处理;后台查看全部来源的工作组、复制到 Playground、回归用例(工作组)
 
+### M5:定时任务(✅ 2026-10-27,见 14)
+- 在对话中由 AI 创建(确认卡片),会话或工作组到点自动执行;Agent API、Bot API、SDK 组件与后台总览
+- 以后:执行结果的 Webhook 通知
+
 ### 以后(按需)
 - 内部平台连接器(设计见 08)
 - 专用系统用户(迁移步骤见 06 §5.3)

+ 100 - 0
docs/design/14-schedules.md

@@ -0,0 +1,100 @@
+# 14 定时任务
+
+> 状态:**已实施(2026-10-27)**。
+
+## 1. 目标
+
+用户在对话中说"每个工作日早上 9 点帮我汇总前一天的销售数据",AI 创建一个定时任务,当前会话或工作组就成为"定时会话 / 定时工作组",之后由服务端按时自动执行,结果留在同一个会话或工作组的时间线中。
+
+- 创建、修改、暂停、删除都可以通过对话完成,界面上也可以暂停和删除。
+- 不需要在后台手工配置;后台只提供总览和管理。
+- 不做结果通知(Webhook),以后按需再加。
+
+## 2. 使用流程
+
+1. 用户提出定期或将来某个时间执行的需求。
+2. AI 调用 `schedule_create`,把时间转成结构化的时间表(§3),把要做的事写成到时候可以独立执行的完整指令。
+3. 会话中出现确认卡片:时间表的中文描述、接下来 3 次的执行时间、指令内容;如果无人值守时会遇到审批,卡片上给出提示。用户确认后才生效。
+4. 会话或工作组的顶部显示定时任务栏(时间表、下次执行时间、上次结果,可以暂停、恢复、删除)。
+5. 到点时,服务端往同一个会话或工作组中发一条消息:`【定时任务「标题」· 11月3日 周一 09:00】` + 指令,AI 照常执行。
+
+## 3. 时间表
+
+AI 填写结构化的时间表,服务端校验并计算下次执行时间,不依赖 AI 记时间:
+
+| kind | 字段 | 说明 |
+|---|---|---|
+| `once` | `at`:`YYYY-MM-DD HH:mm` | 只执行一次,执行后结束 |
+| `daily` | `time`:`HH:mm` | 每天 |
+| `weekdays` | `time` | 周一到周五 |
+| `weekly` | `days`(1=周一 … 7=周日)、`time` | 每周的某几天 |
+| `monthly` | `day`(1–31)、`time` | 每月某天;超过当月天数时取最后一天 |
+| `interval` | `hours` | 从创建时起每 N 小时 |
+
+- 时区:系统设置 `schedules.timezone`(默认 Asia/Shanghai)。
+- 最小间隔:`schedules.minIntervalHours`(默认 1 小时),只约束 `interval`。
+
+## 4. 工具(MCP Gateway 内置)
+
+普通会话和工作组中的所有机器人都有这组工具(工具列表固定,Codex 沿用第一次连接时的工具列表也不受影响):
+
+| 工具 | 说明 |
+|---|---|
+| `schedule_create(title, prompt, schedule)` | 创建;显示确认卡片,用户确认后生效 |
+| `schedule_list()` | 本会话 / 本工作组的定时任务 |
+| `schedule_update(schedule_id, title?, prompt?, schedule?, status?)` | 修改时间表或指令需要确认;只暂停或恢复不需要 |
+| `schedule_delete(schedule_id)` | 删除 |
+
+- 普通会话中创建的定时任务属于这个会话;工作组中创建的属于工作组。
+- 工作组中由组员创建时,到点的消息 @ 这个组员;由组长创建时交给组长。
+- 引擎自带的定时工具(Claude 的 CronCreate、ScheduleWakeup 等)继续禁用:它们依赖引擎进程常驻。
+
+## 5. 执行
+
+- 调度器每 30 秒检查一次到期的定时任务;服务重启期间错过的执行在启动后补跑一次(不逐次补跑),然后从当前时间计算下次执行时间。
+- **会话**:以用户消息的形式开始一轮(与平台发消息相同)。会话正在执行时跳过本次并记录。
+  - 每次执行的时长上限 `schedules.maxMinutesPerRun`(默认 60 分钟),超时中断。
+  - 每次执行的成本上限 `schedules.maxCostUsdPerRun`(默认 $2):一轮结束后超出时暂停这个定时任务并记录原因,避免持续超支。
+- **工作组**:以系统消息发到工作组,按工作组的规则交给组长或被 @ 的组员。还有进行中的任务或机器人在工作时跳过本次;工作组暂停或关闭时跳过。成本由工作组的任务限额约束。
+- **无人值守时的审批**:照常显示审批卡片,等待 `limits.interactionTimeoutSec`(默认 10 分钟)后按超时拒绝,时间线中留有记录。
+- 每次执行都记录在执行记录中:开始 / 完成 / 失败 / 跳过 / 已发出(工作组),以及原因和成本。
+- 会话或工作组删除时,它的定时任务一起删除。
+
+## 6. 限制
+
+| 设置 | 默认 |
+|---|---|
+| `schedules.maxPerTarget`:每个会话 / 工作组的定时任务数 | 5 |
+| `schedules.minIntervalHours` | 1 |
+| `schedules.maxMinutesPerRun` | 60 |
+| `schedules.maxCostUsdPerRun` | 2 |
+| `schedules.timezone` | Asia/Shanghai |
+
+## 7. 数据模型
+
+- `schedules`:id、目标(`session_id` 或 `team_id`)、创建者(工作组中的机器人 id)、标题、指令、时间表、时区、状态(active / paused / ended)、下次执行时间、上次执行时间与结果、执行次数。
+- `schedule_runs`:每次执行的时间、结果、Run id、成本、说明。
+
+## 8. API
+
+| API | 接口 |
+|---|---|
+| Agent API | `GET /v1/sessions/:id/schedules`;`PATCH /v1/schedules/:id`(`{ status }`);`DELETE /v1/schedules/:id`;`GET /v1/schedules/:id/runs` |
+| Bot API | `GET /bot/v1/teams/:id/schedules`;`PATCH`、`DELETE /bot/v1/teams/:id/schedules/:scheduleId`;`GET …/runs` |
+| 后台 | `GET /admin/v1/schedules`(全部,带目标信息);修改、删除、执行记录同上 |
+
+SDK:`AgentClient.sessions.schedules`、`AgentClient.schedules.*`、`BotClient.teams.schedules` 等;`<AgentChat>` 与 `<TeamChat>` 顶部显示定时任务栏。
+
+## 9. 界面
+
+- `<AgentChat>`、`<TeamChat>`:有定时任务时顶部显示定时任务栏,可以展开查看、暂停、恢复、删除。
+- 后台"定时任务"页面:全部定时任务,按状态筛选,查看执行记录,跳转到对应的会话或工作组。
+- 系统设置增加"定时任务"一节(§6)。
+
+## 10. 实施记录(2026-10-27)
+
+- 系统提示词的"运行环境"中说明了定时任务工具和时区。真实验收时,Claude 一开始用 `sleep 120` 等待,没有使用工具:MCP 工具在 Claude 中按需搜索,模型不知道它存在。
+- `once` 除了 `at`,还可以填 `after_minutes`(多少分钟后),用于"两小时后"这类相对时间,模型不需要知道当前时间。`schedule_list` 的结果中带有当前时间。
+- 已用真实 Claude(haiku)验收:
+  - "两分钟后提醒我喝水":模型换算出执行时间并创建一次性任务,确认卡片显示时间表、下次执行时间和无人值守提示;到点后在同一个会话中执行,执行记录为完成,成本 $0.006。
+  - "每个工作日早上 9 点出日报":创建每个工作日 09:00 的定时任务,Playground 顶部显示定时任务栏,后台"定时任务"页面列出。

+ 21 - 3
docs/integration.md

@@ -47,9 +47,9 @@ app.post('/api/agent-token', requireLogin, async (req, res) => {
 SDK 由 AgentServer 自己提供,三个包需要一起安装:
 
 ```bash
-pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.3.1.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-client-0.3.1.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.3.1.tgz
+pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.4.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-client-0.4.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.4.0.tgz
 ```
 
 | 包 | 内容 |
@@ -137,6 +137,24 @@ await client.usage(30)                                    // 本平台最近 30
 
 事件类型见 [design/04-api.md](design/04-api.md)。`useChatSession` 的 `state` 也可以单独使用,它是已经整理好的时间线。
 
+## 5.1 定时任务
+
+用户在对话中说"每个工作日早上 9 点帮我出日报",AI 会创建定时任务:对话中出现确认卡片,用户确认后生效,之后到点由 AgentServer 自动往同一个会话(或工作组)发出指令并执行,结果留在时间线中。设计见 [design/14-schedules.md](design/14-schedules.md)。
+
+- `<AgentChat>`、`<TeamChat>` 已经处理了确认卡片,并在顶部显示定时任务栏(可以暂停、恢复、删除),平台不需要额外开发。
+- 自己渲染界面时:
+
+```ts
+await client.sessions.schedules(sessionId)                  // 会话的定时任务
+await client.schedules.setStatus(scheduleId, 'paused')     // 暂停或恢复('active')
+await client.schedules.remove(scheduleId)
+await client.schedules.runs(scheduleId)                    // 执行记录
+await bots.teams.schedules(teamId)                         // 工作组的定时任务(BotClient)
+```
+
+- 定时执行时没有人在场:需要审批的操作会在等待超时后被拒绝。希望定时任务稳定执行的场景,使用"自动执行"或"只读"的 Profile。
+- 每次执行有时长和成本上限(后台"设置 › 定时任务"),超出成本上限时定时任务自动暂停。
+
 ## 6. 外观
 
 - 亮色/暗色默认跟随系统;在 `<html>` 上设置 `data-theme="light"` 或 `"dark"` 可以强制指定。

+ 3 - 3
examples/platform-demo/package.json

@@ -9,9 +9,9 @@
     "typecheck": "vue-tsc --noEmit"
   },
   "dependencies": {
-    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.3.1.tgz",
-    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.3.1.tgz",
-    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.3.1.tgz",
+    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.4.0.tgz",
+    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.4.0.tgz",
+    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.4.0.tgz",
     "vue": "^3.5.44"
   },
   "devDependencies": {

+ 1 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.3.1",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "exports": {

+ 2 - 0
packages/chat-ui/src/components/AgentChat.vue

@@ -5,6 +5,7 @@ import type { FileDto } from '@agent-server/shared'
 import { downloadFile, provideClient } from '../context'
 import type { ChatSession } from '../store'
 import Composer from './Composer.vue'
+import ScheduleBar from './ScheduleBar.vue'
 import Timeline from './Timeline.vue'
 
 const props = defineProps<{ client: AgentClient; session: ChatSession; readonly?: boolean; placeholder?: string }>()
@@ -63,6 +64,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 
 <template>
   <div class="agent-chat">
+    <ScheduleBar :source="session" :refresh-key="state.runState" :readonly="readonly" />
     <div ref="scroller" class="scroller" @scroll="onScroll">
       <div class="column">
         <div v-if="state.loading" class="empty">加载中…</div>

+ 137 - 0
packages/chat-ui/src/components/ScheduleBar.vue

@@ -0,0 +1,137 @@
+<script setup lang="ts">
+// 定时任务栏(docs/design/14-schedules.md §9):会话或工作组有定时任务时显示在顶部,可以展开查看、暂停、恢复、删除。
+// 定时任务由 AI 在对话中创建;refreshKey 变化时(例如一轮执行结束)重新加载
+import { computed, ref, watch } from 'vue'
+import type { ScheduleDto } from '@agent-server/shared'
+
+const props = defineProps<{
+  source: {
+    schedules: () => Promise<ScheduleDto[]>
+    setScheduleStatus: (id: string, status: 'active' | 'paused') => Promise<unknown>
+    removeSchedule: (id: string) => Promise<unknown>
+  }
+  refreshKey: string
+  readonly?: boolean
+}>()
+
+const list = ref<ScheduleDto[]>([])
+const open = ref(false)
+const error = ref('')
+
+async function load() {
+  try {
+    list.value = await props.source.schedules()
+  } catch {
+    // 没有权限或网络错误时不显示
+  }
+}
+watch(() => props.refreshKey, () => void load(), { immediate: true })
+
+const shown = computed(() => list.value.filter(s => s.status !== 'ended'))
+const first = computed(() => shown.value.find(s => s.status === 'active') ?? shown.value[0])
+
+const OUTCOME = { running: '执行中', completed: '完成', failed: '失败', skipped: '跳过', sent: '已发出' } as const
+
+function when(iso: string | null) {
+  if (!iso) return '—'
+  const d = new Date(iso)
+  const today = new Date()
+  const sameDay = d.toDateString() === today.toDateString()
+  const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
+  return sameDay ? `今天 ${hm}` : `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`
+}
+
+async function act(fn: () => Promise<unknown>) {
+  error.value = ''
+  try {
+    await fn()
+    await load()
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+}
+const toggle = (s: ScheduleDto) => act(() => props.source.setScheduleStatus(s.id, s.status === 'active' ? 'paused' : 'active'))
+function remove(s: ScheduleDto) {
+  if (confirm(`删除定时任务「${s.title}」?`)) void act(() => props.source.removeSchedule(s.id))
+}
+</script>
+
+<template>
+  <div v-if="shown.length" class="schedule-bar">
+    <button type="button" class="summary" :aria-expanded="open" @click="open = !open">
+      <span class="codicon codicon-watch" />
+      <span class="label">定时</span>
+      <span class="text">
+        {{ first!.title }} · {{ first!.description }}<template v-if="first!.status === 'active'"> · 下次 {{ when(first!.nextRunAt) }}</template><template v-else> · 已暂停</template>
+      </span>
+      <span v-if="shown.length > 1" class="more">等 {{ shown.length }} 个</span>
+      <span class="codicon" :class="open ? 'codicon-chevron-up' : 'codicon-chevron-down'" />
+    </button>
+    <div v-if="open" class="list">
+      <div v-for="s in shown" :key="s.id" class="item">
+        <div class="head">
+          <span class="title">{{ s.title }}</span>
+          <span class="desc">{{ s.description }}</span>
+          <span class="flex" />
+          <template v-if="!readonly">
+            <button type="button" class="btn" @click="toggle(s)">{{ s.status === 'active' ? '暂停' : '恢复' }}</button>
+            <button type="button" class="btn" @click="remove(s)">删除</button>
+          </template>
+        </div>
+        <div class="meta">
+          <template v-if="s.status === 'active'">下次 {{ when(s.nextRunAt) }}</template>
+          <template v-else>已暂停{{ s.statusNote ? `:${s.statusNote}` : '' }}</template>
+          <template v-if="s.lastRunAt"> · 上次 {{ when(s.lastRunAt) }}{{ s.lastOutcome ? ` ${OUTCOME[s.lastOutcome]}` : '' }}</template>
+          · 已执行 {{ s.runCount }} 次
+        </div>
+        <div class="prompt">{{ s.prompt }}</div>
+      </div>
+      <div class="hint">在对话中告诉 AI 就可以新建、修改或删除定时任务。</div>
+      <div v-if="error" class="err">{{ error }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.schedule-bar {
+  flex: none;
+  border-bottom: 1px solid var(--app-input-border);
+  background: var(--app-input-secondary-background);
+  font-size: 12.5px;
+  color: var(--app-primary-foreground);
+}
+.summary {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  width: 100%;
+  height: 30px;
+  padding: 0 12px;
+  border: none;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+  text-align: left;
+}
+.label { font-weight: 600; }
+.text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--app-secondary-foreground); }
+.more { color: var(--app-secondary-foreground); white-space: nowrap; }
+.list { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; padding: 4px 12px 10px; }
+.item { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; border: 1px solid var(--app-input-border); border-radius: var(--corner-radius-medium, 6px); background: var(--app-primary-background); }
+.head { display: flex; align-items: center; gap: 8px; }
+.title { font-weight: 600; }
+.desc, .meta, .hint { color: var(--app-secondary-foreground); }
+.flex { flex: 1; }
+.prompt { white-space: pre-wrap; max-height: 60px; overflow: hidden; color: var(--app-secondary-foreground); }
+.btn {
+  padding: 1px 8px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 4px;
+  background: transparent;
+  color: inherit;
+  font-size: 12px;
+  cursor: pointer;
+}
+.err { color: var(--app-error-foreground); }
+</style>

+ 4 - 0
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 ScheduleBar from './ScheduleBar.vue'
 import TeamCard from './TeamCard.vue'
 
 type OpenedFile = { fileId: string; path: string; mime: string; size: number }
@@ -30,6 +31,8 @@ function openFile(f: OpenedFile) {
 }
 
 const state = computed(() => props.session.state)
+// 机器人状态变化(例如一轮执行结束)时重新加载定时任务
+const scheduleKey = computed(() => state.value.team?.bots.map(b => b.status).join() ?? '')
 const team = computed(() => state.value.team)
 const bots = computed(() => team.value?.bots ?? [])
 const busy = computed(() => bots.value.filter(b => b.status !== 'idle'))
@@ -118,6 +121,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 
 <template>
   <div class="agent-chat team-chat">
+    <ScheduleBar :source="session" :refresh-key="scheduleKey" :readonly="readonly" />
     <div ref="scroller" class="scroller" @scroll="onScroll">
       <div class="column">
         <div v-if="state.loading" class="empty">加载中…</div>

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

@@ -17,3 +17,4 @@ export { applyTeamEvent, useTeamSession, type TeamItem, type TeamSession, type T
 export { AVATAR_COLORS, AVATAR_GROUPS, AVATAR_ICONS, avatarText, avatarValue, parseAvatar, type ParsedAvatar } from './avatar'
 export { KEYLINE_AVATAR_SVGS } from './keyline-avatars'
 export { basename, fileIcon, formatSize, renderMarkdown, toolLabel } from './util'
+export { default as ScheduleBar } from './components/ScheduleBar.vue'

+ 4 - 0
packages/chat-ui/src/store.ts

@@ -208,6 +208,10 @@ export function useChatSession(client: AgentClient, sessionId: string, opts: { l
       if (state.activeRunId) await client.runs.interrupt(state.activeRunId)
     },
     reply: (interactionId: string, body: InteractionResponse) => client.interactions.reply(interactionId, body),
+    /** 定时任务(docs/design/14-schedules.md) */
+    schedules: () => client.sessions.schedules(sessionId),
+    setScheduleStatus: (id: string, status: 'active' | 'paused') => client.schedules.setStatus(id, status),
+    removeSchedule: (id: string) => client.schedules.remove(id),
     dispose() {
       unsubscribe?.()
     },

+ 4 - 0
packages/chat-ui/src/team-store.ts

@@ -124,6 +124,10 @@ export function useTeamSession(client: BotClient, teamId: string) {
     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),
+    /** 定时任务(docs/design/14-schedules.md) */
+    schedules: () => client.teams.schedules(teamId),
+    setScheduleStatus: (id: string, status: 'active' | 'paused') => client.teams.setScheduleStatus(teamId, id, status),
+    removeSchedule: (id: string) => client.teams.removeSchedule(teamId, id),
     dispose() {
       disposed = true
       unsubscribe?.()

+ 1 - 1
packages/client/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/client",
-  "version": "0.3.1",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "exports": {

+ 8 - 0
packages/client/src/bot.ts

@@ -6,6 +6,8 @@ import type {
   FileDto,
   InteractionResponse,
   PatchTeamBody,
+  ScheduleDto,
+  ScheduleRunDto,
   SendTeamMessageBody,
   StubTaskBody,
   TeamDetailDto,
@@ -55,6 +57,12 @@ export class BotClient extends HttpClient {
     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`),
+    /** 工作组的定时任务(由机器人在对话中创建) */
+    schedules: (id: string) => this.request<ScheduleDto[]>('GET', `/bot/v1/teams/${id}/schedules`),
+    setScheduleStatus: (id: string, scheduleId: string, status: 'active' | 'paused') =>
+      this.request<ScheduleDto>('PATCH', `/bot/v1/teams/${id}/schedules/${scheduleId}`, { status }),
+    removeSchedule: (id: string, scheduleId: string) => this.request<void>('DELETE', `/bot/v1/teams/${id}/schedules/${scheduleId}`),
+    scheduleRuns: (id: string, scheduleId: string) => this.request<ScheduleRunDto[]>('GET', `/bot/v1/teams/${id}/schedules/${scheduleId}/runs`),
     /** 仅后台:替身组员的子任务结果 */
     stubResult: (id: string, taskId: string, body: StubTaskBody) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/tasks/${taskId}/stub`, body),
     /** 仅后台:逐步执行时放行一步 */

+ 11 - 0
packages/client/src/index.ts

@@ -11,6 +11,8 @@ import type {
   ProfileDto,
   ProviderStatusDto,
   RunDto,
+  ScheduleDto,
+  ScheduleRunDto,
   UsageSummaryDto,
   SendMessageBody,
   SessionDetailDto,
@@ -42,6 +44,8 @@ export class AgentClient extends HttpClient {
     events: (id: string, after = 0) => this.request<{ items: AgentEvent[]; lastSeq: number }>('GET', `/v1/sessions/${id}/events?after=${after}`),
     send: (id: string, body: SendMessageBody) => this.request<RunDto>('POST', `/v1/sessions/${id}/messages`, body),
     files: (id: string) => this.request<FileDto[]>('GET', `/v1/sessions/${id}/files`),
+    /** 会话的定时任务(由 AI 在对话中创建) */
+    schedules: (id: string) => this.request<ScheduleDto[]>('GET', `/v1/sessions/${id}/schedules`),
     upload: (id: string, files: Blob[] | File[]) => {
       const form = new FormData()
       for (const f of files) form.append('file', f, (f as File).name ?? 'upload')
@@ -49,6 +53,13 @@ export class AgentClient extends HttpClient {
     },
   }
 
+  /** 定时任务:暂停、恢复、删除、执行记录 */
+  schedules = {
+    setStatus: (id: string, status: 'active' | 'paused') => this.request<ScheduleDto>('PATCH', `/v1/schedules/${id}`, { status }),
+    remove: (id: string) => this.request<void>('DELETE', `/v1/schedules/${id}`),
+    runs: (id: string) => this.request<ScheduleRunDto[]>('GET', `/v1/schedules/${id}/runs`),
+  }
+
   runs = {
     get: (id: string) => this.request<RunDto>('GET', `/v1/runs/${id}`),
     interrupt: (id: string) => this.request<{ ok: true }>('POST', `/v1/runs/${id}/interrupt`),

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.3.1",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "exports": {

+ 1 - 0
packages/shared/src/index.ts

@@ -2,3 +2,4 @@ export * from './events'
 export * from './api'
 export * from './settings'
 export * from './teams'
+export * from './schedules'

+ 60 - 0
packages/shared/src/schedules.ts

@@ -0,0 +1,60 @@
+// 定时任务(docs/design/14-schedules.md):在对话中创建,到点由服务端往同一个会话或工作组发出指令
+
+/** 时间表(时区见 ScheduleDto.timezone) */
+export type ScheduleSpec =
+  | { kind: 'once'; at: string }
+  | { kind: 'daily'; time: string }
+  | { kind: 'weekdays'; time: string }
+  | { kind: 'weekly'; days: number[]; time: string }
+  | { kind: 'monthly'; day: number; time: string }
+  | { kind: 'interval'; hours: number }
+
+export type ScheduleStatus = 'active' | 'paused' | 'ended'
+/** running:会话中正在执行;sent:已发到工作组;skipped:上次还没执行完等原因跳过 */
+export type ScheduleOutcome = 'running' | 'completed' | 'failed' | 'skipped' | 'sent'
+
+export type ScheduleTarget = { kind: 'session'; sessionId: string } | { kind: 'team'; teamId: string }
+
+export interface ScheduleDto {
+  id: string
+  target: ScheduleTarget
+  title: string
+  /** 到点时发出的指令 */
+  prompt: string
+  spec: ScheduleSpec
+  timezone: string
+  /** 时间表的中文描述,如"每个工作日 09:00" */
+  description: string
+  status: ScheduleStatus
+  /** 暂停的原因(例如成本超出上限) */
+  statusNote: string | null
+  nextRunAt: string | null
+  lastRunAt: string | null
+  lastOutcome: ScheduleOutcome | null
+  runCount: number
+  /** 工作组中创建它的机器人 */
+  createdByBotId: string | null
+  createdAt: string
+  updatedAt: string
+}
+
+export interface ScheduleRunDto {
+  id: number
+  scheduleId: string
+  ts: string
+  outcome: ScheduleOutcome
+  runId: string | null
+  costUsd: number | null
+  note: string | null
+}
+
+/** 后台总览:带目标的名称与来源 */
+export interface ScheduleOverviewDto extends ScheduleDto {
+  targetTitle: string
+  /** API Key / Bot Key id 或 'admin' */
+  source: string
+}
+
+export interface PatchScheduleBody {
+  status: 'active' | 'paused'
+}

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

@@ -28,6 +28,8 @@ export interface AppSettings {
     workspaceMaxMB: number
   }
   retention: { workspaceDays: number }
+  /** 定时任务(docs/design/14-schedules.md §6) */
+  schedules: { timezone: string; minIntervalHours: number; maxPerTarget: number; maxMinutesPerRun: number; maxCostUsdPerRun: number | null }
   /** 工作组的默认限额(创建工作组时可以调低,docs/design/13-bot-teams.md §7) */
   teamLimits: TeamLimits
   providers: {