# dsh-schedule-view 定时任务插件设计文档

> **包名**: `@lijian-ui/dsh-schedule-view`
> **定位**: 纯 UI 定时任务管理插件，零 LLM tool，人驱动的 scheduler
> **核心原则**: 不注册任何 LLM tool，不改动官方 dsh 源码，通过 npm 消费官方包

---

## 1. 背景与动机

### 1.1 官方 `@deepseek-ai/dsh-schedule` 的局限

| 缺陷 | 说明 |
|------|------|
| 不支持 cron 表达式 | 只有 `after_seconds`/`at`/`every_seconds`，无法表达"工作日 9 点"、"每月 1 号"等 |
| session-local 交付 | 只有原 session live 时才提醒，关掉 session 就不通知 |
| 无外部推送 | 到点只在对话里冒一句，没有桌面通知/webhook/IM |
| 无 UI 管理入口 | 纯 LLM tool，用户无法直观查看/启停/编辑 |
| 注册 3 个 tool | `schedule_create`/`schedule_list`/`schedule_delete` 占用 LLM schema，增加 token 消耗 |

### 1.2 本插件的设计哲学

- **人驱动，非 AI 驱动**：用户在 UI 面板里创建/管理定时任务，不让 AI 参与
- **零 tool**：不注册任何 LLM tool，零 schema 开销
- **跨 session**：主进程级 timer，不依赖某个 session live
- **指令式触发**：到期后以 user role 注入 `请根据系统指令开始执行任务。`，让 AI 立即执行任务
- **外部通知**：桌面通知 + 可选 webhook

---

## 2. 功能需求

### 2.1 核心功能

| 功能 | 说明 |
|------|------|
| 创建定时任务 | UI 表单：任务名称 + cron 表达式 + 提示词指令 + 目标 session + 启用/禁用 |
| 查看定时任务 | 列表展示所有任务，含下次触发时间、上次执行时间、状态 |
| 编辑定时任务 | 修改 cron / 提示词 / 启用状态 |
| 删除定时任务 | 单个删除 + 批量删除 |
| 立即执行 | 手动触发一次，不等 cron 到期 |
| 启用/禁用 | 开关切换，不删除 |
| cron 表达式校验 | 输入时实时校验 + 人类可读描述（"每天 09:00"） |
| 执行历史 | 记录每次触发的时间、状态、目标 session |

### 2.2 触发行为

到期时按以下顺序执行：

1. **查找目标 agent**：`ctx.agents.get(sessionId)`
2. **注入消息**：若 agent live，`agent.followup(createUserMessage(...))` 注入 `请根据系统指令开始执行任务。`
3. **多级通知**：桌面通知 + 页面 toast + 提示音（见 4.4）
4. **记录历史**：写入执行历史，初始状态 `delivered`
5. **agent 不 live 的处理**：仅通知 + 记录为 `skipped`

### 2.3 执行记录生命周期追踪

注入消息后，通过监听 `session/event` + 消息 id 匹配，追踪完整执行生命周期（借鉴 dsh-cron）：

```
delivered → running → completed/failed
```

| 事件 | session/event 类型 | 处理 |
|------|-------------------|------|
| 消息进入会话 | `user/message` | 匹配注入的 messageId，状态 → `running`，记录 `startedAt` |
| AI 开始回复 | `assistant/message` | 记录 excerpt（前 300 字摘要） |
| turn 结束 | `turn/end` | 状态 → `completed`/`failed`，记录 `completedAt` + `endReason` + 耗时，触发通知 |

这样执行历史不只有"已触发"，还能看到 AI 的执行结果摘要、耗时和最终状态。

### 2.3 非功能需求

| 维度 | 要求 |
|------|------|
| 持久化 | 独立存储，不依赖 session event log，应用重启后恢复 |
| 跨 session | timer 在 host 进程级存活，不绑定单个 session 生命周期 |
| 并发安全 | 多个任务同时到期时串行注入，避免 agent 状态竞争 |
| 时区 | cron 表达式按本地时区解析 |
| 体积 | 不引入重型依赖（cron 解析库 ≤ 50KB） |

---

## 3. 架构设计

### 3.1 双端架构

