@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.
Files changed (186) hide show
  1. package/README.md +267 -267
  2. package/dist/build-meta.json +3 -3
  3. package/dist/core/export-html/AGENT.md +11 -11
  4. package/dist/core/export-html/template.css +971 -971
  5. package/dist/core/export-html/template.html +54 -54
  6. package/dist/core/model/custom-providers.js +1 -1
  7. package/dist/core/model-registry.js +5 -5
  8. package/dist/extensions/builtin/AGENT.md +115 -115
  9. package/dist/extensions/builtin/browser/AGENT.md +17 -17
  10. package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
  11. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
  12. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
  13. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
  14. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
  15. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
  16. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
  17. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
  18. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
  19. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
  20. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
  21. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
  22. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
  23. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
  24. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
  25. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
  26. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
  27. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
  28. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
  29. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
  30. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
  31. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
  32. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
  33. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
  34. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
  35. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
  36. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
  37. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
  38. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
  39. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
  40. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
  41. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
  42. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
  43. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
  44. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
  45. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
  46. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
  47. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
  48. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
  49. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
  50. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
  51. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
  52. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
  53. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
  54. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
  55. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
  56. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
  57. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
  58. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
  59. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
  60. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
  61. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
  62. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
  63. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
  64. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
  65. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
  66. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
  67. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
  68. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
  69. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
  70. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
  71. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
  72. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
  73. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
  74. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
  75. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
  76. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
  77. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
  78. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
  79. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
  80. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
  81. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
  82. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
  83. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
  84. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
  85. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
  86. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
  87. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
  88. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
  89. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
  90. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
  91. package/dist/extensions/builtin/browser/browser.md +73 -73
  92. package/dist/extensions/builtin/browser/install.md +142 -142
  93. package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
  94. package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
  95. package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
  96. package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
  97. package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
  98. package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
  99. package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
  100. package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
  101. package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
  102. package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
  103. package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
  104. package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
  105. package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
  106. package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
  107. package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
  108. package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
  109. package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
  110. package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
  111. package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
  112. package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
  113. package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
  114. package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
  115. package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
  116. package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
  117. package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
  118. package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
  119. package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
  120. package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
  121. package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
  122. package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
  123. package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
  124. package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
  125. package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
  126. package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
  127. package/dist/extensions/builtin/goal/README.md +67 -67
  128. package/dist/extensions/builtin/grub/README.md +112 -112
  129. package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
  130. package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
  131. package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
  132. package/dist/extensions/builtin/link-world/linkworld.md +313 -313
  133. package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
  134. package/dist/extensions/builtin/loop/README.md +92 -92
  135. package/dist/extensions/builtin/mcp/figma-design.md +68 -68
  136. package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
  137. package/dist/extensions/builtin/recap/AGENT.md +15 -15
  138. package/dist/extensions/builtin/sal/README.md +72 -72
  139. package/dist/extensions/builtin/security-audit/README.md +289 -289
  140. package/dist/extensions/builtin/team/AGENT.md +112 -112
  141. package/dist/extensions/builtin/team/TESTING.md +299 -299
  142. package/dist/extensions/builtin/token-save/README.md +56 -56
  143. package/dist/extensions/optional/AGENT.md +10 -10
  144. package/dist/modes/interactive/controllers/input-submit-controller.js +2 -2
  145. package/dist/modes/interactive/controllers/stream-render-controller.js +2 -2
  146. package/dist/modes/interactive/interactive-mode.js +19 -19
  147. package/dist/modes/interactive/theme/dark.json +85 -85
  148. package/dist/modes/interactive/theme/light.json +84 -84
  149. package/dist/modes/interactive/theme/theme-schema.json +335 -335
  150. package/dist/modes/interactive/theme/warm.json +81 -81
  151. package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
  152. package/dist/node_modules/@pencil-agent/ai/dist/models.generated.js +1 -1
  153. 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
  154. package/docs/SDK-TESTING.md +364 -0
  155. package/docs/codex-goal-command-impl.md +1055 -1055
  156. package/docs/codex-goal-vs-grub.md +500 -500
  157. package/docs/custom-provider.md +27 -27
  158. package/docs/extensions.md +27 -27
  159. package/docs/keybindings.md +27 -27
  160. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
  161. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
  162. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
  163. 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
  164. 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
  165. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
  166. package/docs/loop-usage-examples.md +214 -214
  167. package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +593 -0
  168. package/docs/models.md +27 -27
  169. package/docs/packages.md +27 -27
  170. package/docs/pi-design-philosophy.md +457 -457
  171. package/docs/planmode.md +1987 -1987
  172. package/docs/prompt-templates.md +27 -27
  173. package/docs/providers.md +27 -27
  174. package/docs/sdk.md +27 -27
  175. package/docs/skills.md +27 -27
  176. package/docs/startup-performance-optimization.md +301 -0
  177. package/docs/themes.md +27 -27
  178. package/docs/tui.md +27 -27
  179. package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +47 -0
  180. package/package.json +190 -190
  181. package/docs/cc-agent-design.md +0 -1297
  182. package/docs/cc-tui-design.md +0 -1333
  183. package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +0 -170
  184. package/docs/scan-report.md +0 -3820
  185. package/docs//345/257/271/346/240/207Claude-Code.md +0 -1775
  186. 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
+ 这样即使模型能力较弱,也可以每一步只完成一个小模块,并用测试清单逐项验证。