@pencil-agent/nano-pencil 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +267 -267
- package/dist/build-meta.json +3 -3
- package/dist/core/export-html/AGENT.md +11 -11
- package/dist/core/export-html/template.css +971 -971
- package/dist/core/export-html/template.html +54 -54
- package/dist/core/model/custom-providers.js +1 -1
- package/dist/core/model-registry.js +5 -5
- package/dist/extensions/builtin/AGENT.md +115 -115
- package/dist/extensions/builtin/browser/AGENT.md +17 -17
- package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
- package/dist/extensions/builtin/browser/browser.md +73 -73
- package/dist/extensions/builtin/browser/install.md +142 -142
- package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
- package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
- package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
- package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
- package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
- package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
- package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
- package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
- package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
- package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
- package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
- package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
- package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
- package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
- package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
- package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
- package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
- package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
- package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
- package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
- package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
- package/dist/extensions/builtin/goal/README.md +67 -67
- package/dist/extensions/builtin/grub/README.md +112 -112
- package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
- package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
- package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
- package/dist/extensions/builtin/link-world/linkworld.md +313 -313
- package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
- package/dist/extensions/builtin/loop/README.md +92 -92
- package/dist/extensions/builtin/mcp/figma-design.md +68 -68
- package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
- package/dist/extensions/builtin/recap/AGENT.md +15 -15
- package/dist/extensions/builtin/sal/README.md +72 -72
- package/dist/extensions/builtin/security-audit/README.md +289 -289
- package/dist/extensions/builtin/team/AGENT.md +112 -112
- package/dist/extensions/builtin/team/TESTING.md +299 -299
- package/dist/extensions/builtin/token-save/README.md +56 -56
- package/dist/extensions/optional/AGENT.md +10 -10
- package/dist/modes/interactive/controllers/input-submit-controller.js +2 -2
- package/dist/modes/interactive/controllers/stream-render-controller.js +2 -2
- package/dist/modes/interactive/interactive-mode.js +19 -19
- package/dist/modes/interactive/theme/dark.json +85 -85
- package/dist/modes/interactive/theme/light.json +84 -84
- package/dist/modes/interactive/theme/theme-schema.json +335 -335
- package/dist/modes/interactive/theme/warm.json +81 -81
- package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
- package/dist/node_modules/@pencil-agent/ai/dist/models.generated.js +1 -1
- package/docs/ACP/345/215/217/350/256/256/351/233/206/346/210/220/345/274/200/345/217/221/346/226/207/346/241/243.md +851 -0
- package/docs/SDK-TESTING.md +364 -0
- package/docs/codex-goal-command-impl.md +1055 -1055
- package/docs/codex-goal-vs-grub.md +500 -500
- package/docs/custom-provider.md +27 -27
- package/docs/extensions.md +27 -27
- package/docs/keybindings.md +27 -27
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +158 -158
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +128 -128
- package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
- package/docs/loop-usage-examples.md +214 -214
- package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +593 -0
- package/docs/models.md +27 -27
- package/docs/packages.md +27 -27
- package/docs/pi-design-philosophy.md +457 -457
- package/docs/planmode.md +1987 -1987
- package/docs/prompt-templates.md +27 -27
- package/docs/providers.md +27 -27
- package/docs/sdk.md +27 -27
- package/docs/skills.md +27 -27
- package/docs/startup-performance-optimization.md +301 -0
- package/docs/themes.md +27 -27
- package/docs/tui.md +27 -27
- package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +47 -0
- package/package.json +190 -190
- package/docs/cc-agent-design.md +0 -1297
- package/docs/cc-tui-design.md +0 -1333
- package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +0 -170
- package/docs/scan-report.md +0 -3820
- package/docs//345/257/271/346/240/207Claude-Code.md +0 -1775
- package/docs//351/230/277/351/207/214/345/267/264/345/267/264/350/264/242/346/212/245/345/210/206/346/236/220/344/271/246.md +0 -261
|
@@ -1,1222 +1,1222 @@
|
|
|
1
|
-
# 07 | `/loop` 命令实现与复现指南
|
|
2
|
-
|
|
3
|
-
> 基于 Claude Code v2.1.88 反编译源码的逆向分析文档。本文目标不是只解释现有代码,而是把 `/loop` 的实现拆成足够小的步骤,让能力较弱的大模型也能按步骤复现出同等功能。
|
|
4
|
-
|
|
5
|
-
## 目录
|
|
6
|
-
|
|
7
|
-
1. [一句话总结](#1-一句话总结)
|
|
8
|
-
2. [核心文件清单](#2-核心文件清单)
|
|
9
|
-
3. [整体数据流](#3-整体数据流)
|
|
10
|
-
4. [功能边界](#4-功能边界)
|
|
11
|
-
5. [第一层:注册 bundled skill](#5-第一层注册-bundled-skill)
|
|
12
|
-
6. [第二层:`/loop` skill prompt](#6-第二层loop-skill-prompt)
|
|
13
|
-
7. [第三层:CronCreate 工具](#7-第三层croncreate-工具)
|
|
14
|
-
8. [第四层:任务存储](#8-第四层任务存储)
|
|
15
|
-
9. [第五层:调度器](#9-第五层调度器)
|
|
16
|
-
10. [第六层:REPL 接入](#10-第六层repl-接入)
|
|
17
|
-
11. [从零实现步骤](#11-从零实现步骤)
|
|
18
|
-
12. [最小可用版本](#12-最小可用版本)
|
|
19
|
-
13. [完整版本增强项](#13-完整版本增强项)
|
|
20
|
-
14. [测试清单](#14-测试清单)
|
|
21
|
-
15. [常见错误](#15-常见错误)
|
|
22
|
-
16. [关键结论](#16-关键结论)
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## 1. 一句话总结
|
|
27
|
-
|
|
28
|
-
`/loop` 不是一个直接执行定时逻辑的普通命令。它是一个 bundled skill:
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
用户输入 /loop 5m check deploy
|
|
32
|
-
│
|
|
33
|
-
▼
|
|
34
|
-
processSlashCommand 识别为 prompt skill
|
|
35
|
-
│
|
|
36
|
-
▼
|
|
37
|
-
/loop skill 返回一段“请解析参数并调用 CronCreate”的 prompt
|
|
38
|
-
│
|
|
39
|
-
▼
|
|
40
|
-
主模型读取 prompt,调用 CronCreate 工具
|
|
41
|
-
│
|
|
42
|
-
▼
|
|
43
|
-
CronCreate 写入 session 内存或 .claude/scheduled_tasks.json
|
|
44
|
-
│
|
|
45
|
-
▼
|
|
46
|
-
cronScheduler 每秒检查,到点后把 prompt 重新放入队列
|
|
47
|
-
│
|
|
48
|
-
▼
|
|
49
|
-
REPL 像处理普通用户输入一样处理这个 scheduled prompt
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
最重要的设计点:**`/loop` 本身不解析 cron,也不直接调度任务,它把解析规则写进 skill prompt,让模型调用 `CronCreate` 工具。**
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## 2. 核心文件清单
|
|
57
|
-
|
|
58
|
-
| 文件 | 作用 |
|
|
59
|
-
|------|------|
|
|
60
|
-
| `src/skills/bundled/index.ts` | 启动时注册 bundled skills,包含 `/loop` 的 feature gate |
|
|
61
|
-
| `src/skills/bundled/loop.ts` | `/loop` skill 的实现,生成解析和调度用 prompt |
|
|
62
|
-
| `src/skills/bundledSkills.ts` | bundled skill 注册表,把 skill 转成 `Command` |
|
|
63
|
-
| `src/utils/processUserInput/processSlashCommand.tsx` | slash command 分发逻辑,把 `/loop` 转成模型可见 prompt |
|
|
64
|
-
| `src/tools/ScheduleCronTool/prompt.ts` | cron 工具的名字、说明、feature gate |
|
|
65
|
-
| `src/tools/ScheduleCronTool/CronCreateTool.ts` | 创建定时任务的工具 |
|
|
66
|
-
| `src/tools/ScheduleCronTool/CronDeleteTool.ts` | 删除定时任务的工具 |
|
|
67
|
-
| `src/tools/ScheduleCronTool/CronListTool.ts` | 列出定时任务的工具 |
|
|
68
|
-
| `src/utils/cronTasks.ts` | 任务读写、内存任务、durable 文件任务、jitter 计算 |
|
|
69
|
-
| `src/utils/cronScheduler.ts` | 非 React 调度器核心,每秒检查任务是否到期 |
|
|
70
|
-
| `src/hooks/useScheduledTasks.ts` | REPL 中挂载 scheduler,把到期任务放入命令队列 |
|
|
71
|
-
| `src/tools.ts` | 工具注册中心,把 CronCreate/CronDelete/CronList 加入工具集合 |
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## 3. 整体数据流
|
|
76
|
-
|
|
77
|
-
### 3.1 用户创建 loop
|
|
78
|
-
|
|
79
|
-
```
|
|
80
|
-
/loop 5m /standup 1
|
|
81
|
-
│
|
|
82
|
-
▼
|
|
83
|
-
parseSlashCommand(input)
|
|
84
|
-
│
|
|
85
|
-
▼
|
|
86
|
-
getCommand("loop")
|
|
87
|
-
│
|
|
88
|
-
▼
|
|
89
|
-
loop.getPromptForCommand("5m /standup 1")
|
|
90
|
-
│
|
|
91
|
-
▼
|
|
92
|
-
生成 meta user message:
|
|
93
|
-
“解析输入,转 cron,调用 CronCreate”
|
|
94
|
-
│
|
|
95
|
-
▼
|
|
96
|
-
主模型调用:
|
|
97
|
-
CronCreate({
|
|
98
|
-
cron: "*/5 * * * *",
|
|
99
|
-
prompt: "/standup 1",
|
|
100
|
-
recurring: true
|
|
101
|
-
})
|
|
102
|
-
│
|
|
103
|
-
▼
|
|
104
|
-
addCronTask(...)
|
|
105
|
-
│
|
|
106
|
-
├─ durable=false: 写入 session memory
|
|
107
|
-
└─ durable=true: 写入 .claude/scheduled_tasks.json
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
### 3.2 定时任务触发
|
|
111
|
-
|
|
112
|
-
```
|
|
113
|
-
cronScheduler 每 1 秒 check()
|
|
114
|
-
│
|
|
115
|
-
▼
|
|
116
|
-
计算每个任务 nextFireAt
|
|
117
|
-
│
|
|
118
|
-
▼
|
|
119
|
-
now >= nextFireAt ?
|
|
120
|
-
│
|
|
121
|
-
├─ 否:等待下一秒
|
|
122
|
-
│
|
|
123
|
-
└─ 是:
|
|
124
|
-
│
|
|
125
|
-
▼
|
|
126
|
-
onFireTask(task)
|
|
127
|
-
│
|
|
128
|
-
▼
|
|
129
|
-
enqueuePendingNotification({
|
|
130
|
-
value: task.prompt,
|
|
131
|
-
mode: "prompt",
|
|
132
|
-
priority: "later",
|
|
133
|
-
isMeta: true,
|
|
134
|
-
workload: WORKLOAD_CRON
|
|
135
|
-
})
|
|
136
|
-
│
|
|
137
|
-
▼
|
|
138
|
-
REPL 队列在空闲时执行该 prompt
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
## 4. 功能边界
|
|
144
|
-
|
|
145
|
-
`/loop` 要支持:
|
|
146
|
-
|
|
147
|
-
1. `/loop 5m check deploy`
|
|
148
|
-
2. `/loop check deploy`
|
|
149
|
-
3. `/loop check deploy every 20m`
|
|
150
|
-
4. `/loop 1h /standup 1`
|
|
151
|
-
5. 空输入时显示 usage
|
|
152
|
-
6. 创建后立即执行一次原 prompt
|
|
153
|
-
7. 后续按 cron 反复执行
|
|
154
|
-
8. 支持取消,提示用户用 `CronDelete`
|
|
155
|
-
9. recurring 任务默认 7 天后自动过期
|
|
156
|
-
|
|
157
|
-
`/loop` 不负责:
|
|
158
|
-
|
|
159
|
-
1. 直接操作文件
|
|
160
|
-
2. 自己实现定时器
|
|
161
|
-
3. 自己执行 bash 或 slash command
|
|
162
|
-
4. 自己决定权限
|
|
163
|
-
5. 自己实现 durable 存储
|
|
164
|
-
|
|
165
|
-
这些都交给 Cron 工具和 scheduler。
|
|
166
|
-
|
|
167
|
-
---
|
|
168
|
-
|
|
169
|
-
## 5. 第一层:注册 bundled skill
|
|
170
|
-
|
|
171
|
-
入口文件:`src/skills/bundled/index.ts`
|
|
172
|
-
|
|
173
|
-
关键逻辑:
|
|
174
|
-
|
|
175
|
-
```typescript
|
|
176
|
-
if (feature('AGENT_TRIGGERS')) {
|
|
177
|
-
const { registerLoopSkill } = require('./loop.js')
|
|
178
|
-
registerLoopSkill()
|
|
179
|
-
}
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
这说明 `/loop` 被 `AGENT_TRIGGERS` 编译期开关控制。即使注册了,最终是否可用还会由 `isKairosCronEnabled()` 决定。
|
|
183
|
-
|
|
184
|
-
复现时需要做三件事:
|
|
185
|
-
|
|
186
|
-
1. 在 bundled skill 初始化入口中引入 `registerLoopSkill`
|
|
187
|
-
2. 用 feature gate 包起来
|
|
188
|
-
3. 调用 `registerLoopSkill()`
|
|
189
|
-
|
|
190
|
-
如果没有 feature 系统,最小实现可以直接注册:
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
registerLoopSkill()
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
## 6. 第二层:`/loop` skill prompt
|
|
199
|
-
|
|
200
|
-
核心文件:`src/skills/bundled/loop.ts`
|
|
201
|
-
|
|
202
|
-
### 6.1 默认间隔
|
|
203
|
-
|
|
204
|
-
```typescript
|
|
205
|
-
const DEFAULT_INTERVAL = '10m'
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
如果用户没有写时间,默认每 10 分钟执行一次。
|
|
209
|
-
|
|
210
|
-
### 6.2 空输入 usage
|
|
211
|
-
|
|
212
|
-
空输入返回说明文案,不调用 CronCreate:
|
|
213
|
-
|
|
214
|
-
```typescript
|
|
215
|
-
if (!trimmed) {
|
|
216
|
-
return [{ type: 'text', text: USAGE_MESSAGE }]
|
|
217
|
-
}
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
### 6.3 非空输入 buildPrompt
|
|
221
|
-
|
|
222
|
-
`buildPrompt(args)` 返回一整段给模型的 instructions,核心要求是:
|
|
223
|
-
|
|
224
|
-
1. 解析 interval 和 prompt
|
|
225
|
-
2. 把 interval 转成 cron
|
|
226
|
-
3. 调用 `CronCreate`
|
|
227
|
-
4. 告知用户 job id 和过期时间
|
|
228
|
-
5. 立即执行一次 prompt
|
|
229
|
-
|
|
230
|
-
关键 prompt 规则:
|
|
231
|
-
|
|
232
|
-
```text
|
|
233
|
-
1. Leading token:
|
|
234
|
-
如果第一个 token 匹配 ^\d+[smhd]$,它就是 interval。
|
|
235
|
-
|
|
236
|
-
2. Trailing "every" clause:
|
|
237
|
-
如果输入以 every <N><unit> 或 every <N> <unit-word> 结尾,提取为 interval。
|
|
238
|
-
|
|
239
|
-
3. Default:
|
|
240
|
-
否则 interval = 10m,整个输入都是 prompt。
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
### 6.4 interval 到 cron 的转换
|
|
244
|
-
|
|
245
|
-
| interval | cron | 说明 |
|
|
246
|
-
|----------|------|------|
|
|
247
|
-
| `5m` | `*/5 * * * *` | 每 5 分钟 |
|
|
248
|
-
| `30m` | `*/30 * * * *` | 每 30 分钟 |
|
|
249
|
-
| `1h` | `0 */1 * * *` | 每小时 |
|
|
250
|
-
| `2h` | `0 */2 * * *` | 每 2 小时 |
|
|
251
|
-
| `1d` | `0 0 */1 * *` | 每天午夜 |
|
|
252
|
-
| `30s` | `*/1 * * * *` | 秒级向上取整到分钟 |
|
|
253
|
-
|
|
254
|
-
注意:cron 最小粒度是分钟,所以 `Ns` 要转换成 `ceil(N/60)m`,至少 1 分钟。
|
|
255
|
-
|
|
256
|
-
### 6.5 注册 skill
|
|
257
|
-
|
|
258
|
-
`registerLoopSkill()` 调用 `registerBundledSkill()`:
|
|
259
|
-
|
|
260
|
-
```typescript
|
|
261
|
-
registerBundledSkill({
|
|
262
|
-
name: 'loop',
|
|
263
|
-
description: 'Run a prompt or slash command on a recurring interval...',
|
|
264
|
-
whenToUse: 'When the user wants to set up a recurring task...',
|
|
265
|
-
argumentHint: '[interval] <prompt>',
|
|
266
|
-
userInvocable: true,
|
|
267
|
-
isEnabled: isKairosCronEnabled,
|
|
268
|
-
async getPromptForCommand(args) {
|
|
269
|
-
...
|
|
270
|
-
},
|
|
271
|
-
})
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
这会把 `/loop` 变成一个 `type: 'prompt'` 的 command。
|
|
275
|
-
|
|
276
|
-
---
|
|
277
|
-
|
|
278
|
-
## 7. 第三层:CronCreate 工具
|
|
279
|
-
|
|
280
|
-
核心文件:`src/tools/ScheduleCronTool/CronCreateTool.ts`
|
|
281
|
-
|
|
282
|
-
### 7.1 输入 schema
|
|
283
|
-
|
|
284
|
-
`CronCreate` 接收:
|
|
285
|
-
|
|
286
|
-
```typescript
|
|
287
|
-
{
|
|
288
|
-
cron: string,
|
|
289
|
-
prompt: string,
|
|
290
|
-
recurring?: boolean,
|
|
291
|
-
durable?: boolean,
|
|
292
|
-
}
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
字段含义:
|
|
296
|
-
|
|
297
|
-
| 字段 | 默认值 | 含义 |
|
|
298
|
-
|------|--------|------|
|
|
299
|
-
| `cron` | 无 | 5-field cron 表达式 |
|
|
300
|
-
| `prompt` | 无 | 到点后重新送入队列的 prompt |
|
|
301
|
-
| `recurring` | `true` | 是否反复执行 |
|
|
302
|
-
| `durable` | `false` | 是否写入 `.claude/scheduled_tasks.json` |
|
|
303
|
-
|
|
304
|
-
### 7.2 validateInput
|
|
305
|
-
|
|
306
|
-
创建任务前要做四个检查:
|
|
307
|
-
|
|
308
|
-
1. `parseCronExpression(input.cron)` 必须成功
|
|
309
|
-
2. `nextCronRunMs(input.cron, Date.now())` 必须能在一年内找到下一次运行时间
|
|
310
|
-
3. 当前任务数量不能超过 `MAX_JOBS = 50`
|
|
311
|
-
4. teammate 场景下不能创建 durable cron
|
|
312
|
-
|
|
313
|
-
伪代码:
|
|
314
|
-
|
|
315
|
-
```typescript
|
|
316
|
-
if (!parseCronExpression(input.cron)) reject
|
|
317
|
-
if (nextCronRunMs(input.cron, Date.now()) === null) reject
|
|
318
|
-
if ((await listAllCronTasks()).length >= 50) reject
|
|
319
|
-
if (input.durable && getTeammateContext()) reject
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
### 7.3 call
|
|
323
|
-
|
|
324
|
-
真正创建任务:
|
|
325
|
-
|
|
326
|
-
```typescript
|
|
327
|
-
const effectiveDurable = durable && isDurableCronEnabled()
|
|
328
|
-
const id = await addCronTask(
|
|
329
|
-
cron,
|
|
330
|
-
prompt,
|
|
331
|
-
recurring,
|
|
332
|
-
effectiveDurable,
|
|
333
|
-
getTeammateContext()?.agentId,
|
|
334
|
-
)
|
|
335
|
-
setScheduledTasksEnabled(true)
|
|
336
|
-
return { id, humanSchedule, recurring, durable: effectiveDurable }
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
`setScheduledTasksEnabled(true)` 很关键。scheduler 启动后会轮询这个 flag,flag 打开后才开始 load/watch/check。
|
|
340
|
-
|
|
341
|
-
---
|
|
342
|
-
|
|
343
|
-
## 8. 第四层:任务存储
|
|
344
|
-
|
|
345
|
-
核心文件:`src/utils/cronTasks.ts`
|
|
346
|
-
|
|
347
|
-
### 8.1 CronTask 数据结构
|
|
348
|
-
|
|
349
|
-
```typescript
|
|
350
|
-
type CronTask = {
|
|
351
|
-
id: string
|
|
352
|
-
cron: string
|
|
353
|
-
prompt: string
|
|
354
|
-
createdAt: number
|
|
355
|
-
lastFiredAt?: number
|
|
356
|
-
recurring?: boolean
|
|
357
|
-
permanent?: boolean
|
|
358
|
-
durable?: boolean
|
|
359
|
-
agentId?: string
|
|
360
|
-
}
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
字段说明:
|
|
364
|
-
|
|
365
|
-
| 字段 | 说明 |
|
|
366
|
-
|------|------|
|
|
367
|
-
| `id` | 8 位短 id,来自 `randomUUID().slice(0, 8)` |
|
|
368
|
-
| `cron` | 5-field cron |
|
|
369
|
-
| `prompt` | 到点后执行的文本 |
|
|
370
|
-
| `createdAt` | 创建时间,用于计算第一次 fire 和 missed task |
|
|
371
|
-
| `lastFiredAt` | durable recurring 任务上次执行时间 |
|
|
372
|
-
| `recurring` | 是否循环 |
|
|
373
|
-
| `permanent` | 系统内置任务可永久不过期 |
|
|
374
|
-
| `durable` | runtime-only,false 表示 session-only |
|
|
375
|
-
| `agentId` | teammate 专用,触发时投递给对应 teammate |
|
|
376
|
-
|
|
377
|
-
### 8.2 两种存储位置
|
|
378
|
-
|
|
379
|
-
#### session-only
|
|
380
|
-
|
|
381
|
-
默认路径,不写磁盘:
|
|
382
|
-
|
|
383
|
-
```typescript
|
|
384
|
-
addSessionCronTask(task)
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
特点:
|
|
388
|
-
|
|
389
|
-
1. 只在当前进程有效
|
|
390
|
-
2. Claude 退出后丢失
|
|
391
|
-
3. scheduler 每秒从 bootstrap state 读取
|
|
392
|
-
4. 不需要文件锁
|
|
393
|
-
|
|
394
|
-
#### durable
|
|
395
|
-
|
|
396
|
-
写入:
|
|
397
|
-
|
|
398
|
-
```text
|
|
399
|
-
<project>/.claude/scheduled_tasks.json
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
特点:
|
|
403
|
-
|
|
404
|
-
1. 重启后还能恢复
|
|
405
|
-
2. 需要文件 watcher
|
|
406
|
-
3. 多 Claude session 共享同一个 cwd 时需要 scheduler lock
|
|
407
|
-
4. recurring 任务执行后要写回 `lastFiredAt`
|
|
408
|
-
|
|
409
|
-
### 8.3 addCronTask
|
|
410
|
-
|
|
411
|
-
伪代码:
|
|
412
|
-
|
|
413
|
-
```typescript
|
|
414
|
-
function addCronTask(cron, prompt, recurring, durable, agentId) {
|
|
415
|
-
const id = randomUUID().slice(0, 8)
|
|
416
|
-
const task = { id, cron, prompt, createdAt: Date.now(), recurring }
|
|
417
|
-
|
|
418
|
-
if (!durable) {
|
|
419
|
-
addSessionCronTask({ ...task, agentId })
|
|
420
|
-
return id
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const tasks = await readCronTasks()
|
|
424
|
-
tasks.push(task)
|
|
425
|
-
await writeCronTasks(tasks)
|
|
426
|
-
return id
|
|
427
|
-
}
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
---
|
|
431
|
-
|
|
432
|
-
## 9. 第五层:调度器
|
|
433
|
-
|
|
434
|
-
核心文件:`src/utils/cronScheduler.ts`
|
|
435
|
-
|
|
436
|
-
这是非 React 的核心调度器,REPL 和 headless 模式都能复用。
|
|
437
|
-
|
|
438
|
-
### 9.1 生命周期
|
|
439
|
-
|
|
440
|
-
源码注释给出的生命周期:
|
|
441
|
-
|
|
442
|
-
```text
|
|
443
|
-
poll getScheduledTasksEnabled() until true
|
|
444
|
-
↓
|
|
445
|
-
load tasks
|
|
446
|
-
↓
|
|
447
|
-
watch .claude/scheduled_tasks.json
|
|
448
|
-
↓
|
|
449
|
-
start 1s check timer
|
|
450
|
-
↓
|
|
451
|
-
on fire, call onFire(prompt)
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
### 9.2 start
|
|
455
|
-
|
|
456
|
-
启动时:
|
|
457
|
-
|
|
458
|
-
1. 如果传了 `dir`,说明是 daemon/headless 路径,直接 enable
|
|
459
|
-
2. 如果没有传 `dir`,检查 bootstrap flag
|
|
460
|
-
3. 如果 `assistantMode` 或磁盘上已有任务,自动打开 flag
|
|
461
|
-
4. 否则每秒轮询 `getScheduledTasksEnabled()`
|
|
462
|
-
|
|
463
|
-
### 9.3 enable
|
|
464
|
-
|
|
465
|
-
`enable()` 做四件事:
|
|
466
|
-
|
|
467
|
-
1. 动态 import `chokidar`
|
|
468
|
-
2. 获取 scheduler lock
|
|
469
|
-
3. `load(true)` 读取 durable tasks
|
|
470
|
-
4. watch `.claude/scheduled_tasks.json`
|
|
471
|
-
5. `setInterval(check, 1000)`
|
|
472
|
-
|
|
473
|
-
为什么需要 lock:
|
|
474
|
-
|
|
475
|
-
```
|
|
476
|
-
同一个项目目录可能开两个 Claude
|
|
477
|
-
│
|
|
478
|
-
├─ 如果没有 lock,两个进程都会读同一个 scheduled_tasks.json
|
|
479
|
-
└─ 同一个任务会被执行两次
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
所以 durable/file-backed tasks 只有 lock owner 会触发。session-only tasks 是进程内存,其他 session 看不到,不需要 lock。
|
|
483
|
-
|
|
484
|
-
### 9.4 check
|
|
485
|
-
|
|
486
|
-
`check()` 是调度器核心。
|
|
487
|
-
|
|
488
|
-
伪代码:
|
|
489
|
-
|
|
490
|
-
```typescript
|
|
491
|
-
function check() {
|
|
492
|
-
if (isKilled?.()) return
|
|
493
|
-
if (isLoading() && !assistantMode) return
|
|
494
|
-
|
|
495
|
-
const now = Date.now()
|
|
496
|
-
const jitterCfg = getJitterConfig?.() ?? DEFAULT_CRON_JITTER_CONFIG
|
|
497
|
-
|
|
498
|
-
if (isOwner) {
|
|
499
|
-
for (const task of fileTasks) process(task, false)
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (dir === undefined) {
|
|
503
|
-
for (const task of sessionTasks) process(task, true)
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
### 9.5 process(task, isSession)
|
|
509
|
-
|
|
510
|
-
每个任务的处理逻辑:
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
if (filter && !filter(task)) return
|
|
514
|
-
if (inFlight.has(task.id)) return
|
|
515
|
-
|
|
516
|
-
let next = nextFireAt.get(task.id)
|
|
517
|
-
|
|
518
|
-
if (next === undefined) {
|
|
519
|
-
if (task.recurring) {
|
|
520
|
-
next = jitteredNextCronRunMs(
|
|
521
|
-
task.cron,
|
|
522
|
-
task.lastFiredAt ?? task.createdAt,
|
|
523
|
-
task.id,
|
|
524
|
-
jitterCfg,
|
|
525
|
-
)
|
|
526
|
-
} else {
|
|
527
|
-
next = oneShotJitteredNextCronRunMs(
|
|
528
|
-
task.cron,
|
|
529
|
-
task.createdAt,
|
|
530
|
-
task.id,
|
|
531
|
-
jitterCfg,
|
|
532
|
-
)
|
|
533
|
-
}
|
|
534
|
-
nextFireAt.set(task.id, next)
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (Date.now() < next) return
|
|
538
|
-
|
|
539
|
-
fire(task)
|
|
540
|
-
|
|
541
|
-
if (task.recurring && !aged) {
|
|
542
|
-
nextFireAt.set(task.id, jitteredNextCronRunMs(task.cron, now, task.id))
|
|
543
|
-
if (!isSession) markCronTasksFired([task.id], now)
|
|
544
|
-
} else {
|
|
545
|
-
remove task
|
|
546
|
-
}
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
### 9.6 recurring 过期
|
|
550
|
-
|
|
551
|
-
默认配置:
|
|
552
|
-
|
|
553
|
-
```typescript
|
|
554
|
-
recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
也就是 7 天。
|
|
558
|
-
|
|
559
|
-
过期 recurring task 会再执行最后一次,然后删除。
|
|
560
|
-
|
|
561
|
-
### 9.7 jitter
|
|
562
|
-
|
|
563
|
-
jitter 的目的不是功能正确性,而是避免大量用户都在整点触发任务造成流量尖峰。
|
|
564
|
-
|
|
565
|
-
两类 jitter:
|
|
566
|
-
|
|
567
|
-
1. recurring:在下一次 cron 时间后加一点确定性延迟
|
|
568
|
-
2. one-shot:如果落在 `:00` 或 `:30`,可以提前一点触发
|
|
569
|
-
|
|
570
|
-
确定性来源:
|
|
571
|
-
|
|
572
|
-
```typescript
|
|
573
|
-
parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
同一个任务 id 每次计算 jitter 都稳定。
|
|
577
|
-
|
|
578
|
-
---
|
|
579
|
-
|
|
580
|
-
## 10. 第六层:REPL 接入
|
|
581
|
-
|
|
582
|
-
核心文件:`src/hooks/useScheduledTasks.ts`
|
|
583
|
-
|
|
584
|
-
这个 hook 负责把 scheduler 接到 REPL。
|
|
585
|
-
|
|
586
|
-
### 10.1 创建 scheduler
|
|
587
|
-
|
|
588
|
-
```typescript
|
|
589
|
-
const scheduler = createCronScheduler({
|
|
590
|
-
onFire: enqueueForLead,
|
|
591
|
-
onFireTask: task => { ... },
|
|
592
|
-
isLoading: () => isLoadingRef.current,
|
|
593
|
-
assistantMode,
|
|
594
|
-
getJitterConfig: getCronJitterConfig,
|
|
595
|
-
isKilled: () => !isKairosCronEnabled(),
|
|
596
|
-
})
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
### 10.2 enqueueForLead
|
|
600
|
-
|
|
601
|
-
普通任务触发后,不是直接执行,而是入队:
|
|
602
|
-
|
|
603
|
-
```typescript
|
|
604
|
-
enqueuePendingNotification({
|
|
605
|
-
value: prompt,
|
|
606
|
-
mode: 'prompt',
|
|
607
|
-
priority: 'later',
|
|
608
|
-
isMeta: true,
|
|
609
|
-
workload: WORKLOAD_CRON,
|
|
610
|
-
})
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
这让 scheduled task 和普通用户输入走同一套后续处理流程。
|
|
614
|
-
|
|
615
|
-
### 10.3 teammate 路由
|
|
616
|
-
|
|
617
|
-
如果 task 有 `agentId`:
|
|
618
|
-
|
|
619
|
-
1. 查找对应 teammate task
|
|
620
|
-
2. 如果还活着,调用 `injectUserMessageToTeammate`
|
|
621
|
-
3. 如果 teammate 已结束,删除这个 cron,避免无限触发
|
|
622
|
-
|
|
623
|
-
### 10.4 普通主线程任务
|
|
624
|
-
|
|
625
|
-
没有 `agentId` 时:
|
|
626
|
-
|
|
627
|
-
1. 往消息列表追加 “Running scheduled task”
|
|
628
|
-
2. 调用 `enqueueForLead(task.prompt)`
|
|
629
|
-
|
|
630
|
-
---
|
|
631
|
-
|
|
632
|
-
## 11. 从零实现步骤
|
|
633
|
-
|
|
634
|
-
本节假设你要在一个类似 Claude Code 的项目里复现 `/loop`。
|
|
635
|
-
|
|
636
|
-
### 步骤 1:定义 CronTask 类型
|
|
637
|
-
|
|
638
|
-
新建或扩展 `cronTasks.ts`:
|
|
639
|
-
|
|
640
|
-
```typescript
|
|
641
|
-
export type CronTask = {
|
|
642
|
-
id: string
|
|
643
|
-
cron: string
|
|
644
|
-
prompt: string
|
|
645
|
-
createdAt: number
|
|
646
|
-
lastFiredAt?: number
|
|
647
|
-
recurring?: boolean
|
|
648
|
-
permanent?: boolean
|
|
649
|
-
durable?: boolean
|
|
650
|
-
agentId?: string
|
|
651
|
-
}
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
### 步骤 2:实现 durable 文件路径
|
|
655
|
-
|
|
656
|
-
```typescript
|
|
657
|
-
const CRON_FILE_REL = join('.claude', 'scheduled_tasks.json')
|
|
658
|
-
|
|
659
|
-
export function getCronFilePath(root: string): string {
|
|
660
|
-
return join(root, CRON_FILE_REL)
|
|
661
|
-
}
|
|
662
|
-
```
|
|
663
|
-
|
|
664
|
-
### 步骤 3:实现 readCronTasks
|
|
665
|
-
|
|
666
|
-
要求:
|
|
667
|
-
|
|
668
|
-
1. 文件不存在返回 `[]`
|
|
669
|
-
2. JSON malformed 返回 `[]`
|
|
670
|
-
3. `tasks` 不是数组返回 `[]`
|
|
671
|
-
4. 单个 task 缺字段就跳过
|
|
672
|
-
5. cron 无效就跳过
|
|
673
|
-
|
|
674
|
-
不要因为一个坏 task 让整个 scheduler 崩溃。
|
|
675
|
-
|
|
676
|
-
### 步骤 4:实现 writeCronTasks
|
|
677
|
-
|
|
678
|
-
要求:
|
|
679
|
-
|
|
680
|
-
1. 自动创建 `.claude/`
|
|
681
|
-
2. 写入 `{ tasks: [...] }`
|
|
682
|
-
3. 移除 runtime-only 字段 `durable`
|
|
683
|
-
4. 最后加换行
|
|
684
|
-
|
|
685
|
-
### 步骤 5:实现 session task store
|
|
686
|
-
|
|
687
|
-
如果项目已有全局 state,就放进去。否则可以先做模块级变量:
|
|
688
|
-
|
|
689
|
-
```typescript
|
|
690
|
-
const sessionCronTasks: CronTask[] = []
|
|
691
|
-
|
|
692
|
-
export function addSessionCronTask(task: CronTask) {
|
|
693
|
-
sessionCronTasks.push(task)
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
export function getSessionCronTasks() {
|
|
697
|
-
return [...sessionCronTasks]
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
export function removeSessionCronTasks(ids: string[]) {
|
|
701
|
-
...
|
|
702
|
-
}
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### 步骤 6:实现 addCronTask
|
|
706
|
-
|
|
707
|
-
逻辑:
|
|
708
|
-
|
|
709
|
-
1. 生成短 id
|
|
710
|
-
2. 组装 task
|
|
711
|
-
3. durable false 写 session store
|
|
712
|
-
4. durable true 读文件、push、写回文件
|
|
713
|
-
5. 返回 id
|
|
714
|
-
|
|
715
|
-
### 步骤 7:实现 cron parser
|
|
716
|
-
|
|
717
|
-
最小版本只需要支持:
|
|
718
|
-
|
|
719
|
-
1. `*/N * * * *`
|
|
720
|
-
2. `0 */N * * *`
|
|
721
|
-
3. `0 0 */N * *`
|
|
722
|
-
|
|
723
|
-
完整版本要支持标准 5-field cron:
|
|
724
|
-
|
|
725
|
-
```text
|
|
726
|
-
minute hour day-of-month month day-of-week
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
必须实现:
|
|
730
|
-
|
|
731
|
-
```typescript
|
|
732
|
-
parseCronExpression(cron): ParsedCron | null
|
|
733
|
-
computeNextCronRun(parsed, fromDate): Date | null
|
|
734
|
-
nextCronRunMs(cron, fromMs): number | null
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
### 步骤 8:实现 CronCreateTool
|
|
738
|
-
|
|
739
|
-
工具输入:
|
|
740
|
-
|
|
741
|
-
```typescript
|
|
742
|
-
{
|
|
743
|
-
cron: string
|
|
744
|
-
prompt: string
|
|
745
|
-
recurring?: boolean
|
|
746
|
-
durable?: boolean
|
|
747
|
-
}
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
`validateInput`:
|
|
751
|
-
|
|
752
|
-
1. cron 格式有效
|
|
753
|
-
2. 能找到下一次 fire 时间
|
|
754
|
-
3. job 数量小于 50
|
|
755
|
-
|
|
756
|
-
`call`:
|
|
757
|
-
|
|
758
|
-
1. 调 `addCronTask`
|
|
759
|
-
2. 设置 scheduler enabled flag
|
|
760
|
-
3. 返回 id 和 human schedule
|
|
761
|
-
|
|
762
|
-
### 步骤 9:实现 CronDeleteTool
|
|
763
|
-
|
|
764
|
-
输入:
|
|
765
|
-
|
|
766
|
-
```typescript
|
|
767
|
-
{ id: string }
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
行为:
|
|
771
|
-
|
|
772
|
-
1. 从 session store 删除
|
|
773
|
-
2. 从 durable 文件删除
|
|
774
|
-
3. 返回是否删除成功
|
|
775
|
-
|
|
776
|
-
### 步骤 10:实现 CronListTool
|
|
777
|
-
|
|
778
|
-
行为:
|
|
779
|
-
|
|
780
|
-
1. 读取 durable tasks
|
|
781
|
-
2. 合并 session tasks
|
|
782
|
-
3. 返回 id、cron、prompt、recurring、durable、next fire time
|
|
783
|
-
|
|
784
|
-
### 步骤 11:实现 createCronScheduler
|
|
785
|
-
|
|
786
|
-
输入 options:
|
|
787
|
-
|
|
788
|
-
```typescript
|
|
789
|
-
type CronSchedulerOptions = {
|
|
790
|
-
onFire: (prompt: string) => void
|
|
791
|
-
onFireTask?: (task: CronTask) => void
|
|
792
|
-
isLoading: () => boolean
|
|
793
|
-
assistantMode?: boolean
|
|
794
|
-
dir?: string
|
|
795
|
-
isKilled?: () => boolean
|
|
796
|
-
}
|
|
797
|
-
```
|
|
798
|
-
|
|
799
|
-
返回:
|
|
800
|
-
|
|
801
|
-
```typescript
|
|
802
|
-
{
|
|
803
|
-
start(): void
|
|
804
|
-
stop(): void
|
|
805
|
-
getNextFireTime(): number | null
|
|
806
|
-
}
|
|
807
|
-
```
|
|
808
|
-
|
|
809
|
-
### 步骤 12:scheduler.start
|
|
810
|
-
|
|
811
|
-
实现:
|
|
812
|
-
|
|
813
|
-
1. 如果当前没有 enabled,轮询等待
|
|
814
|
-
2. enabled 后读取 durable tasks
|
|
815
|
-
3. watch 文件变化
|
|
816
|
-
4. setInterval 每秒调用 check
|
|
817
|
-
|
|
818
|
-
最小版本可以不做文件 watch,只每秒同时读取 session 和文件。
|
|
819
|
-
|
|
820
|
-
### 步骤 13:scheduler.check
|
|
821
|
-
|
|
822
|
-
实现:
|
|
823
|
-
|
|
824
|
-
1. killed 则 return
|
|
825
|
-
2. loading 且非 assistantMode 则 return
|
|
826
|
-
3. 遍历 file tasks
|
|
827
|
-
4. 遍历 session tasks
|
|
828
|
-
5. 到点则 fire
|
|
829
|
-
6. recurring 重新计算下一次
|
|
830
|
-
7. one-shot 删除
|
|
831
|
-
|
|
832
|
-
### 步骤 14:实现 `/loop` skill
|
|
833
|
-
|
|
834
|
-
创建 `loop.ts`:
|
|
835
|
-
|
|
836
|
-
```typescript
|
|
837
|
-
const DEFAULT_INTERVAL = '10m'
|
|
838
|
-
|
|
839
|
-
function buildPrompt(args: string): string {
|
|
840
|
-
return `
|
|
841
|
-
Parse the input into [interval] <prompt>.
|
|
842
|
-
Call CronCreate with cron, prompt, recurring: true.
|
|
843
|
-
Then execute the prompt immediately.
|
|
844
|
-
Input:
|
|
845
|
-
${args}
|
|
846
|
-
`
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
export function registerLoopSkill() {
|
|
850
|
-
registerBundledSkill({
|
|
851
|
-
name: 'loop',
|
|
852
|
-
description: 'Run a prompt or slash command on a recurring interval',
|
|
853
|
-
argumentHint: '[interval] <prompt>',
|
|
854
|
-
userInvocable: true,
|
|
855
|
-
getPromptForCommand(args) {
|
|
856
|
-
const trimmed = args.trim()
|
|
857
|
-
if (!trimmed) return [{ type: 'text', text: USAGE_MESSAGE }]
|
|
858
|
-
return [{ type: 'text', text: buildPrompt(trimmed) }]
|
|
859
|
-
},
|
|
860
|
-
})
|
|
861
|
-
}
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
### 步骤 15:注册 `/loop`
|
|
865
|
-
|
|
866
|
-
在 bundled skills 初始化入口:
|
|
867
|
-
|
|
868
|
-
```typescript
|
|
869
|
-
registerLoopSkill()
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
### 步骤 16:注册 Cron tools
|
|
873
|
-
|
|
874
|
-
在工具注册中心加入:
|
|
875
|
-
|
|
876
|
-
```typescript
|
|
877
|
-
CronCreateTool
|
|
878
|
-
CronDeleteTool
|
|
879
|
-
CronListTool
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
否则模型看到 `/loop` prompt 后无法调用 `CronCreate`。
|
|
883
|
-
|
|
884
|
-
### 步骤 17:接入 REPL
|
|
885
|
-
|
|
886
|
-
在 REPL 顶层 hook 或启动逻辑中:
|
|
887
|
-
|
|
888
|
-
```typescript
|
|
889
|
-
const scheduler = createCronScheduler({
|
|
890
|
-
onFire: prompt => enqueuePendingNotification({
|
|
891
|
-
value: prompt,
|
|
892
|
-
mode: 'prompt',
|
|
893
|
-
priority: 'later',
|
|
894
|
-
isMeta: true,
|
|
895
|
-
}),
|
|
896
|
-
isLoading: () => currentIsLoading,
|
|
897
|
-
})
|
|
898
|
-
|
|
899
|
-
scheduler.start()
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
卸载时:
|
|
903
|
-
|
|
904
|
-
```typescript
|
|
905
|
-
scheduler.stop()
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
---
|
|
909
|
-
|
|
910
|
-
## 12. 最小可用版本
|
|
911
|
-
|
|
912
|
-
如果只想先做出能跑的 `/loop`,可以砍掉这些复杂性:
|
|
913
|
-
|
|
914
|
-
1. 不支持 durable,只做 session-only
|
|
915
|
-
2. 不支持 teammate
|
|
916
|
-
3. 不支持 missed task
|
|
917
|
-
4. 不支持 file watcher
|
|
918
|
-
5. 不支持 scheduler lock
|
|
919
|
-
6. 不支持 jitter
|
|
920
|
-
7. 不支持 GrowthBook feature gate
|
|
921
|
-
8. 不支持 one-shot
|
|
922
|
-
|
|
923
|
-
最小数据流:
|
|
924
|
-
|
|
925
|
-
```
|
|
926
|
-
/loop 5m foo
|
|
927
|
-
│
|
|
928
|
-
▼
|
|
929
|
-
模型调用 CronCreate({ cron: "*/5 * * * *", prompt: "foo", recurring: true })
|
|
930
|
-
│
|
|
931
|
-
▼
|
|
932
|
-
addSessionCronTask
|
|
933
|
-
│
|
|
934
|
-
▼
|
|
935
|
-
setInterval 每秒检查
|
|
936
|
-
│
|
|
937
|
-
▼
|
|
938
|
-
到点 enqueue prompt
|
|
939
|
-
```
|
|
940
|
-
|
|
941
|
-
最小版本必须保留:
|
|
942
|
-
|
|
943
|
-
1. `/loop` skill
|
|
944
|
-
2. `CronCreate`
|
|
945
|
-
3. session task store
|
|
946
|
-
4. scheduler
|
|
947
|
-
5. REPL enqueue
|
|
948
|
-
|
|
949
|
-
---
|
|
950
|
-
|
|
951
|
-
## 13. 完整版本增强项
|
|
952
|
-
|
|
953
|
-
最小版本跑通后,再按顺序加:
|
|
954
|
-
|
|
955
|
-
### 13.1 CronDelete
|
|
956
|
-
|
|
957
|
-
用户必须能取消 recurring 任务,否则 loop 会一直跑。
|
|
958
|
-
|
|
959
|
-
### 13.2 CronList
|
|
960
|
-
|
|
961
|
-
便于用户查看当前有哪些任务,也便于测试。
|
|
962
|
-
|
|
963
|
-
### 13.3 durable
|
|
964
|
-
|
|
965
|
-
加入 `.claude/scheduled_tasks.json`,让任务重启后恢复。
|
|
966
|
-
|
|
967
|
-
### 13.4 scheduler lock
|
|
968
|
-
|
|
969
|
-
有 durable 后必须加 lock,否则同目录多个进程会重复触发。
|
|
970
|
-
|
|
971
|
-
### 13.5 file watcher
|
|
972
|
-
|
|
973
|
-
监听 `.claude/scheduled_tasks.json` 变化,支持其他进程增删任务。
|
|
974
|
-
|
|
975
|
-
### 13.6 missed one-shot
|
|
976
|
-
|
|
977
|
-
Claude 关闭期间错过的一次性任务,启动后不要直接执行,应先问用户是否补跑。
|
|
978
|
-
|
|
979
|
-
### 13.7 jitter
|
|
980
|
-
|
|
981
|
-
防止整点和半点流量尖峰。
|
|
982
|
-
|
|
983
|
-
### 13.8 recurring 自动过期
|
|
984
|
-
|
|
985
|
-
默认 7 天,防止长期无人管理的 loop 无限运行。
|
|
986
|
-
|
|
987
|
-
---
|
|
988
|
-
|
|
989
|
-
## 14. 测试清单
|
|
990
|
-
|
|
991
|
-
### 14.1 `/loop` prompt 生成
|
|
992
|
-
|
|
993
|
-
输入:
|
|
994
|
-
|
|
995
|
-
```text
|
|
996
|
-
/loop
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
期望:
|
|
1000
|
-
|
|
1001
|
-
1. 返回 usage
|
|
1002
|
-
2. 不调用 `CronCreate`
|
|
1003
|
-
|
|
1004
|
-
输入:
|
|
1005
|
-
|
|
1006
|
-
```text
|
|
1007
|
-
/loop 5m check deploy
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
期望模型调用:
|
|
1011
|
-
|
|
1012
|
-
```json
|
|
1013
|
-
{
|
|
1014
|
-
"cron": "*/5 * * * *",
|
|
1015
|
-
"prompt": "check deploy",
|
|
1016
|
-
"recurring": true
|
|
1017
|
-
}
|
|
1018
|
-
```
|
|
1019
|
-
|
|
1020
|
-
输入:
|
|
1021
|
-
|
|
1022
|
-
```text
|
|
1023
|
-
/loop check deploy every 20m
|
|
1024
|
-
```
|
|
1025
|
-
|
|
1026
|
-
期望模型调用:
|
|
1027
|
-
|
|
1028
|
-
```json
|
|
1029
|
-
{
|
|
1030
|
-
"cron": "*/20 * * * *",
|
|
1031
|
-
"prompt": "check deploy",
|
|
1032
|
-
"recurring": true
|
|
1033
|
-
}
|
|
1034
|
-
```
|
|
1035
|
-
|
|
1036
|
-
输入:
|
|
1037
|
-
|
|
1038
|
-
```text
|
|
1039
|
-
/loop check every PR
|
|
1040
|
-
```
|
|
1041
|
-
|
|
1042
|
-
期望:
|
|
1043
|
-
|
|
1044
|
-
```json
|
|
1045
|
-
{
|
|
1046
|
-
"cron": "*/10 * * * *",
|
|
1047
|
-
"prompt": "check every PR",
|
|
1048
|
-
"recurring": true
|
|
1049
|
-
}
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
### 14.2 CronCreate validation
|
|
1053
|
-
|
|
1054
|
-
| 输入 | 期望 |
|
|
1055
|
-
|------|------|
|
|
1056
|
-
| `*/5 * * * *` | 通过 |
|
|
1057
|
-
| `bad cron` | 拒绝 |
|
|
1058
|
-
| 无未来运行时间 | 拒绝 |
|
|
1059
|
-
| 已有 50 个任务 | 拒绝 |
|
|
1060
|
-
|
|
1061
|
-
### 14.3 session-only 创建
|
|
1062
|
-
|
|
1063
|
-
调用:
|
|
1064
|
-
|
|
1065
|
-
```typescript
|
|
1066
|
-
addCronTask('*/5 * * * *', 'hello', true, false)
|
|
1067
|
-
```
|
|
1068
|
-
|
|
1069
|
-
期望:
|
|
1070
|
-
|
|
1071
|
-
1. 返回 8 位 id
|
|
1072
|
-
2. session store 增加 1 个任务
|
|
1073
|
-
3. `.claude/scheduled_tasks.json` 不变
|
|
1074
|
-
|
|
1075
|
-
### 14.4 durable 创建
|
|
1076
|
-
|
|
1077
|
-
调用:
|
|
1078
|
-
|
|
1079
|
-
```typescript
|
|
1080
|
-
addCronTask('*/5 * * * *', 'hello', true, true)
|
|
1081
|
-
```
|
|
1082
|
-
|
|
1083
|
-
期望:
|
|
1084
|
-
|
|
1085
|
-
1. `.claude/scheduled_tasks.json` 被创建
|
|
1086
|
-
2. JSON 里有该任务
|
|
1087
|
-
3. `durable` 字段不写入磁盘
|
|
1088
|
-
|
|
1089
|
-
### 14.5 scheduler fire
|
|
1090
|
-
|
|
1091
|
-
创建一个下一分钟触发的任务。
|
|
1092
|
-
|
|
1093
|
-
期望:
|
|
1094
|
-
|
|
1095
|
-
1. 到点前不 enqueue
|
|
1096
|
-
2. 到点后 enqueue 一次
|
|
1097
|
-
3. recurring 任务更新下一次 fire time
|
|
1098
|
-
4. one-shot 任务触发后删除
|
|
1099
|
-
|
|
1100
|
-
### 14.6 isLoading gate
|
|
1101
|
-
|
|
1102
|
-
设置:
|
|
1103
|
-
|
|
1104
|
-
```typescript
|
|
1105
|
-
isLoading: () => true
|
|
1106
|
-
assistantMode: false
|
|
1107
|
-
```
|
|
1108
|
-
|
|
1109
|
-
期望:
|
|
1110
|
-
|
|
1111
|
-
1. 到点不触发
|
|
1112
|
-
2. `isLoading` 变 false 后触发
|
|
1113
|
-
|
|
1114
|
-
设置:
|
|
1115
|
-
|
|
1116
|
-
```typescript
|
|
1117
|
-
isLoading: () => true
|
|
1118
|
-
assistantMode: true
|
|
1119
|
-
```
|
|
1120
|
-
|
|
1121
|
-
期望:
|
|
1122
|
-
|
|
1123
|
-
1. 到点仍然可以 enqueue
|
|
1124
|
-
|
|
1125
|
-
### 14.7 CronDelete
|
|
1126
|
-
|
|
1127
|
-
创建任务后删除。
|
|
1128
|
-
|
|
1129
|
-
期望:
|
|
1130
|
-
|
|
1131
|
-
1. session store 中删除
|
|
1132
|
-
2. durable 文件中删除
|
|
1133
|
-
3. scheduler 不再触发
|
|
1134
|
-
|
|
1135
|
-
### 14.8 多进程 lock
|
|
1136
|
-
|
|
1137
|
-
启动两个 scheduler,指向同一个 `.claude/scheduled_tasks.json`。
|
|
1138
|
-
|
|
1139
|
-
期望:
|
|
1140
|
-
|
|
1141
|
-
1. 只有 lock owner 触发 durable task
|
|
1142
|
-
2. 非 owner 不触发 file-backed task
|
|
1143
|
-
3. owner 停止后,另一个 scheduler 能接管
|
|
1144
|
-
|
|
1145
|
-
---
|
|
1146
|
-
|
|
1147
|
-
## 15. 常见错误
|
|
1148
|
-
|
|
1149
|
-
### 错误 1:把 `/loop` 写成 local command
|
|
1150
|
-
|
|
1151
|
-
不要把 `/loop` 写成直接创建任务的 local command。现有设计是 prompt skill,让模型负责解析自然语言并调用 `CronCreate`。
|
|
1152
|
-
|
|
1153
|
-
如果写成 local command,会失去:
|
|
1154
|
-
|
|
1155
|
-
1. 自然语言解析能力
|
|
1156
|
-
2. slash command prompt 透传能力
|
|
1157
|
-
3. 模型立即执行一次 prompt 的能力
|
|
1158
|
-
|
|
1159
|
-
### 错误 2:忘记注册 CronCreate 工具
|
|
1160
|
-
|
|
1161
|
-
`/loop` prompt 会要求模型调用 `CronCreate`。如果工具没注册,模型只能输出文字,不能真正创建任务。
|
|
1162
|
-
|
|
1163
|
-
### 错误 3:创建任务后没打开 scheduler enabled flag
|
|
1164
|
-
|
|
1165
|
-
`CronCreate.call()` 必须调用:
|
|
1166
|
-
|
|
1167
|
-
```typescript
|
|
1168
|
-
setScheduledTasksEnabled(true)
|
|
1169
|
-
```
|
|
1170
|
-
|
|
1171
|
-
否则 scheduler 可能一直在等待,不会开始检查任务。
|
|
1172
|
-
|
|
1173
|
-
### 错误 4:durable 任务没有 lock
|
|
1174
|
-
|
|
1175
|
-
多个 Claude session 共享同一个项目目录时,如果没有 lock,同一个 durable task 会被触发多次。
|
|
1176
|
-
|
|
1177
|
-
### 错误 5:recurring 任务从旧时间补跑
|
|
1178
|
-
|
|
1179
|
-
recurring 触发后下一次应该从 `now` 重新计算,而不是从上一次理论 fire time 继续追赶。否则应用卡顿或休眠后可能连续快速执行多次。
|
|
1180
|
-
|
|
1181
|
-
正确逻辑:
|
|
1182
|
-
|
|
1183
|
-
```typescript
|
|
1184
|
-
newNext = jitteredNextCronRunMs(task.cron, now, task.id)
|
|
1185
|
-
```
|
|
1186
|
-
|
|
1187
|
-
### 错误 6:把 session-only 任务写进文件
|
|
1188
|
-
|
|
1189
|
-
默认 `durable=false`。用户没有明确要求跨 session 保留时,不要写 `.claude/scheduled_tasks.json`。
|
|
1190
|
-
|
|
1191
|
-
### 错误 7:空 `/loop` 也创建任务
|
|
1192
|
-
|
|
1193
|
-
空输入必须显示 usage,并停止。
|
|
1194
|
-
|
|
1195
|
-
### 错误 8:没有立即执行一次
|
|
1196
|
-
|
|
1197
|
-
现有 `/loop` prompt 明确要求创建后立刻执行 parsed prompt。不要等第一次 cron fire。
|
|
1198
|
-
|
|
1199
|
-
---
|
|
1200
|
-
|
|
1201
|
-
## 16. 关键结论
|
|
1202
|
-
|
|
1203
|
-
`/loop` 的实现可以拆成一句话:
|
|
1204
|
-
|
|
1205
|
-
> `/loop` 是一个 prompt skill,它把“自然语言定时请求”翻译成对 `CronCreate` 工具的调用;真正的任务存储、调度、触发和取消都由 Cron 工具链负责。
|
|
1206
|
-
|
|
1207
|
-
如果要复现,按这个顺序实现最稳:
|
|
1208
|
-
|
|
1209
|
-
1. `CronTask` 类型
|
|
1210
|
-
2. session-only task store
|
|
1211
|
-
3. `CronCreate`
|
|
1212
|
-
4. 简单 scheduler
|
|
1213
|
-
5. REPL enqueue
|
|
1214
|
-
6. `/loop` bundled skill
|
|
1215
|
-
7. `CronDelete`
|
|
1216
|
-
8. `CronList`
|
|
1217
|
-
9. durable 文件存储
|
|
1218
|
-
10. scheduler lock
|
|
1219
|
-
11. file watcher
|
|
1220
|
-
12. jitter 和 auto-expiry
|
|
1221
|
-
|
|
1222
|
-
这样即使模型能力较弱,也可以每一步只完成一个小模块,并用测试清单逐项验证。
|
|
1
|
+
# 07 | `/loop` 命令实现与复现指南
|
|
2
|
+
|
|
3
|
+
> 基于 Claude Code v2.1.88 反编译源码的逆向分析文档。本文目标不是只解释现有代码,而是把 `/loop` 的实现拆成足够小的步骤,让能力较弱的大模型也能按步骤复现出同等功能。
|
|
4
|
+
|
|
5
|
+
## 目录
|
|
6
|
+
|
|
7
|
+
1. [一句话总结](#1-一句话总结)
|
|
8
|
+
2. [核心文件清单](#2-核心文件清单)
|
|
9
|
+
3. [整体数据流](#3-整体数据流)
|
|
10
|
+
4. [功能边界](#4-功能边界)
|
|
11
|
+
5. [第一层:注册 bundled skill](#5-第一层注册-bundled-skill)
|
|
12
|
+
6. [第二层:`/loop` skill prompt](#6-第二层loop-skill-prompt)
|
|
13
|
+
7. [第三层:CronCreate 工具](#7-第三层croncreate-工具)
|
|
14
|
+
8. [第四层:任务存储](#8-第四层任务存储)
|
|
15
|
+
9. [第五层:调度器](#9-第五层调度器)
|
|
16
|
+
10. [第六层:REPL 接入](#10-第六层repl-接入)
|
|
17
|
+
11. [从零实现步骤](#11-从零实现步骤)
|
|
18
|
+
12. [最小可用版本](#12-最小可用版本)
|
|
19
|
+
13. [完整版本增强项](#13-完整版本增强项)
|
|
20
|
+
14. [测试清单](#14-测试清单)
|
|
21
|
+
15. [常见错误](#15-常见错误)
|
|
22
|
+
16. [关键结论](#16-关键结论)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 1. 一句话总结
|
|
27
|
+
|
|
28
|
+
`/loop` 不是一个直接执行定时逻辑的普通命令。它是一个 bundled skill:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
用户输入 /loop 5m check deploy
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
processSlashCommand 识别为 prompt skill
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
/loop skill 返回一段“请解析参数并调用 CronCreate”的 prompt
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
主模型读取 prompt,调用 CronCreate 工具
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
CronCreate 写入 session 内存或 .claude/scheduled_tasks.json
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
cronScheduler 每秒检查,到点后把 prompt 重新放入队列
|
|
47
|
+
│
|
|
48
|
+
▼
|
|
49
|
+
REPL 像处理普通用户输入一样处理这个 scheduled prompt
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
最重要的设计点:**`/loop` 本身不解析 cron,也不直接调度任务,它把解析规则写进 skill prompt,让模型调用 `CronCreate` 工具。**
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 2. 核心文件清单
|
|
57
|
+
|
|
58
|
+
| 文件 | 作用 |
|
|
59
|
+
|------|------|
|
|
60
|
+
| `src/skills/bundled/index.ts` | 启动时注册 bundled skills,包含 `/loop` 的 feature gate |
|
|
61
|
+
| `src/skills/bundled/loop.ts` | `/loop` skill 的实现,生成解析和调度用 prompt |
|
|
62
|
+
| `src/skills/bundledSkills.ts` | bundled skill 注册表,把 skill 转成 `Command` |
|
|
63
|
+
| `src/utils/processUserInput/processSlashCommand.tsx` | slash command 分发逻辑,把 `/loop` 转成模型可见 prompt |
|
|
64
|
+
| `src/tools/ScheduleCronTool/prompt.ts` | cron 工具的名字、说明、feature gate |
|
|
65
|
+
| `src/tools/ScheduleCronTool/CronCreateTool.ts` | 创建定时任务的工具 |
|
|
66
|
+
| `src/tools/ScheduleCronTool/CronDeleteTool.ts` | 删除定时任务的工具 |
|
|
67
|
+
| `src/tools/ScheduleCronTool/CronListTool.ts` | 列出定时任务的工具 |
|
|
68
|
+
| `src/utils/cronTasks.ts` | 任务读写、内存任务、durable 文件任务、jitter 计算 |
|
|
69
|
+
| `src/utils/cronScheduler.ts` | 非 React 调度器核心,每秒检查任务是否到期 |
|
|
70
|
+
| `src/hooks/useScheduledTasks.ts` | REPL 中挂载 scheduler,把到期任务放入命令队列 |
|
|
71
|
+
| `src/tools.ts` | 工具注册中心,把 CronCreate/CronDelete/CronList 加入工具集合 |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 3. 整体数据流
|
|
76
|
+
|
|
77
|
+
### 3.1 用户创建 loop
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
/loop 5m /standup 1
|
|
81
|
+
│
|
|
82
|
+
▼
|
|
83
|
+
parseSlashCommand(input)
|
|
84
|
+
│
|
|
85
|
+
▼
|
|
86
|
+
getCommand("loop")
|
|
87
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
loop.getPromptForCommand("5m /standup 1")
|
|
90
|
+
│
|
|
91
|
+
▼
|
|
92
|
+
生成 meta user message:
|
|
93
|
+
“解析输入,转 cron,调用 CronCreate”
|
|
94
|
+
│
|
|
95
|
+
▼
|
|
96
|
+
主模型调用:
|
|
97
|
+
CronCreate({
|
|
98
|
+
cron: "*/5 * * * *",
|
|
99
|
+
prompt: "/standup 1",
|
|
100
|
+
recurring: true
|
|
101
|
+
})
|
|
102
|
+
│
|
|
103
|
+
▼
|
|
104
|
+
addCronTask(...)
|
|
105
|
+
│
|
|
106
|
+
├─ durable=false: 写入 session memory
|
|
107
|
+
└─ durable=true: 写入 .claude/scheduled_tasks.json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 3.2 定时任务触发
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
cronScheduler 每 1 秒 check()
|
|
114
|
+
│
|
|
115
|
+
▼
|
|
116
|
+
计算每个任务 nextFireAt
|
|
117
|
+
│
|
|
118
|
+
▼
|
|
119
|
+
now >= nextFireAt ?
|
|
120
|
+
│
|
|
121
|
+
├─ 否:等待下一秒
|
|
122
|
+
│
|
|
123
|
+
└─ 是:
|
|
124
|
+
│
|
|
125
|
+
▼
|
|
126
|
+
onFireTask(task)
|
|
127
|
+
│
|
|
128
|
+
▼
|
|
129
|
+
enqueuePendingNotification({
|
|
130
|
+
value: task.prompt,
|
|
131
|
+
mode: "prompt",
|
|
132
|
+
priority: "later",
|
|
133
|
+
isMeta: true,
|
|
134
|
+
workload: WORKLOAD_CRON
|
|
135
|
+
})
|
|
136
|
+
│
|
|
137
|
+
▼
|
|
138
|
+
REPL 队列在空闲时执行该 prompt
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 4. 功能边界
|
|
144
|
+
|
|
145
|
+
`/loop` 要支持:
|
|
146
|
+
|
|
147
|
+
1. `/loop 5m check deploy`
|
|
148
|
+
2. `/loop check deploy`
|
|
149
|
+
3. `/loop check deploy every 20m`
|
|
150
|
+
4. `/loop 1h /standup 1`
|
|
151
|
+
5. 空输入时显示 usage
|
|
152
|
+
6. 创建后立即执行一次原 prompt
|
|
153
|
+
7. 后续按 cron 反复执行
|
|
154
|
+
8. 支持取消,提示用户用 `CronDelete`
|
|
155
|
+
9. recurring 任务默认 7 天后自动过期
|
|
156
|
+
|
|
157
|
+
`/loop` 不负责:
|
|
158
|
+
|
|
159
|
+
1. 直接操作文件
|
|
160
|
+
2. 自己实现定时器
|
|
161
|
+
3. 自己执行 bash 或 slash command
|
|
162
|
+
4. 自己决定权限
|
|
163
|
+
5. 自己实现 durable 存储
|
|
164
|
+
|
|
165
|
+
这些都交给 Cron 工具和 scheduler。
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 5. 第一层:注册 bundled skill
|
|
170
|
+
|
|
171
|
+
入口文件:`src/skills/bundled/index.ts`
|
|
172
|
+
|
|
173
|
+
关键逻辑:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
if (feature('AGENT_TRIGGERS')) {
|
|
177
|
+
const { registerLoopSkill } = require('./loop.js')
|
|
178
|
+
registerLoopSkill()
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
这说明 `/loop` 被 `AGENT_TRIGGERS` 编译期开关控制。即使注册了,最终是否可用还会由 `isKairosCronEnabled()` 决定。
|
|
183
|
+
|
|
184
|
+
复现时需要做三件事:
|
|
185
|
+
|
|
186
|
+
1. 在 bundled skill 初始化入口中引入 `registerLoopSkill`
|
|
187
|
+
2. 用 feature gate 包起来
|
|
188
|
+
3. 调用 `registerLoopSkill()`
|
|
189
|
+
|
|
190
|
+
如果没有 feature 系统,最小实现可以直接注册:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
registerLoopSkill()
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 6. 第二层:`/loop` skill prompt
|
|
199
|
+
|
|
200
|
+
核心文件:`src/skills/bundled/loop.ts`
|
|
201
|
+
|
|
202
|
+
### 6.1 默认间隔
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
const DEFAULT_INTERVAL = '10m'
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
如果用户没有写时间,默认每 10 分钟执行一次。
|
|
209
|
+
|
|
210
|
+
### 6.2 空输入 usage
|
|
211
|
+
|
|
212
|
+
空输入返回说明文案,不调用 CronCreate:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
if (!trimmed) {
|
|
216
|
+
return [{ type: 'text', text: USAGE_MESSAGE }]
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### 6.3 非空输入 buildPrompt
|
|
221
|
+
|
|
222
|
+
`buildPrompt(args)` 返回一整段给模型的 instructions,核心要求是:
|
|
223
|
+
|
|
224
|
+
1. 解析 interval 和 prompt
|
|
225
|
+
2. 把 interval 转成 cron
|
|
226
|
+
3. 调用 `CronCreate`
|
|
227
|
+
4. 告知用户 job id 和过期时间
|
|
228
|
+
5. 立即执行一次 prompt
|
|
229
|
+
|
|
230
|
+
关键 prompt 规则:
|
|
231
|
+
|
|
232
|
+
```text
|
|
233
|
+
1. Leading token:
|
|
234
|
+
如果第一个 token 匹配 ^\d+[smhd]$,它就是 interval。
|
|
235
|
+
|
|
236
|
+
2. Trailing "every" clause:
|
|
237
|
+
如果输入以 every <N><unit> 或 every <N> <unit-word> 结尾,提取为 interval。
|
|
238
|
+
|
|
239
|
+
3. Default:
|
|
240
|
+
否则 interval = 10m,整个输入都是 prompt。
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### 6.4 interval 到 cron 的转换
|
|
244
|
+
|
|
245
|
+
| interval | cron | 说明 |
|
|
246
|
+
|----------|------|------|
|
|
247
|
+
| `5m` | `*/5 * * * *` | 每 5 分钟 |
|
|
248
|
+
| `30m` | `*/30 * * * *` | 每 30 分钟 |
|
|
249
|
+
| `1h` | `0 */1 * * *` | 每小时 |
|
|
250
|
+
| `2h` | `0 */2 * * *` | 每 2 小时 |
|
|
251
|
+
| `1d` | `0 0 */1 * *` | 每天午夜 |
|
|
252
|
+
| `30s` | `*/1 * * * *` | 秒级向上取整到分钟 |
|
|
253
|
+
|
|
254
|
+
注意:cron 最小粒度是分钟,所以 `Ns` 要转换成 `ceil(N/60)m`,至少 1 分钟。
|
|
255
|
+
|
|
256
|
+
### 6.5 注册 skill
|
|
257
|
+
|
|
258
|
+
`registerLoopSkill()` 调用 `registerBundledSkill()`:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
registerBundledSkill({
|
|
262
|
+
name: 'loop',
|
|
263
|
+
description: 'Run a prompt or slash command on a recurring interval...',
|
|
264
|
+
whenToUse: 'When the user wants to set up a recurring task...',
|
|
265
|
+
argumentHint: '[interval] <prompt>',
|
|
266
|
+
userInvocable: true,
|
|
267
|
+
isEnabled: isKairosCronEnabled,
|
|
268
|
+
async getPromptForCommand(args) {
|
|
269
|
+
...
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
这会把 `/loop` 变成一个 `type: 'prompt'` 的 command。
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 7. 第三层:CronCreate 工具
|
|
279
|
+
|
|
280
|
+
核心文件:`src/tools/ScheduleCronTool/CronCreateTool.ts`
|
|
281
|
+
|
|
282
|
+
### 7.1 输入 schema
|
|
283
|
+
|
|
284
|
+
`CronCreate` 接收:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
{
|
|
288
|
+
cron: string,
|
|
289
|
+
prompt: string,
|
|
290
|
+
recurring?: boolean,
|
|
291
|
+
durable?: boolean,
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
字段含义:
|
|
296
|
+
|
|
297
|
+
| 字段 | 默认值 | 含义 |
|
|
298
|
+
|------|--------|------|
|
|
299
|
+
| `cron` | 无 | 5-field cron 表达式 |
|
|
300
|
+
| `prompt` | 无 | 到点后重新送入队列的 prompt |
|
|
301
|
+
| `recurring` | `true` | 是否反复执行 |
|
|
302
|
+
| `durable` | `false` | 是否写入 `.claude/scheduled_tasks.json` |
|
|
303
|
+
|
|
304
|
+
### 7.2 validateInput
|
|
305
|
+
|
|
306
|
+
创建任务前要做四个检查:
|
|
307
|
+
|
|
308
|
+
1. `parseCronExpression(input.cron)` 必须成功
|
|
309
|
+
2. `nextCronRunMs(input.cron, Date.now())` 必须能在一年内找到下一次运行时间
|
|
310
|
+
3. 当前任务数量不能超过 `MAX_JOBS = 50`
|
|
311
|
+
4. teammate 场景下不能创建 durable cron
|
|
312
|
+
|
|
313
|
+
伪代码:
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
if (!parseCronExpression(input.cron)) reject
|
|
317
|
+
if (nextCronRunMs(input.cron, Date.now()) === null) reject
|
|
318
|
+
if ((await listAllCronTasks()).length >= 50) reject
|
|
319
|
+
if (input.durable && getTeammateContext()) reject
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 7.3 call
|
|
323
|
+
|
|
324
|
+
真正创建任务:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const effectiveDurable = durable && isDurableCronEnabled()
|
|
328
|
+
const id = await addCronTask(
|
|
329
|
+
cron,
|
|
330
|
+
prompt,
|
|
331
|
+
recurring,
|
|
332
|
+
effectiveDurable,
|
|
333
|
+
getTeammateContext()?.agentId,
|
|
334
|
+
)
|
|
335
|
+
setScheduledTasksEnabled(true)
|
|
336
|
+
return { id, humanSchedule, recurring, durable: effectiveDurable }
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`setScheduledTasksEnabled(true)` 很关键。scheduler 启动后会轮询这个 flag,flag 打开后才开始 load/watch/check。
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 8. 第四层:任务存储
|
|
344
|
+
|
|
345
|
+
核心文件:`src/utils/cronTasks.ts`
|
|
346
|
+
|
|
347
|
+
### 8.1 CronTask 数据结构
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
type CronTask = {
|
|
351
|
+
id: string
|
|
352
|
+
cron: string
|
|
353
|
+
prompt: string
|
|
354
|
+
createdAt: number
|
|
355
|
+
lastFiredAt?: number
|
|
356
|
+
recurring?: boolean
|
|
357
|
+
permanent?: boolean
|
|
358
|
+
durable?: boolean
|
|
359
|
+
agentId?: string
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
字段说明:
|
|
364
|
+
|
|
365
|
+
| 字段 | 说明 |
|
|
366
|
+
|------|------|
|
|
367
|
+
| `id` | 8 位短 id,来自 `randomUUID().slice(0, 8)` |
|
|
368
|
+
| `cron` | 5-field cron |
|
|
369
|
+
| `prompt` | 到点后执行的文本 |
|
|
370
|
+
| `createdAt` | 创建时间,用于计算第一次 fire 和 missed task |
|
|
371
|
+
| `lastFiredAt` | durable recurring 任务上次执行时间 |
|
|
372
|
+
| `recurring` | 是否循环 |
|
|
373
|
+
| `permanent` | 系统内置任务可永久不过期 |
|
|
374
|
+
| `durable` | runtime-only,false 表示 session-only |
|
|
375
|
+
| `agentId` | teammate 专用,触发时投递给对应 teammate |
|
|
376
|
+
|
|
377
|
+
### 8.2 两种存储位置
|
|
378
|
+
|
|
379
|
+
#### session-only
|
|
380
|
+
|
|
381
|
+
默认路径,不写磁盘:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
addSessionCronTask(task)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
特点:
|
|
388
|
+
|
|
389
|
+
1. 只在当前进程有效
|
|
390
|
+
2. Claude 退出后丢失
|
|
391
|
+
3. scheduler 每秒从 bootstrap state 读取
|
|
392
|
+
4. 不需要文件锁
|
|
393
|
+
|
|
394
|
+
#### durable
|
|
395
|
+
|
|
396
|
+
写入:
|
|
397
|
+
|
|
398
|
+
```text
|
|
399
|
+
<project>/.claude/scheduled_tasks.json
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
特点:
|
|
403
|
+
|
|
404
|
+
1. 重启后还能恢复
|
|
405
|
+
2. 需要文件 watcher
|
|
406
|
+
3. 多 Claude session 共享同一个 cwd 时需要 scheduler lock
|
|
407
|
+
4. recurring 任务执行后要写回 `lastFiredAt`
|
|
408
|
+
|
|
409
|
+
### 8.3 addCronTask
|
|
410
|
+
|
|
411
|
+
伪代码:
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
function addCronTask(cron, prompt, recurring, durable, agentId) {
|
|
415
|
+
const id = randomUUID().slice(0, 8)
|
|
416
|
+
const task = { id, cron, prompt, createdAt: Date.now(), recurring }
|
|
417
|
+
|
|
418
|
+
if (!durable) {
|
|
419
|
+
addSessionCronTask({ ...task, agentId })
|
|
420
|
+
return id
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const tasks = await readCronTasks()
|
|
424
|
+
tasks.push(task)
|
|
425
|
+
await writeCronTasks(tasks)
|
|
426
|
+
return id
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 9. 第五层:调度器
|
|
433
|
+
|
|
434
|
+
核心文件:`src/utils/cronScheduler.ts`
|
|
435
|
+
|
|
436
|
+
这是非 React 的核心调度器,REPL 和 headless 模式都能复用。
|
|
437
|
+
|
|
438
|
+
### 9.1 生命周期
|
|
439
|
+
|
|
440
|
+
源码注释给出的生命周期:
|
|
441
|
+
|
|
442
|
+
```text
|
|
443
|
+
poll getScheduledTasksEnabled() until true
|
|
444
|
+
↓
|
|
445
|
+
load tasks
|
|
446
|
+
↓
|
|
447
|
+
watch .claude/scheduled_tasks.json
|
|
448
|
+
↓
|
|
449
|
+
start 1s check timer
|
|
450
|
+
↓
|
|
451
|
+
on fire, call onFire(prompt)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### 9.2 start
|
|
455
|
+
|
|
456
|
+
启动时:
|
|
457
|
+
|
|
458
|
+
1. 如果传了 `dir`,说明是 daemon/headless 路径,直接 enable
|
|
459
|
+
2. 如果没有传 `dir`,检查 bootstrap flag
|
|
460
|
+
3. 如果 `assistantMode` 或磁盘上已有任务,自动打开 flag
|
|
461
|
+
4. 否则每秒轮询 `getScheduledTasksEnabled()`
|
|
462
|
+
|
|
463
|
+
### 9.3 enable
|
|
464
|
+
|
|
465
|
+
`enable()` 做四件事:
|
|
466
|
+
|
|
467
|
+
1. 动态 import `chokidar`
|
|
468
|
+
2. 获取 scheduler lock
|
|
469
|
+
3. `load(true)` 读取 durable tasks
|
|
470
|
+
4. watch `.claude/scheduled_tasks.json`
|
|
471
|
+
5. `setInterval(check, 1000)`
|
|
472
|
+
|
|
473
|
+
为什么需要 lock:
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
同一个项目目录可能开两个 Claude
|
|
477
|
+
│
|
|
478
|
+
├─ 如果没有 lock,两个进程都会读同一个 scheduled_tasks.json
|
|
479
|
+
└─ 同一个任务会被执行两次
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
所以 durable/file-backed tasks 只有 lock owner 会触发。session-only tasks 是进程内存,其他 session 看不到,不需要 lock。
|
|
483
|
+
|
|
484
|
+
### 9.4 check
|
|
485
|
+
|
|
486
|
+
`check()` 是调度器核心。
|
|
487
|
+
|
|
488
|
+
伪代码:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
function check() {
|
|
492
|
+
if (isKilled?.()) return
|
|
493
|
+
if (isLoading() && !assistantMode) return
|
|
494
|
+
|
|
495
|
+
const now = Date.now()
|
|
496
|
+
const jitterCfg = getJitterConfig?.() ?? DEFAULT_CRON_JITTER_CONFIG
|
|
497
|
+
|
|
498
|
+
if (isOwner) {
|
|
499
|
+
for (const task of fileTasks) process(task, false)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (dir === undefined) {
|
|
503
|
+
for (const task of sessionTasks) process(task, true)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### 9.5 process(task, isSession)
|
|
509
|
+
|
|
510
|
+
每个任务的处理逻辑:
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
if (filter && !filter(task)) return
|
|
514
|
+
if (inFlight.has(task.id)) return
|
|
515
|
+
|
|
516
|
+
let next = nextFireAt.get(task.id)
|
|
517
|
+
|
|
518
|
+
if (next === undefined) {
|
|
519
|
+
if (task.recurring) {
|
|
520
|
+
next = jitteredNextCronRunMs(
|
|
521
|
+
task.cron,
|
|
522
|
+
task.lastFiredAt ?? task.createdAt,
|
|
523
|
+
task.id,
|
|
524
|
+
jitterCfg,
|
|
525
|
+
)
|
|
526
|
+
} else {
|
|
527
|
+
next = oneShotJitteredNextCronRunMs(
|
|
528
|
+
task.cron,
|
|
529
|
+
task.createdAt,
|
|
530
|
+
task.id,
|
|
531
|
+
jitterCfg,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
nextFireAt.set(task.id, next)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (Date.now() < next) return
|
|
538
|
+
|
|
539
|
+
fire(task)
|
|
540
|
+
|
|
541
|
+
if (task.recurring && !aged) {
|
|
542
|
+
nextFireAt.set(task.id, jitteredNextCronRunMs(task.cron, now, task.id))
|
|
543
|
+
if (!isSession) markCronTasksFired([task.id], now)
|
|
544
|
+
} else {
|
|
545
|
+
remove task
|
|
546
|
+
}
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### 9.6 recurring 过期
|
|
550
|
+
|
|
551
|
+
默认配置:
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
也就是 7 天。
|
|
558
|
+
|
|
559
|
+
过期 recurring task 会再执行最后一次,然后删除。
|
|
560
|
+
|
|
561
|
+
### 9.7 jitter
|
|
562
|
+
|
|
563
|
+
jitter 的目的不是功能正确性,而是避免大量用户都在整点触发任务造成流量尖峰。
|
|
564
|
+
|
|
565
|
+
两类 jitter:
|
|
566
|
+
|
|
567
|
+
1. recurring:在下一次 cron 时间后加一点确定性延迟
|
|
568
|
+
2. one-shot:如果落在 `:00` 或 `:30`,可以提前一点触发
|
|
569
|
+
|
|
570
|
+
确定性来源:
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
同一个任务 id 每次计算 jitter 都稳定。
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## 10. 第六层:REPL 接入
|
|
581
|
+
|
|
582
|
+
核心文件:`src/hooks/useScheduledTasks.ts`
|
|
583
|
+
|
|
584
|
+
这个 hook 负责把 scheduler 接到 REPL。
|
|
585
|
+
|
|
586
|
+
### 10.1 创建 scheduler
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
const scheduler = createCronScheduler({
|
|
590
|
+
onFire: enqueueForLead,
|
|
591
|
+
onFireTask: task => { ... },
|
|
592
|
+
isLoading: () => isLoadingRef.current,
|
|
593
|
+
assistantMode,
|
|
594
|
+
getJitterConfig: getCronJitterConfig,
|
|
595
|
+
isKilled: () => !isKairosCronEnabled(),
|
|
596
|
+
})
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### 10.2 enqueueForLead
|
|
600
|
+
|
|
601
|
+
普通任务触发后,不是直接执行,而是入队:
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
enqueuePendingNotification({
|
|
605
|
+
value: prompt,
|
|
606
|
+
mode: 'prompt',
|
|
607
|
+
priority: 'later',
|
|
608
|
+
isMeta: true,
|
|
609
|
+
workload: WORKLOAD_CRON,
|
|
610
|
+
})
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
这让 scheduled task 和普通用户输入走同一套后续处理流程。
|
|
614
|
+
|
|
615
|
+
### 10.3 teammate 路由
|
|
616
|
+
|
|
617
|
+
如果 task 有 `agentId`:
|
|
618
|
+
|
|
619
|
+
1. 查找对应 teammate task
|
|
620
|
+
2. 如果还活着,调用 `injectUserMessageToTeammate`
|
|
621
|
+
3. 如果 teammate 已结束,删除这个 cron,避免无限触发
|
|
622
|
+
|
|
623
|
+
### 10.4 普通主线程任务
|
|
624
|
+
|
|
625
|
+
没有 `agentId` 时:
|
|
626
|
+
|
|
627
|
+
1. 往消息列表追加 “Running scheduled task”
|
|
628
|
+
2. 调用 `enqueueForLead(task.prompt)`
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## 11. 从零实现步骤
|
|
633
|
+
|
|
634
|
+
本节假设你要在一个类似 Claude Code 的项目里复现 `/loop`。
|
|
635
|
+
|
|
636
|
+
### 步骤 1:定义 CronTask 类型
|
|
637
|
+
|
|
638
|
+
新建或扩展 `cronTasks.ts`:
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
export type CronTask = {
|
|
642
|
+
id: string
|
|
643
|
+
cron: string
|
|
644
|
+
prompt: string
|
|
645
|
+
createdAt: number
|
|
646
|
+
lastFiredAt?: number
|
|
647
|
+
recurring?: boolean
|
|
648
|
+
permanent?: boolean
|
|
649
|
+
durable?: boolean
|
|
650
|
+
agentId?: string
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### 步骤 2:实现 durable 文件路径
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
const CRON_FILE_REL = join('.claude', 'scheduled_tasks.json')
|
|
658
|
+
|
|
659
|
+
export function getCronFilePath(root: string): string {
|
|
660
|
+
return join(root, CRON_FILE_REL)
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### 步骤 3:实现 readCronTasks
|
|
665
|
+
|
|
666
|
+
要求:
|
|
667
|
+
|
|
668
|
+
1. 文件不存在返回 `[]`
|
|
669
|
+
2. JSON malformed 返回 `[]`
|
|
670
|
+
3. `tasks` 不是数组返回 `[]`
|
|
671
|
+
4. 单个 task 缺字段就跳过
|
|
672
|
+
5. cron 无效就跳过
|
|
673
|
+
|
|
674
|
+
不要因为一个坏 task 让整个 scheduler 崩溃。
|
|
675
|
+
|
|
676
|
+
### 步骤 4:实现 writeCronTasks
|
|
677
|
+
|
|
678
|
+
要求:
|
|
679
|
+
|
|
680
|
+
1. 自动创建 `.claude/`
|
|
681
|
+
2. 写入 `{ tasks: [...] }`
|
|
682
|
+
3. 移除 runtime-only 字段 `durable`
|
|
683
|
+
4. 最后加换行
|
|
684
|
+
|
|
685
|
+
### 步骤 5:实现 session task store
|
|
686
|
+
|
|
687
|
+
如果项目已有全局 state,就放进去。否则可以先做模块级变量:
|
|
688
|
+
|
|
689
|
+
```typescript
|
|
690
|
+
const sessionCronTasks: CronTask[] = []
|
|
691
|
+
|
|
692
|
+
export function addSessionCronTask(task: CronTask) {
|
|
693
|
+
sessionCronTasks.push(task)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export function getSessionCronTasks() {
|
|
697
|
+
return [...sessionCronTasks]
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function removeSessionCronTasks(ids: string[]) {
|
|
701
|
+
...
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### 步骤 6:实现 addCronTask
|
|
706
|
+
|
|
707
|
+
逻辑:
|
|
708
|
+
|
|
709
|
+
1. 生成短 id
|
|
710
|
+
2. 组装 task
|
|
711
|
+
3. durable false 写 session store
|
|
712
|
+
4. durable true 读文件、push、写回文件
|
|
713
|
+
5. 返回 id
|
|
714
|
+
|
|
715
|
+
### 步骤 7:实现 cron parser
|
|
716
|
+
|
|
717
|
+
最小版本只需要支持:
|
|
718
|
+
|
|
719
|
+
1. `*/N * * * *`
|
|
720
|
+
2. `0 */N * * *`
|
|
721
|
+
3. `0 0 */N * *`
|
|
722
|
+
|
|
723
|
+
完整版本要支持标准 5-field cron:
|
|
724
|
+
|
|
725
|
+
```text
|
|
726
|
+
minute hour day-of-month month day-of-week
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
必须实现:
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
parseCronExpression(cron): ParsedCron | null
|
|
733
|
+
computeNextCronRun(parsed, fromDate): Date | null
|
|
734
|
+
nextCronRunMs(cron, fromMs): number | null
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### 步骤 8:实现 CronCreateTool
|
|
738
|
+
|
|
739
|
+
工具输入:
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
{
|
|
743
|
+
cron: string
|
|
744
|
+
prompt: string
|
|
745
|
+
recurring?: boolean
|
|
746
|
+
durable?: boolean
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
`validateInput`:
|
|
751
|
+
|
|
752
|
+
1. cron 格式有效
|
|
753
|
+
2. 能找到下一次 fire 时间
|
|
754
|
+
3. job 数量小于 50
|
|
755
|
+
|
|
756
|
+
`call`:
|
|
757
|
+
|
|
758
|
+
1. 调 `addCronTask`
|
|
759
|
+
2. 设置 scheduler enabled flag
|
|
760
|
+
3. 返回 id 和 human schedule
|
|
761
|
+
|
|
762
|
+
### 步骤 9:实现 CronDeleteTool
|
|
763
|
+
|
|
764
|
+
输入:
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
{ id: string }
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
行为:
|
|
771
|
+
|
|
772
|
+
1. 从 session store 删除
|
|
773
|
+
2. 从 durable 文件删除
|
|
774
|
+
3. 返回是否删除成功
|
|
775
|
+
|
|
776
|
+
### 步骤 10:实现 CronListTool
|
|
777
|
+
|
|
778
|
+
行为:
|
|
779
|
+
|
|
780
|
+
1. 读取 durable tasks
|
|
781
|
+
2. 合并 session tasks
|
|
782
|
+
3. 返回 id、cron、prompt、recurring、durable、next fire time
|
|
783
|
+
|
|
784
|
+
### 步骤 11:实现 createCronScheduler
|
|
785
|
+
|
|
786
|
+
输入 options:
|
|
787
|
+
|
|
788
|
+
```typescript
|
|
789
|
+
type CronSchedulerOptions = {
|
|
790
|
+
onFire: (prompt: string) => void
|
|
791
|
+
onFireTask?: (task: CronTask) => void
|
|
792
|
+
isLoading: () => boolean
|
|
793
|
+
assistantMode?: boolean
|
|
794
|
+
dir?: string
|
|
795
|
+
isKilled?: () => boolean
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
返回:
|
|
800
|
+
|
|
801
|
+
```typescript
|
|
802
|
+
{
|
|
803
|
+
start(): void
|
|
804
|
+
stop(): void
|
|
805
|
+
getNextFireTime(): number | null
|
|
806
|
+
}
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### 步骤 12:scheduler.start
|
|
810
|
+
|
|
811
|
+
实现:
|
|
812
|
+
|
|
813
|
+
1. 如果当前没有 enabled,轮询等待
|
|
814
|
+
2. enabled 后读取 durable tasks
|
|
815
|
+
3. watch 文件变化
|
|
816
|
+
4. setInterval 每秒调用 check
|
|
817
|
+
|
|
818
|
+
最小版本可以不做文件 watch,只每秒同时读取 session 和文件。
|
|
819
|
+
|
|
820
|
+
### 步骤 13:scheduler.check
|
|
821
|
+
|
|
822
|
+
实现:
|
|
823
|
+
|
|
824
|
+
1. killed 则 return
|
|
825
|
+
2. loading 且非 assistantMode 则 return
|
|
826
|
+
3. 遍历 file tasks
|
|
827
|
+
4. 遍历 session tasks
|
|
828
|
+
5. 到点则 fire
|
|
829
|
+
6. recurring 重新计算下一次
|
|
830
|
+
7. one-shot 删除
|
|
831
|
+
|
|
832
|
+
### 步骤 14:实现 `/loop` skill
|
|
833
|
+
|
|
834
|
+
创建 `loop.ts`:
|
|
835
|
+
|
|
836
|
+
```typescript
|
|
837
|
+
const DEFAULT_INTERVAL = '10m'
|
|
838
|
+
|
|
839
|
+
function buildPrompt(args: string): string {
|
|
840
|
+
return `
|
|
841
|
+
Parse the input into [interval] <prompt>.
|
|
842
|
+
Call CronCreate with cron, prompt, recurring: true.
|
|
843
|
+
Then execute the prompt immediately.
|
|
844
|
+
Input:
|
|
845
|
+
${args}
|
|
846
|
+
`
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export function registerLoopSkill() {
|
|
850
|
+
registerBundledSkill({
|
|
851
|
+
name: 'loop',
|
|
852
|
+
description: 'Run a prompt or slash command on a recurring interval',
|
|
853
|
+
argumentHint: '[interval] <prompt>',
|
|
854
|
+
userInvocable: true,
|
|
855
|
+
getPromptForCommand(args) {
|
|
856
|
+
const trimmed = args.trim()
|
|
857
|
+
if (!trimmed) return [{ type: 'text', text: USAGE_MESSAGE }]
|
|
858
|
+
return [{ type: 'text', text: buildPrompt(trimmed) }]
|
|
859
|
+
},
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### 步骤 15:注册 `/loop`
|
|
865
|
+
|
|
866
|
+
在 bundled skills 初始化入口:
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
registerLoopSkill()
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
### 步骤 16:注册 Cron tools
|
|
873
|
+
|
|
874
|
+
在工具注册中心加入:
|
|
875
|
+
|
|
876
|
+
```typescript
|
|
877
|
+
CronCreateTool
|
|
878
|
+
CronDeleteTool
|
|
879
|
+
CronListTool
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
否则模型看到 `/loop` prompt 后无法调用 `CronCreate`。
|
|
883
|
+
|
|
884
|
+
### 步骤 17:接入 REPL
|
|
885
|
+
|
|
886
|
+
在 REPL 顶层 hook 或启动逻辑中:
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
const scheduler = createCronScheduler({
|
|
890
|
+
onFire: prompt => enqueuePendingNotification({
|
|
891
|
+
value: prompt,
|
|
892
|
+
mode: 'prompt',
|
|
893
|
+
priority: 'later',
|
|
894
|
+
isMeta: true,
|
|
895
|
+
}),
|
|
896
|
+
isLoading: () => currentIsLoading,
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
scheduler.start()
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
卸载时:
|
|
903
|
+
|
|
904
|
+
```typescript
|
|
905
|
+
scheduler.stop()
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## 12. 最小可用版本
|
|
911
|
+
|
|
912
|
+
如果只想先做出能跑的 `/loop`,可以砍掉这些复杂性:
|
|
913
|
+
|
|
914
|
+
1. 不支持 durable,只做 session-only
|
|
915
|
+
2. 不支持 teammate
|
|
916
|
+
3. 不支持 missed task
|
|
917
|
+
4. 不支持 file watcher
|
|
918
|
+
5. 不支持 scheduler lock
|
|
919
|
+
6. 不支持 jitter
|
|
920
|
+
7. 不支持 GrowthBook feature gate
|
|
921
|
+
8. 不支持 one-shot
|
|
922
|
+
|
|
923
|
+
最小数据流:
|
|
924
|
+
|
|
925
|
+
```
|
|
926
|
+
/loop 5m foo
|
|
927
|
+
│
|
|
928
|
+
▼
|
|
929
|
+
模型调用 CronCreate({ cron: "*/5 * * * *", prompt: "foo", recurring: true })
|
|
930
|
+
│
|
|
931
|
+
▼
|
|
932
|
+
addSessionCronTask
|
|
933
|
+
│
|
|
934
|
+
▼
|
|
935
|
+
setInterval 每秒检查
|
|
936
|
+
│
|
|
937
|
+
▼
|
|
938
|
+
到点 enqueue prompt
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
最小版本必须保留:
|
|
942
|
+
|
|
943
|
+
1. `/loop` skill
|
|
944
|
+
2. `CronCreate`
|
|
945
|
+
3. session task store
|
|
946
|
+
4. scheduler
|
|
947
|
+
5. REPL enqueue
|
|
948
|
+
|
|
949
|
+
---
|
|
950
|
+
|
|
951
|
+
## 13. 完整版本增强项
|
|
952
|
+
|
|
953
|
+
最小版本跑通后,再按顺序加:
|
|
954
|
+
|
|
955
|
+
### 13.1 CronDelete
|
|
956
|
+
|
|
957
|
+
用户必须能取消 recurring 任务,否则 loop 会一直跑。
|
|
958
|
+
|
|
959
|
+
### 13.2 CronList
|
|
960
|
+
|
|
961
|
+
便于用户查看当前有哪些任务,也便于测试。
|
|
962
|
+
|
|
963
|
+
### 13.3 durable
|
|
964
|
+
|
|
965
|
+
加入 `.claude/scheduled_tasks.json`,让任务重启后恢复。
|
|
966
|
+
|
|
967
|
+
### 13.4 scheduler lock
|
|
968
|
+
|
|
969
|
+
有 durable 后必须加 lock,否则同目录多个进程会重复触发。
|
|
970
|
+
|
|
971
|
+
### 13.5 file watcher
|
|
972
|
+
|
|
973
|
+
监听 `.claude/scheduled_tasks.json` 变化,支持其他进程增删任务。
|
|
974
|
+
|
|
975
|
+
### 13.6 missed one-shot
|
|
976
|
+
|
|
977
|
+
Claude 关闭期间错过的一次性任务,启动后不要直接执行,应先问用户是否补跑。
|
|
978
|
+
|
|
979
|
+
### 13.7 jitter
|
|
980
|
+
|
|
981
|
+
防止整点和半点流量尖峰。
|
|
982
|
+
|
|
983
|
+
### 13.8 recurring 自动过期
|
|
984
|
+
|
|
985
|
+
默认 7 天,防止长期无人管理的 loop 无限运行。
|
|
986
|
+
|
|
987
|
+
---
|
|
988
|
+
|
|
989
|
+
## 14. 测试清单
|
|
990
|
+
|
|
991
|
+
### 14.1 `/loop` prompt 生成
|
|
992
|
+
|
|
993
|
+
输入:
|
|
994
|
+
|
|
995
|
+
```text
|
|
996
|
+
/loop
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
期望:
|
|
1000
|
+
|
|
1001
|
+
1. 返回 usage
|
|
1002
|
+
2. 不调用 `CronCreate`
|
|
1003
|
+
|
|
1004
|
+
输入:
|
|
1005
|
+
|
|
1006
|
+
```text
|
|
1007
|
+
/loop 5m check deploy
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
期望模型调用:
|
|
1011
|
+
|
|
1012
|
+
```json
|
|
1013
|
+
{
|
|
1014
|
+
"cron": "*/5 * * * *",
|
|
1015
|
+
"prompt": "check deploy",
|
|
1016
|
+
"recurring": true
|
|
1017
|
+
}
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
输入:
|
|
1021
|
+
|
|
1022
|
+
```text
|
|
1023
|
+
/loop check deploy every 20m
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
期望模型调用:
|
|
1027
|
+
|
|
1028
|
+
```json
|
|
1029
|
+
{
|
|
1030
|
+
"cron": "*/20 * * * *",
|
|
1031
|
+
"prompt": "check deploy",
|
|
1032
|
+
"recurring": true
|
|
1033
|
+
}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
输入:
|
|
1037
|
+
|
|
1038
|
+
```text
|
|
1039
|
+
/loop check every PR
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
期望:
|
|
1043
|
+
|
|
1044
|
+
```json
|
|
1045
|
+
{
|
|
1046
|
+
"cron": "*/10 * * * *",
|
|
1047
|
+
"prompt": "check every PR",
|
|
1048
|
+
"recurring": true
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### 14.2 CronCreate validation
|
|
1053
|
+
|
|
1054
|
+
| 输入 | 期望 |
|
|
1055
|
+
|------|------|
|
|
1056
|
+
| `*/5 * * * *` | 通过 |
|
|
1057
|
+
| `bad cron` | 拒绝 |
|
|
1058
|
+
| 无未来运行时间 | 拒绝 |
|
|
1059
|
+
| 已有 50 个任务 | 拒绝 |
|
|
1060
|
+
|
|
1061
|
+
### 14.3 session-only 创建
|
|
1062
|
+
|
|
1063
|
+
调用:
|
|
1064
|
+
|
|
1065
|
+
```typescript
|
|
1066
|
+
addCronTask('*/5 * * * *', 'hello', true, false)
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
期望:
|
|
1070
|
+
|
|
1071
|
+
1. 返回 8 位 id
|
|
1072
|
+
2. session store 增加 1 个任务
|
|
1073
|
+
3. `.claude/scheduled_tasks.json` 不变
|
|
1074
|
+
|
|
1075
|
+
### 14.4 durable 创建
|
|
1076
|
+
|
|
1077
|
+
调用:
|
|
1078
|
+
|
|
1079
|
+
```typescript
|
|
1080
|
+
addCronTask('*/5 * * * *', 'hello', true, true)
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
期望:
|
|
1084
|
+
|
|
1085
|
+
1. `.claude/scheduled_tasks.json` 被创建
|
|
1086
|
+
2. JSON 里有该任务
|
|
1087
|
+
3. `durable` 字段不写入磁盘
|
|
1088
|
+
|
|
1089
|
+
### 14.5 scheduler fire
|
|
1090
|
+
|
|
1091
|
+
创建一个下一分钟触发的任务。
|
|
1092
|
+
|
|
1093
|
+
期望:
|
|
1094
|
+
|
|
1095
|
+
1. 到点前不 enqueue
|
|
1096
|
+
2. 到点后 enqueue 一次
|
|
1097
|
+
3. recurring 任务更新下一次 fire time
|
|
1098
|
+
4. one-shot 任务触发后删除
|
|
1099
|
+
|
|
1100
|
+
### 14.6 isLoading gate
|
|
1101
|
+
|
|
1102
|
+
设置:
|
|
1103
|
+
|
|
1104
|
+
```typescript
|
|
1105
|
+
isLoading: () => true
|
|
1106
|
+
assistantMode: false
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
期望:
|
|
1110
|
+
|
|
1111
|
+
1. 到点不触发
|
|
1112
|
+
2. `isLoading` 变 false 后触发
|
|
1113
|
+
|
|
1114
|
+
设置:
|
|
1115
|
+
|
|
1116
|
+
```typescript
|
|
1117
|
+
isLoading: () => true
|
|
1118
|
+
assistantMode: true
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
期望:
|
|
1122
|
+
|
|
1123
|
+
1. 到点仍然可以 enqueue
|
|
1124
|
+
|
|
1125
|
+
### 14.7 CronDelete
|
|
1126
|
+
|
|
1127
|
+
创建任务后删除。
|
|
1128
|
+
|
|
1129
|
+
期望:
|
|
1130
|
+
|
|
1131
|
+
1. session store 中删除
|
|
1132
|
+
2. durable 文件中删除
|
|
1133
|
+
3. scheduler 不再触发
|
|
1134
|
+
|
|
1135
|
+
### 14.8 多进程 lock
|
|
1136
|
+
|
|
1137
|
+
启动两个 scheduler,指向同一个 `.claude/scheduled_tasks.json`。
|
|
1138
|
+
|
|
1139
|
+
期望:
|
|
1140
|
+
|
|
1141
|
+
1. 只有 lock owner 触发 durable task
|
|
1142
|
+
2. 非 owner 不触发 file-backed task
|
|
1143
|
+
3. owner 停止后,另一个 scheduler 能接管
|
|
1144
|
+
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
## 15. 常见错误
|
|
1148
|
+
|
|
1149
|
+
### 错误 1:把 `/loop` 写成 local command
|
|
1150
|
+
|
|
1151
|
+
不要把 `/loop` 写成直接创建任务的 local command。现有设计是 prompt skill,让模型负责解析自然语言并调用 `CronCreate`。
|
|
1152
|
+
|
|
1153
|
+
如果写成 local command,会失去:
|
|
1154
|
+
|
|
1155
|
+
1. 自然语言解析能力
|
|
1156
|
+
2. slash command prompt 透传能力
|
|
1157
|
+
3. 模型立即执行一次 prompt 的能力
|
|
1158
|
+
|
|
1159
|
+
### 错误 2:忘记注册 CronCreate 工具
|
|
1160
|
+
|
|
1161
|
+
`/loop` prompt 会要求模型调用 `CronCreate`。如果工具没注册,模型只能输出文字,不能真正创建任务。
|
|
1162
|
+
|
|
1163
|
+
### 错误 3:创建任务后没打开 scheduler enabled flag
|
|
1164
|
+
|
|
1165
|
+
`CronCreate.call()` 必须调用:
|
|
1166
|
+
|
|
1167
|
+
```typescript
|
|
1168
|
+
setScheduledTasksEnabled(true)
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
否则 scheduler 可能一直在等待,不会开始检查任务。
|
|
1172
|
+
|
|
1173
|
+
### 错误 4:durable 任务没有 lock
|
|
1174
|
+
|
|
1175
|
+
多个 Claude session 共享同一个项目目录时,如果没有 lock,同一个 durable task 会被触发多次。
|
|
1176
|
+
|
|
1177
|
+
### 错误 5:recurring 任务从旧时间补跑
|
|
1178
|
+
|
|
1179
|
+
recurring 触发后下一次应该从 `now` 重新计算,而不是从上一次理论 fire time 继续追赶。否则应用卡顿或休眠后可能连续快速执行多次。
|
|
1180
|
+
|
|
1181
|
+
正确逻辑:
|
|
1182
|
+
|
|
1183
|
+
```typescript
|
|
1184
|
+
newNext = jitteredNextCronRunMs(task.cron, now, task.id)
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
### 错误 6:把 session-only 任务写进文件
|
|
1188
|
+
|
|
1189
|
+
默认 `durable=false`。用户没有明确要求跨 session 保留时,不要写 `.claude/scheduled_tasks.json`。
|
|
1190
|
+
|
|
1191
|
+
### 错误 7:空 `/loop` 也创建任务
|
|
1192
|
+
|
|
1193
|
+
空输入必须显示 usage,并停止。
|
|
1194
|
+
|
|
1195
|
+
### 错误 8:没有立即执行一次
|
|
1196
|
+
|
|
1197
|
+
现有 `/loop` prompt 明确要求创建后立刻执行 parsed prompt。不要等第一次 cron fire。
|
|
1198
|
+
|
|
1199
|
+
---
|
|
1200
|
+
|
|
1201
|
+
## 16. 关键结论
|
|
1202
|
+
|
|
1203
|
+
`/loop` 的实现可以拆成一句话:
|
|
1204
|
+
|
|
1205
|
+
> `/loop` 是一个 prompt skill,它把“自然语言定时请求”翻译成对 `CronCreate` 工具的调用;真正的任务存储、调度、触发和取消都由 Cron 工具链负责。
|
|
1206
|
+
|
|
1207
|
+
如果要复现,按这个顺序实现最稳:
|
|
1208
|
+
|
|
1209
|
+
1. `CronTask` 类型
|
|
1210
|
+
2. session-only task store
|
|
1211
|
+
3. `CronCreate`
|
|
1212
|
+
4. 简单 scheduler
|
|
1213
|
+
5. REPL enqueue
|
|
1214
|
+
6. `/loop` bundled skill
|
|
1215
|
+
7. `CronDelete`
|
|
1216
|
+
8. `CronList`
|
|
1217
|
+
9. durable 文件存储
|
|
1218
|
+
10. scheduler lock
|
|
1219
|
+
11. file watcher
|
|
1220
|
+
12. jitter 和 auto-expiry
|
|
1221
|
+
|
|
1222
|
+
这样即使模型能力较弱,也可以每一步只完成一个小模块,并用测试清单逐项验证。
|