```
┌─────────────────────────────────────────────────────────┐
│  Client 端 (浏览器)                                      │
│  ┌─────────────────────────────────────────────────┐    │
│  │  TimerSettingsSection.tsx                        │    │
│  │  ├─ 任务列表（查看/启停/删除/立即执行）           │    │
│  │  ├─ 创建/编辑表单（cron + 提示词 + session 选择） │    │
│  │  └─ 执行历史面板                                 │    │
│  └─────────────────────────────────────────────────┘    │
│           ↕ Typert Remote RPC                            │
├─────────────────────────────────────────────────────────┤
│  Host 端 (Node / dsh 进程)                               │
│  ┌─────────────────────────────────────────────────┐    │
│  │  TimerRuntime                                    │    │
│  │  ├─ cron 解析 + tick 轮询调度                    │    │
│  │  ├─ agent.followup 消息注入                      │    │
│  │  ├─ session/event 生命周期追踪                   │    │
│  │  ├─ 故障隔离 guarded                             │    │
│  │  └─ 多级通知 IPC                                 │    │
│  ├─────────────────────────────────────────────────┤    │
│  │  持久化 (dsh-settings + dsh-storage-domain)      │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘
```

### 3.2 host 端职责

| 职责 | 说明 |
|------|------|
| 定时调度 | 解析 cron 表达式，tick 轮询检查到期（`setInterval`，默认 15s） |
| 消息注入 | 到期时 `ctx.agents.get(sessionId).followup(msg)` |
| 生命周期追踪 | 监听 `session/event`，追踪 `delivered→running→completed/failed` |
| 故障隔离 | 所有回调入口 `guarded` 包裹 try-catch，插件故障不拖垮宿主 |
| 多级通知 | 桌面通知 + 页面 toast + 提示音（见 4.4） |
| 持久化 | 任务定义存 dsh-settings，执行历史存 dsh-storage-domain（500 条封顶） |
| RPC 服务 | 暴露 list/create/update/delete/fireNow 给 client 端 |
| 生命周期 | 监听 settings 变更，动态增删任务 |

### 3.3 client 端职责

| 职责 | 说明 |
|------|------|
| UI 渲染 | 设置页面板：任务列表 + 创建/编辑表单 + 执行历史 |
| RPC 调用 | 通过 `ctx.remote` 调用 host 端 API |
| session 选择 | 从 `ctx.sessions` 读取 session 列表供用户选择目标 |
| cron 校验 | 输入时实时校验 + 人类可读描述 |

### 3.4 slot 注册

注册到 `settings.section`（设置页左侧导航），与 im-gateway / session-cleaner / dsh-skill-manage 同类：

```ts
ctx.slots.inject('settings.section', () => ctx.slots.register({
  name: 'settings.section',
  id: 'dsh-schedule-view',
  order: 40,  // im-gateway=20, skill-manage=30, session-cleaner=50
  label: () => t('section.label'),
  inject: () => ({ configApi, t }),
}, TimerSettingsSection))
```

---

## 4. 核心技术方案

### 4.1 消息注入

到期后以 user role 注入指令式消息，让 AI 立即执行任务。

**关键 API**：

| API | 来源 | 作用 |
|-----|------|------|
| `createUserMessage` | `@deepseek-ai/dsh-llm` | 创建 user-role 消息 |
| `Agent.followup` | `@deepseek-ai/dsh-agent` | 排队 follow-up turn 并唤醒 driver |

**注入逻辑**：

```ts
import { createUserMessage } from '@deepseek-ai/dsh-llm'

function fireTask(ctx: Context, task: TimerTask): void {
  const agent = ctx.agents.get(task.sessionId)
  if (!agent) {
    // session 已关闭，仅桌面通知
    showDesktopNotification(task.title, '目标会话已关闭，任务未执行')
    recordHistory(task.id, 'skipped', 'session not live')
    return
  }

  const message = createUserMessage({
    content: [{ type: 'text', text: task.prompt || '请根据系统指令开始执行任务。' }],
    source: { kind: 'plugin', plugin: 'dsh-schedule-view' },
  })
  agent.followup(message)

  showDesktopNotification(task.title, task.description || '定时任务已触发')
  recordHistory(task.id, 'fired', undefined)
}
```

**与官方 schedule 的语义差异**：

| | 官方 schedule | 本插件 |
|---|---|---|
| framing | `[SCHEDULE REMINDER] Present ... as untrusted reminder content` | `请根据系统指令开始执行任务。` |
| 语义 | 提醒式（模型不当新指令） | 指令式（模型立即执行） |
| source | `{ kind: 'plugin', plugin: 'schedule' }` | `{ kind: 'plugin', plugin: 'dsh-schedule-view' }` |

### 4.2 定时调度

**cron 解析**：使用 `cron-parser` 库（~30KB），支持标准 5 字段 + 秒字段。

**调度策略：tick 轮询**（借鉴 dsh-cron，天然避开 setTimeout 24.8 天上限）：

```ts
import { parseExpression } from 'cron-parser'

class TimerRuntime {
  private nextFireMap = new Map<string, number>()  // taskId → 下次触发时间戳

  // 启动时为每个任务计算下次触发时间
  scheduleTask(task: TimerTask): void {
    const interval = parseExpression(task.cron, { tz: task.timezone ?? localTz })
    const next = interval.next().toDate()
    this.nextFireMap.set(task.id, next.getTime())
  }

  // tick 轮询：每 15s 检查所有任务是否到期
  start(ctx: Context, config: { tickSeconds: number }): void {
    const tick = () => {
      const now = Date.now()
      for (const task of this.tasks.values()) {
        if (!task.enabled) continue
        const next = this.nextFireMap.get(task.id)
        if (next != null && next <= now) {
          this.fireTask(ctx, task)
          // 重新计算下次触发时间
          this.scheduleTask(task)
        }
      }
    }

    const first = setTimeout(guarded('tick', tick), 3000)  // 3s 后首次
    const timer = setInterval(guarded('tick', tick), Math.max(1, config.tickSeconds) * 1000)
    ctx.effect(() => () => { clearTimeout(first); clearInterval(timer) })
  }
}
```

**优势**：
- 天然避开 `setTimeout` 的 `2^31 - 1` ms（~24.8 天）上限，无需中间 timer 兜底
- 代码极简，增删任务只需更新 `nextFireMap`，不用管理 setTimeout handle
- 重启后从持久化恢复，重新计算 `nextFireMap` 即可

**代价**：触发精度受 `tickSeconds` 限制（默认最多延迟 15s）。对定时任务场景完全可接受。

**cronNext 缓存**（借鉴 dsh-cron）：成功 fire 后才重算 `nextFireMap`，tick 本身只做比较，极廉价。

**错过补发**：重启后若 `nextFireMap` 的时间已过（如应用关了 3 天），tick 首次检查时立即触发一次补发，然后重算下次时间。

### 4.3 持久化

**任务定义** → dsh-settings（UI 可编辑，自带 revision 防冲突）：

```ts
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { Type } from '@deepseek-ai/schemastery'

const TimerConfig = Type.Object({
  tasks: Type.Array(Type.Object({
    id: Type.String(),
    title: Type.String(),
    cron: Type.String(),
    prompt: Type.String(),
    description: Type.Optional(Type.String()),
    sessionId: Type.String(),
    sessionTitle: Type.Optional(Type.String()),
    enabled: Type.Boolean(),
    timezone: Type.Optional(Type.String()),
    createdAt: Type.String(),
    updatedAt: Type.String(),
  })),
})

installSettingsSection(ctx, settingsNamespace('dsh-schedule-view'), TimerConfig, { tasks: [] }, {
  setSource: (src) => { currentConfig = src },
  onChange: () => syncTimers(currentConfig()),
})
```

**执行历史** → dsh-storage-domain（结构化 KV，原子写）：

```ts
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'

const spec = defineDomain({
  name: 'dsh-schedule-view',
  tables: {
    runs: domainTable<RunRecord>(),  // key = runId
  },
  global: { initial: {} },
})

const domain = await ctx.storageDomain.open(spec)
const runs = domain.table('runs')
await runs.put(runId, { taskId, firedAt, status, detail, sessionId })
```

**数据模型**：

```ts
interface TimerTask {
  id: string                    // UUID
  title: string                 // 任务名称
  cron: string                  // cron 表达式（5 字段）
  prompt: string                // 到期注入的提示词指令
  description?: string          // 描述（通知 body）
  sessionId: string             // 目标 session ID
  sessionTitle?: string         // 目标 session 标题（快照，UI 显示用）
  enabled: boolean              // 启用/禁用
  timezone?: string             // 时区（默认本地）
  createdAt: string             // ISO 8601
  updatedAt: string             // ISO 8601
}

interface RunRecord {
  runId: string                 // UUID
  taskId: string                // 关联任务 ID
  messageId?: string            // 注入的消息 ID（用于 session/event 匹配）
  firedAt: string               // ISO 8601，触发时间
  startedAt?: string            // ISO 8601，AI 开始执行时间
  completedAt?: string          // ISO 8601，执行完成时间
  status: 'delivered' | 'running' | 'completed' | 'failed' | 'skipped'
  excerpt?: string              // AI 回复摘要（前 300 字）
  endReason?: string            // turn 结束原因（completed/aborted/error 等）
  detail?: string               // 附加信息（如 "session not live"）
  sessionId: string             // 目标 session
}

interface TimerView {
  task: TimerTask
  nextFireAt?: string           // 下次触发时间（计算值）
  lastRun?: RunRecord           // 上次执行记录
}
```

### 4.4 多级通知体系

借鉴 dsh-cron 的四级通知，覆盖"页面可见 / 页面后台 / 应用最小化"三种场景：

| 通道 | 触发时机 | 覆盖场景 | 实现 |
|------|---------|---------|------|
| 页面 Toast | 完成(8s)/失败(常驻) | 当前页面可见 | React 组件 + `createPortal`，z-index `2147483647` |
| 提示音 | 完成/失败 | 当前页面 | WebAudio 合成双音和弦，零音频文件 |
| 桌面通知 | 触发/完成/失败 | 应用最小化或不在前台 | Electron 主进程 `Notification`，点击聚焦主窗口 |
| 未读徽标 | 完成/失败 | 设置页入口 | 计数标记，打开面板清零 |

#### 4.4.1 WebAudio 合成提示音（借鉴 dsh-cron，零资源成本）

```ts
function playChime(kind: 'completed' | 'failed'): void {
  const Ctor = window.AudioContext ?? (window as any).webkitAudioContext
  if (!Ctor) return
  const audio = new Ctor()
  // 完成上升和弦 C5+G5，失败下降和弦 Eb4+D4
  const frequencies = kind === 'failed' ? [311.13, 293.66] : [523.25, 783.99]
  frequencies.forEach((freq) => {
    const osc = audio.createOscillator()
    const gain = audio.createGain()
    osc.type = 'sine'
    osc.frequency.value = freq
    // 包络：0.01s rise, 0.3s hold, 0.1s fall
    osc.connect(gain).connect(audio.destination)
    osc.start()
    osc.stop(audio.currentTime + 0.4)
  })
}
```

#### 4.4.2 Electron 桌面通知

**preload 扩展**（`src/preload/index.ts`）：

```ts
// 新增到 bridge
showNotification: (title: string, body: string) =>
  ipcRenderer.invoke('dsh:schedule-notification', { title, body }),
```

**主进程 IPC handler**（`src/main/index.ts`）：

```ts
import { Notification } from 'electron'

ipcMain.handle('dsh:schedule-notification', (_e, { title, body }) => {
  const n = new Notification({ title, body, icon: resolveIconPath() })
  n.on('click', () => showMainWindow())
  n.show()
})
```

**client 端调用**：

```ts
window.dshDesktop?.showNotification(task.title, task.description ?? '定时任务已触发')
```

**注意**：这需要扩展 dsh-desktop 的 preload 和主进程，属于桌面壳层面的改动（不是改 dsh 官方代码）。

#### 4.4.3 Toast 通知

用 `createPortal` 挂到 `document.body`，z-index `2147483647`（int32 max）确保盖过所有插件 overlay：

```tsx
function Toast({ run }: { run: RunRecord }) {
  return createPortal(
    <div className="sv-toast" style={{ zIndex: 2147483647 }}>
      <span>{run.status === 'completed' ? '✓' : '✗'}</span>
      <span>{run.taskTitle}</span>
      <span>{run.excerpt}</span>
    </div>,
    document.body
  )
}
```

完成状态 8s 自动消失，失败状态常驻直到用户关闭。点击跳转到执行历史面板。

### 4.5 Typert Remote RPC

host 端暴露 API，client 端通过 `ctx.remote` 调用。

**host 端**（`src/remote.ts`）：

```ts
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'

class ScheduleViewApi extends TypertRemoteService {
  constructor(ctx: Context) { super(ctx, 'scheduleViewRemote') }

  listTimers(): TimerView[] { ... }
  createTimer(spec: TimerCreateSpec): Promise<TimerView> { ... }
  updateTimer(id: string, patch: Partial<TimerTask>): Promise<TimerView> { ... }
  deleteTimer(id: string): Promise<void> { ... }
  fireNow(id: string): Promise<void> { ... }
  listRuns(taskId: string, limit: number): Promise<RunRecord[]> { ... }
}
```

**client 端**（`src/client/config-api.ts`）：

```ts
export function createConfigApi(ctx: ClientContext): ConfigApi {
  const mount = ctx.remote.$mount(CONTRIBUTION)
  const call = async (method, ...args) => {
    await mount
    const remote = ctx.get('remote.scheduleViewRemote')
    return remote[method](...args)
  }
  return {
    list: () => call('listTimers'),
    create: (spec) => call('createTimer', spec),
    update: (id, patch) => call('updateTimer', id, patch),
    delete: (id) => call('deleteTimer', id),
    fireNow: (id) => call('fireNow', id),
    runs: (taskId, limit) => call('listRuns', taskId, limit),
  }
}
```

### 4.6 故障隔离（借鉴 dsh-cron）

所有回调入口用 `guarded` 包裹 `try-catch`，插件故障不拖垮宿主进程：

```ts
function guarded<T extends (...args: any[]) => any>(label: string, fn: T): T {
  return ((...args: any[]) => {
    try {
      return fn(...args)
    } catch (error) {
      logger.warn(`dsh-schedule-view: ${label} failed: ${error?.message ?? error}`)
      return undefined
    }
  }) as T
}

// 应用到所有回调入口
ctx.on('session/event', guarded('session/event', handleSessionEvent))
ctx.on('agent/created', guarded('agent/created', handleAgentCreated))
const tick = guarded('tick', doTick)
```

### 4.7 执行记录生命周期追踪（借鉴 dsh-cron）

注入消息后，通过监听 `session/event` + 消息 id 匹配，追踪完整执行生命周期：

```ts
// 待追踪的运行记录：messageId → runRecord
const pendingRuns = new Map<string, { runId: string; sessionId: string; seen: boolean }>()

function fireTask(ctx: Context, task: TimerTask): void {
  const agent = ctx.agents.get(task.sessionId)
  if (!agent) {
    recordHistory(task.id, { status: 'skipped', detail: 'session not live' })
    notify(task, 'skipped')
    return
  }

  const message = createUserMessage({
    content: [{ type: 'text', text: task.prompt || '请根据系统指令开始执行任务。' }],
    source: { kind: 'plugin', plugin: 'dsh-schedule-view' },
  })
  agent.followup(message)

  // 记录初始状态
  const runId = uuid()
  recordHistory(runId, { taskId: task.id, messageId: message.id, status: 'delivered', firedAt: now() })
  pendingRuns.set(message.id, { runId, sessionId: task.sessionId, seen: false })
}

// 监听 session/event 追踪生命周期
ctx.on('session/event', guarded('session/event', (session, event) => {
  if (pendingRuns.size === 0) return
  const data = event?.data ?? {}

  // 消息进入会话 → running
  if (event.type === 'user/message') {
    const run = pendingRuns.get(data.id)
    if (run && !run.seen) {
      run.seen = true
      updateHistory(run.runId, { status: 'running', startedAt: now() })
    }
  }

  // AI 回复 → 记录 excerpt
  if (event.type === 'assistant/message') {
    const text = extractMessageText(data.message)
    for (const run of pendingRuns.values()) {
      if (run.seen && run.sessionId === session.id) {
        updateHistory(run.runId, { excerpt: text.slice(0, 300) })
      }
    }
  }

  // turn 结束 → completed/failed
  if (event.type === 'turn/end') {
    const kind = event.data?.reason?.kind ?? 'unknown'
    for (const [messageId, run] of pendingRuns) {
      if (!run.seen || run.sessionId !== session.id) continue
      pendingRuns.delete(messageId)
      updateHistory(run.runId, {
        status: kind === 'completed' ? 'completed' : 'failed',
        endReason: kind,
        completedAt: now(),
      })
      notifyCompleted(run.runId)  // toast + 提示音 + 桌面通知
    }
  }
}))
```

**历史封顶**：执行历史最多保留 500 条（借鉴 dsh-cron），超出时删除最旧的记录：

```ts
async function appendHistory(record: RunRecord): Promise<void> {
  await runs.put(record.runId, record)
  const all = await runs.entries()
  if (all.length > 500) {
    const oldest = all.sort((a, b) => a.firedAt.localeCompare(b.firedAt)).slice(0, all.length - 500)
    for (const r of oldest) await runs.delete(r.runId)
  }
}
```

---

## 5. UI 设计

### 5.1 设置页布局

```
┌─────────────────────────────────────────────────────┐
│  定时任务                                             │
│  ┌───────────────────────────────────────────────┐  │
│  │  [+ 新建任务]                  [刷新]          │  │
│  └───────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────┐  │
│  │  ☑ 每日站会报告      每天 09:00               │  │
│  │    目标: 工作区A    下次: 2026-08-25 09:00    │  │
│  │    [编辑] [立即执行] [删除]    [查看历史]     │  │
│  ├───────────────────────────────────────────────┤  │
│  │  ☐ 月度总结          每月 1 日 10:00          │  │
│  │    目标: 工作区B    已禁用                    │  │
│  │    [编辑] [立即执行] [删除]    [查看历史]     │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
```

### 5.2 创建/编辑表单

| 字段 | 类型 | 说明 |
|------|------|------|
| 任务名称 | text | 显示用 |
| cron 表达式 | text | 5 字段，实时校验 + 人类可读预览 |
| 提示词指令 | textarea | 到期注入的内容，默认 `请根据系统指令开始执行任务。` |
| 描述 | text | 通知 body，可选 |
| 目标会话 | select | 从 `ctx.sessions.list` 读取 |
| 时区 | select | 默认本地，可选常见时区 |
| 启用 | switch | 默认启用 |

### 5.3 cron 人类可读描述

输入 `0 9 * * 1-5` 时实时显示："工作日 09:00"。

使用 `cronstrue` 库（~20KB）生成描述：

```ts
import cronstrue from 'cronstrue/i18n'

const description = cronstrue.toString('0 9 * * 1-5', { locale: 'zh_CN' })
// "每天 09:00, 仅在 周一 到 周五"
```

### 5.4 执行历史面板

| 列 | 说明 |
|----|------|
| 触发时间 | ISO 8601 → 本地时间 |
| 状态 | `delivered` / `running` / `completed` / `failed` / `skipped`（带颜色标记） |
| 摘要 | AI 回复 excerpt（前 300 字） |
| 耗时 | `completedAt - startedAt` |
| 结束原因 | `endReason`（completed/aborted/error 等） |
| 目标会话 | session 标题 |

---

## 6. cordis inject 声明

### 6.1 host 端

```ts
export const inject = ['agents', 'sessions', 'typert', 'settings', 'storageDomain']
```

| 服务 | 用途 |
|------|------|
| `agents` | `ctx.agents.get(sessionId).followup(msg)` 消息注入 |
| `sessions` | `ctx.on('session/event')` 监听执行生命周期 + session 生命周期 |
| `typert` | 注册 Remote service 暴露给 client |
| `settings` | 任务定义持久化 |
| `storageDomain` | 执行历史持久化 |

### 6.2 client 端

```ts
export const inject = ['slots', 'locale', 'connection', 'remote', 'sessions']
```

| 服务 | 用途 |
|------|------|
| `slots` | 注册 `settings.section` UI slot |
| `locale` | i18n |
| `connection` | 连接状态 |
| `remote` | 调用 host 端 Typert Remote |
| `sessions` | 读取 session 列表供目标选择 |

---

## 7. 文件结构

```
extensions/dsh-schedule-view/
├── package.json                    # @lijian-ui/dsh-schedule-view
├── tsconfig.json
├── tsdown.config.ts
├── design.md                       # 本文档
├── src/
│   ├── index.ts                    # host 端入口：apply + inject
│   ├── remote.ts                   # Typert Remote service 定义
│   ├── timer-runtime.ts            # cron 解析 + tick 调度 + 消息注入
│   ├── lifecycle-tracker.ts        # session/event 监听 + 执行记录生命周期追踪
│   ├── guarded.ts                  # 故障隔离 wrapper
│   ├── notify.ts                   # 多级通知（桌面通知 + toast + 提示音）
│   ├── types.ts                    # TimerTask / RunRecord / TimerView
│   └── schema.ts                   # schemastery 配置 schema
└── src/client/
    ├── index.ts                    # client 端入口：slot 注册
    ├── config-api.ts               # ctx.remote.$mount + RPC 调用
    ├── TimerSettingsSection.tsx    # React 设置页 UI
    ├── Toast.tsx                   # Toast 通知组件（createPortal）
    └── chime.ts                    # WebAudio 合成提示音
```

### 7.1 package.json 关键字段

```jsonc
{
  "name": "@lijian-ui/dsh-schedule-view",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": "./lib/index.js",
    "./client": "./lib/client.js"
  },
  "dsh": {
    "client": "./client"
  },
  "dependencies": {
    "cron-parser": "^4.9.0",
    "cronstrue": "^2.50.0"
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
    "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
    "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
    "@deepseek-ai/dsh-storage-domain": "0.1.1-rc.2",
    "@deepseek-ai/dsh-typert-protocol": "0.1.1-rc.2"
  }
}
```

---

## 8. 依赖说明

### 8.1 运行时依赖

| 包 | 体积 | 用途 |
|----|------|------|
| `cron-parser` | ~30KB | cron 表达式解析 + 下次触发时间计算 |
| `cronstrue` | ~20KB | cron 表达式人类可读描述 |

总计新增 ~50KB，远小于官方 schedule 包的运行时开销。

### 8.2 peer 依赖（不打包，由 dsh 运行时提供）

| 包 | 用途 |
|----|------|
| `@deepseek-ai/cordis` | 插件框架 |
| `@deepseek-ai/dsh-agent` | `Agent.followup` 消息注入 |
| `@deepseek-ai/dsh-llm` | `createUserMessage` |
| `@deepseek-ai/dsh-settings` | 配置持久化 |
| `@deepseek-ai/dsh-storage-domain` | 执行历史持久化 |
| `@deepseek-ai/dsh-typert-protocol` | Typert Remote RPC |

### 8.3 dsh-desktop 桌面壳改动

| 文件 | 改动 | 说明 |
|------|------|------|
| `src/preload/index.ts` | 新增 `showNotification` | IPC 桥接 |
| `src/main/index.ts` | 新增 `ipcMain.handle('dsh:schedule-notification')` | 主进程通知 |
| `src/main/profile-init.ts` | 新增 `SCHEDULE_VIEW_BUNDLE` | 插件注册 |

这些是桌面壳层面的改动，不涉及 dsh 官方代码。

---

## 9. 技术风险与限制

| 风险 | 说明 | 缓解 |
|------|------|------|
| **agent 不 live** | `ctx.agents.get(id)` 返回 undefined（session 已关闭） | 仅通知 + 记录 skipped |
| **tick 精度延迟** | 轮询间隔 15s，触发最多延迟 15s | 对定时任务场景可接受；可配置 `tickSeconds` 降低间隔 |
| **session 删除后残留** | 目标 session 被删除，任务仍存在 | UI 标记"目标会话不存在"，用户手动清理 |
| **多任务同时到期** | 一个 tick 内多个任务到期 | tick 内串行 fire，followup 本身支持排队 |
| **桌面通知权限** | Windows 需要应用有 AppID | Electron 打包时设置 appId，系统通知自动可用 |
| **cron 时区** | 用户跨时区时 cron 语义可能混淆 | 每个任务可独立设置时区，默认本地 |
| **Typert codec 繁琐** | 每个方法需 host/client 两端镜像 descriptors | 参照 im-gateway remote.ts 模板 |
| **followup 语义** | `请根据系统指令开始执行任务。` 是指令式，模型会立即执行 | 这是设计意图，非风险 |
| **dsh 版本兼容** | peer 依赖版本需跟随 dsh 升级 | 用 `0.1.1-rc.2 \|\| 0.1.0-rc.8` 多版本兼容 |
| **历史无限增长** | 执行历史不断累积 | 500 条封顶，超出删最旧（借鉴 dsh-cron） |
| **插件故障拖垮宿主** | timer 回调或 event 监听抛异常 | 所有回调 `guarded` 包裹 try-catch（借鉴 dsh-cron） |
| **session/event 事件格式变动** | dsh 升级可能改 event 类型名 | guarded 吞掉异常，降级为不追踪生命周期 |

---

## 10. 与官方 schedule 和 dsh-cron 的对比

| 维度 | 官方 `dsh-schedule` | 社区 `dsh-cron` | 本插件 `dsh-schedule-view` |
|------|---------------------|-----------------|---------------------------|
| 驱动方式 | AI 驱动（对话创建） | AI 驱动（对话创建） | 人驱动（UI 创建） |
| LLM tool | 3 个（create/list/delete） | 5 个（list/add/update/remove/history） | 0 个 |
| cron 表达式 | 不支持 | 支持（手写解析器） | 支持（cron-parser 库） |
| 人类可读描述 | 无 | 无 | cronstrue 实时预览"工作日 09:00" |
| 交付范围 | session-local | 跨 session + 回退最近活跃 | 跨 session（绑定 session，失效 skipped） |
| 调度方式 | setTimeout | tick 轮询（15s） | tick 轮询（15s） |
| 外部通知 | 无 | 四级（徽标+toast+提示音+OS原生） | 四级（徽标+toast+提示音+桌面通知） |
| 执行记录 | 仅 dispatched 事件 | 完整生命周期 + excerpt + 耗时 | 完整生命周期 + excerpt + 耗时 |
| UI 入口 | 无 | 会话头部按钮 + 抽屉 | 设置页面板 |
| 消息语义 | 提醒式（untrusted reminder） | 框架式（`[cron]` 声明自动化） | 指令式（立即执行） |
| 持久化 | session event log | node:fs 直接写文件 | dsh-settings + dsh-storage-domain |
| RPC | 无 | 裸 HTTP API | Typert Remote RPC（类型安全） |
| 故障隔离 | 无 | guarded try-catch | guarded try-catch |
| 时区 | 不支持 | 仅本地 | 每任务可独立设置 |
| 历史封顶 | 无 | 500 条 | 500 条 |
| 依赖体积 | 官方包自带 | 零依赖（手写） | ~50KB（cron-parser + cronstrue） |

---

## 11. 构建与部署

### 11.1 构建

```bash
cd extensions/dsh-schedule-view
npx tsdown
```

### 11.2 部署

1. 发布到 npm：`npm publish --registry https://registry.npmjs.org/`
2. dsh-desktop `package.json` 添加 `"@lijian-ui/dsh-schedule-view": "0.1.0"`
3. `profile-init.ts` 的 `PLUGIN_BUNDLES` 添加 `SCHEDULE_VIEW_BUNDLE`
4. 扩展 preload + 主进程 IPC handler
5. 重建 junction + 重启桌面端

### 11.3 i18n

| key | 中文 | 英文 |
|-----|------|------|
| `section.label` | 定时任务 | Scheduled Tasks |
| `task.create` | 新建任务 | New Task |
| `task.edit` | 编辑 | Edit |
| `task.delete` | 删除 | Delete |
| `task.fireNow` | 立即执行 | Fire Now |
| `task.enabled` | 启用 | Enabled |
| `task.cron` | cron 表达式 | Cron Expression |
| `task.prompt` | 提示词指令 | Prompt Instruction |
| `task.targetSession` | 目标会话 | Target Session |
| `task.nextFire` | 下次触发 | Next Fire |
| `task.history` | 执行历史 | Run History |
| `task.status.delivered` | 已投递 | Delivered |
| `task.status.running` | 执行中 | Running |
| `task.status.completed` | 已完成 | Completed |
| `task.status.failed` | 已失败 | Failed |
| `task.status.skipped` | 已跳过 | Skipped |
| `task.history.excerpt` | 摘要 | Excerpt |
| `task.history.duration` | 耗时 | Duration |
| `task.history.endReason` | 结束原因 | End Reason |

---

## 12. 后续扩展方向

| 方向 | 说明 |
|------|------|
| webhook 通知 | 到期后 POST 到用户配置的 URL |
| IM 通知 | 复用 im-gateway 的通道，到期后通过钉钉/QQ/微信推送 |
| 任务模板 | 预设常用 cron 模板（每日站会、周报、月度总结等） |
| 任务分组 | 按项目/工作区分组管理 |
| 条件触发 | 结合 dsh 的 session 状态，仅当 session idle 时注入 |
| 重试机制 | 注入失败后重试 N 次 |
| 任务依赖 | A 完成后触发 B |