pi-cc-loop 0.3.0 → 0.4.0
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 +96 -34
- package/cron.ts +0 -10
- package/index.ts +77 -33
- package/package.json +1 -1
- package/prompts.ts +17 -17
- package/renderers.ts +3 -1
- package/schedule.ts +16 -2
package/README.md
CHANGED
|
@@ -1,59 +1,102 @@
|
|
|
1
1
|
# pi-cc-loop
|
|
2
2
|
|
|
3
|
-
> 在 Pi 里复刻 Claude Code 的 `/loop
|
|
3
|
+
> 在 Pi 里复刻 Claude Code 的 `/loop`:固定间隔、自选间隔、兜底心跳和底部状态摘要。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
# 本地开发:把仓库软链接到 Pi 扩展目录
|
|
9
|
-
ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
10
|
-
|
|
11
|
-
# 发布后
|
|
12
8
|
pi install npm:pi-cc-loop
|
|
13
9
|
```
|
|
14
10
|
|
|
15
|
-
安装后执行 `/reload
|
|
11
|
+
安装后执行 `/reload`。本地开发时把仓库软链接到扩展目录即可:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
15
|
+
```
|
|
16
16
|
|
|
17
17
|
## 工作方式
|
|
18
18
|
|
|
19
19
|
`/loop` 和 CC 一样是一个**技能**,不是用代码直接排任务:你输入的内容会作为一条普通 user 消息进入对话,
|
|
20
20
|
模型自己解析间隔并调用工具完成。所以对话里始终能看到你要求了什么、模型做了什么。
|
|
21
21
|
|
|
22
|
+
给模型的规则(怎么建任务、自定步调怎么续期)以一条隐藏消息附在这一轮后面,不改 system prompt:
|
|
23
|
+
不支持对话中途 system 消息的 provider 会把 system prompt 整体重建,规则一来一去会让整段提示缓存失效。
|
|
24
|
+
固定间隔的每次触发不附加任何东西,信封里一句"它会自己触发下一次,取消用 cron_delete"就够了。
|
|
25
|
+
|
|
22
26
|
| 功能 | 说明 | 入口 |
|
|
23
27
|
| --- | --- | --- |
|
|
24
|
-
| 固定间隔 | 模型用 `cron_create` 建一个 cron(`5m` → `*/5 * * * *`),然后立即执行一次;触发时按"定时任务"
|
|
28
|
+
| 固定间隔 | 模型用 `cron_create` 建一个 cron(`5m` → `*/5 * * * *`),然后立即执行一次;触发时按"定时任务"呈现。不带 `/loop`、直接说"每分钟打印一次hello"也一样 | `/loop 5m <prompt>`、`/loop 每分钟 ...` |
|
|
25
29
|
| 自选间隔 | 运行一次后用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
|
|
26
|
-
| loop.md | 请求本身没有任务时(裸 `/loop` 或只给间隔)由 `.pi/loop.md
|
|
27
|
-
| 停止 / 查看 | 也走对话:模型用 `cron_list` / `cron_delete`(自选间隔用 `schedule_wakeup` + `stop: true
|
|
30
|
+
| loop.md | 请求本身没有任务时(裸 `/loop` 或只给间隔)由 `.pi/loop.md`(或 `~/.pi/loop.md`)提供,每次触发重读;没有时用内置维护 prompt | `/loop`、`/loop 15m` |
|
|
31
|
+
| 停止 / 查看 | 也走对话:模型用 `cron_list` / `cron_delete`(自选间隔用 `schedule_wakeup` + `stop: true`);停掉后再输入同一个 `/loop` 会重新开始 | `/loop stop`、`/loop status` |
|
|
32
|
+
| 回合中输入 | 模型正在回复时输入 `/loop ...`:底部提示"will start when the current turn ends",当前回合结束后作为新的一轮开始(不会混进正在进行的回合) | 自动 |
|
|
28
33
|
| 漏跑合并 | agent 忙时到点只记一次 pending,空闲后发一条 | 自动 |
|
|
29
34
|
| 兜底心跳 | 模型忘记调度时 arm 一次 20 分钟心跳,预算 1 次 | 自动 |
|
|
30
35
|
| Esc 取消 | 打断属于某个自定步调 loop 的回合(它的 tick、它的 Monitor 唤醒的回合)时取消该 loop;无关回合、空闲时不受影响;固定间隔不受影响 | Esc |
|
|
31
36
|
| 错峰 | 周期任务按 id 固定延后最多半个周期(≤30 分钟),间隔本身不变;`:00`/`:30` 的一次性任务最多提前 90 秒 | 自动 |
|
|
32
37
|
| 过期 | 周期任务 7 天后最后执行一次再删除;一次性任务不过期 | 自动 |
|
|
33
|
-
| 次数上限 | `maxFires`,到顶自动删除 |
|
|
34
|
-
| 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json
|
|
38
|
+
| 次数上限 | `maxFires`,到顶自动删除 | `--max N` |
|
|
39
|
+
| 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json`,重启后继续;同一项目只由一个 Pi 会话执行 | `--durable` |
|
|
35
40
|
| 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/loop footer on\|off` |
|
|
36
|
-
| 界面设置 |
|
|
41
|
+
| 界面设置 | 本地命令,不进对话 | `/loop settings ...` |
|
|
37
42
|
|
|
38
43
|
## 命令
|
|
39
44
|
|
|
40
45
|
```text
|
|
41
|
-
/loop
|
|
42
|
-
/loop <prompt>
|
|
43
|
-
/loop [间隔]
|
|
44
|
-
/loop status
|
|
45
|
-
/loop stop [id]
|
|
46
|
+
/loop <间隔> <prompt> 固定间隔重复(模型用 cron_create)
|
|
47
|
+
/loop <prompt> 自选间隔(模型用 schedule_wakeup)
|
|
48
|
+
/loop [间隔] 跑 loop.md,没有就跑内置维护 prompt
|
|
49
|
+
/loop status 列出当前任务
|
|
50
|
+
/loop stop [id] 停止一个或全部
|
|
46
51
|
/loop footer on|off 本地:底部摘要
|
|
47
52
|
/loop settings ... 本地:界面设置
|
|
48
53
|
/loop help 本地:帮助
|
|
49
54
|
```
|
|
50
55
|
|
|
51
|
-
前五个会作为普通 user
|
|
52
|
-
`cron_create`、`cron_list`、`cron_delete`、`schedule_wakeup`。后三个只改界面,不进对话。
|
|
56
|
+
前五个会作为普通 user 消息进入对话(内容就是你输入的那行),模型再按技能说明调用工具;后三个只改界面,不进对话。
|
|
53
57
|
|
|
54
58
|
间隔写法没有约束,模型自己解析:`30s`、`5m`、`2h`、`1d`、`check the deploy every 20m`、
|
|
55
59
|
`每2分钟输出一次`、`每分钟打印一次hi` 都可以。小于一分钟会向上取整到每分钟。
|
|
56
60
|
|
|
61
|
+
可以在请求里加 `--durable`(写入 `.pi/scheduled_tasks.json`,重启后继续)和 `--max N`(触发 N 次后自动删除)。
|
|
62
|
+
|
|
63
|
+
## 工具
|
|
64
|
+
|
|
65
|
+
| 工具 | 界面显示 | 说明 |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `cron_create` | `CronCreate` | 5 段 cron(本地时区)+ prompt;`recurring` 默认 `true`,`false` 为一次性;可选 `durable`、`maxFires` |
|
|
68
|
+
| `cron_delete` | `CronDelete` | 按 id 取消;也能结束自定步调 loop |
|
|
69
|
+
| `cron_list` | `CronList` | 列出任务和实际的下次运行时间(已含错峰) |
|
|
70
|
+
| `schedule_wakeup` | `ScheduleWakeup` | 自定步调:`delaySeconds`(限制在 60–3600)、`reason`(必填,显示在触发块表头)、`prompt`;`stop: true` 结束 |
|
|
71
|
+
|
|
72
|
+
## 渲染
|
|
73
|
+
|
|
74
|
+
定时触发只显示任务本身,给模型看的信封不上屏:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
✻ Running scheduled task · 9:52pm
|
|
78
|
+
print hi
|
|
79
|
+
|
|
80
|
+
✻ loop #3 · 9:53pm · watching CI run
|
|
81
|
+
check the deploy
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
固定间隔按"定时任务"呈现;只有自选间隔叫 loop,表头带上模型给的 `reason`。折叠时正文最多 3 行,
|
|
85
|
+
Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages` 关闭时回到旧的带底色卡片。
|
|
86
|
+
|
|
87
|
+
## 设置
|
|
88
|
+
|
|
89
|
+
两个 pi-cc 扩展共用 `~/.pi/agent/pi-cc.json`,文件不存在时用默认值:
|
|
90
|
+
|
|
91
|
+
```jsonc
|
|
92
|
+
{
|
|
93
|
+
"compactMessages": true, // 触发块和 Monitor 事件用上面的样式;false 为旧的带底色卡片
|
|
94
|
+
"footer": true // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
用 `/loop settings messages on|off`、`/loop settings footer on|off`(或 `/loop footer on|off`)切换,两个扩展同时生效。
|
|
99
|
+
|
|
57
100
|
## 底部摘要
|
|
58
101
|
|
|
59
102
|
`ctx.ui.setStatus("loop", …)` 是 Pi 核心 API,不依赖任何其他扩展:
|
|
@@ -68,42 +111,61 @@ pi install npm:pi-cc-loop
|
|
|
68
111
|
- 倒计时取整到分钟,配合"文本相同就不调用"的去重,最多每分钟重绘一次(Pi 每次 `setStatus` 都会触发重绘)
|
|
69
112
|
- 装了 `pi-cc-extensions`:这一行会作为它的一个 chip 出现在第 2 行,可在它的配置面板里移动或隐藏;它会把 ANSI 去掉,所以这里只用纯文本
|
|
70
113
|
- 没装 `pi-cc-extensions`:Pi 内置 footer 会把它和其他扩展的状态拼成一行,加在用量行下面,只在有任务时出现
|
|
71
|
-
|
|
72
|
-
> 本扩展不写其他插件的配置文件。如果你之前在 `pi-cc-extensions.json` 里因为旧版本加过
|
|
73
|
-
> `cron_create`、`cron_delete`、`cron_list`、`schedule_wakeup` 这几条 `excludeRenderers`,
|
|
74
|
-
> 现在可以删掉:工具行由 Pi 或 pi-cc-extensions 自己渲染。
|
|
114
|
+
- 装了 `pi-cc-monitor`:运行中的 monitor 另有一项 `monitor 2`,和这一行用同一个开关
|
|
75
115
|
|
|
76
116
|
## 环境变量
|
|
77
117
|
|
|
78
118
|
| 变量 | 默认 | 说明 |
|
|
79
119
|
| --- | --- | --- |
|
|
80
|
-
| `PI_LOOP_MAX_AGE_DAYS` | `7` |
|
|
120
|
+
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 周期任务最长存活天数,`0` 不过期 |
|
|
81
121
|
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
82
122
|
| `PI_LOOP_JITTER` | `0.5` | 周期任务最多延后的周期比例;`0` 关闭错峰 |
|
|
83
123
|
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
84
|
-
| `
|
|
124
|
+
| `PI_LOOP_DURABLE_SYNC_MS` | `5000` | 持久化任务文件的同步间隔 |
|
|
125
|
+
| `PI_CC_FOOTER` | — | `0` / `1`,覆盖设置文件里的 `footer` |
|
|
126
|
+
| `PI_CC_COMPACT_MESSAGES` | — | `0` / `1`,覆盖设置文件里的 `compactMessages` |
|
|
127
|
+
| `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` | 设置文件路径 |
|
|
85
128
|
|
|
86
129
|
## 兼容性
|
|
87
130
|
|
|
88
131
|
- Node.js `>=22.19.0`
|
|
89
|
-
- Pi
|
|
90
|
-
-
|
|
132
|
+
- Pi 1.3.0、1.4.0(测试通过)
|
|
133
|
+
- `pi-cc-monitor` 可选:自定步调 loop 需要等外部事件时会用它,loop 结束时连带停止;两者通过 `pi.events` 通信
|
|
134
|
+
- 与 `pi-cc-extensions` 共存。本扩展不写其他插件的配置文件;旧版本在 `pi-cc-extensions.json` 里加过的
|
|
135
|
+
`cron_create`、`cron_delete`、`cron_list`、`schedule_wakeup` 这几条 `excludeRenderers` 可以删掉
|
|
91
136
|
|
|
92
137
|
## 本地开发
|
|
93
138
|
|
|
94
139
|
```bash
|
|
95
140
|
node --test tests/*.test.mjs
|
|
96
141
|
npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
142
|
+
node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免费模型,发版前跑
|
|
97
143
|
```
|
|
98
144
|
|
|
99
|
-
|
|
145
|
+
### 缓存约束
|
|
146
|
+
|
|
147
|
+
提示缓存按前缀匹配(工具 → system prompt → 消息),扩展只能往对话末尾追加:
|
|
148
|
+
|
|
149
|
+
- **不改 system prompt**:每轮需要的规则作为隐藏消息附在这一轮后面(`before_agent_start` 返回 `message`),不写 `promptGuidelines`,不返回 `systemPrompt`。
|
|
150
|
+
- **不动工具集**:工具在加载时一次注册,描述是固定文本;不调用 `setActiveTools`,会话中途不改描述。
|
|
151
|
+
- **不改历史**:不注册 `context` / `context_with_system` / `before_provider_request`。
|
|
152
|
+
|
|
153
|
+
`tests/cache.test.mjs` 守住这三条。`scripts/cache-check.mjs` 用真实 provider 依次走一遍固定间隔 `/loop`、触发、普通对话、`/loop stop`、Monitor 事件、自定步调 `/loop` 和它的触发,逐个请求看缓存读取量:
|
|
154
|
+
读到的明显少于上一个请求发出的内容就报 MISS(退出码 1)。provider 偶尔会自己丢缓存,同一步骤重跑仍然 MISS 才是扩展的问题。
|
|
155
|
+
默认模型 `opencode-go/beam-free` 免费;`--model` 可换,脚本最后会打印本次花费。
|
|
156
|
+
|
|
157
|
+
## 与 Claude Code 的差异
|
|
158
|
+
|
|
159
|
+
行为对齐 Claude Code 2.1.308 的 `/loop`,用 Pi 扩展 API 实现。有意不同的地方:
|
|
100
160
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
-
|
|
161
|
+
- Esc 只取消被打断回合所属的自定步调 loop;CC 按 Esc(包括空闲时、打断无关回合时)会取消所有自定步调 loop。其他情况用 `/loop stop`。
|
|
162
|
+
- 固定间隔的触发里调用 `schedule_wakeup` 会被当作空操作并返回说明,防止同一个任务又被自定步调重复调度;CC 没有这条硬规则。
|
|
163
|
+
- 不带 `/loop` 的请求(比如"每分钟打印一次hello")也会在建好任务后立即做一次。CC 靠模型自己调用 loop 技能做到(`CronCreate` 在 CC 里是延迟加载的工具,模型会先走技能);Pi 没有延迟加载工具,这里改为在 `cron_create` 的结果里加一句提示,只在普通对话里建周期任务时出现。
|
|
164
|
+
- 底部摘要和 `maxFires` 次数上限是额外加的;CC 主会话不显示 loop 状态,也没有次数上限。
|
|
165
|
+
- 自定步调 loop 结束(stop、Esc、`cron_delete`、兜底心跳用完)时,绑定的 Monitor 会被静默停止;CC 靠模型自己调用 `TaskStop`。
|
|
106
166
|
- Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
|
|
167
|
+
- 没有做 CC 的 `noop` 折叠、触发时"不是用户实时输入"的安全提示、间隔边界提示和云端调度。
|
|
168
|
+
- 没有做 CC 针对 5 分钟提示缓存的两处调整(`*/5` 每次提前 15 秒、接近 300 秒的唤醒往回拉)。在 5 分钟缓存的 provider 上(比如 Anthropic 默认),`*/5` 的触发和 300 秒左右的唤醒可能刚好错过缓存。
|
|
107
169
|
|
|
108
170
|
## 许可证
|
|
109
171
|
|
package/cron.ts
CHANGED
|
@@ -154,7 +154,6 @@ export function isUnreachable(expression: string): boolean {
|
|
|
154
154
|
return nextFireAfter(fields, new Date(), 366) === null;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/** The unit words Claude Code's /loop accepts in a trailing "every N <unit>" clause. */
|
|
158
157
|
/**
|
|
159
158
|
* The divisor of `total` closest to `n`.
|
|
160
159
|
*
|
|
@@ -176,15 +175,6 @@ function nearestDivisor(n: number, total: number): number {
|
|
|
176
175
|
return best;
|
|
177
176
|
}
|
|
178
177
|
|
|
179
|
-
/**
|
|
180
|
-
* Parse `每2分钟输出一次` / `输出一次 每隔 2 小时`: an explicit interval written
|
|
181
|
-
* in Chinese. The digit is required on purpose: `每天都有一个测试失败` is a
|
|
182
|
-
* prompt, not a schedule, and Claude Code's own splitting is just as literal.
|
|
183
|
-
*/
|
|
184
|
-
/**
|
|
185
|
-
* Parse a trailing `every 20m` / `every 5 min` / `every 2 hours` clause, with
|
|
186
|
-
* the unit words Claude Code accepts.
|
|
187
|
-
*/
|
|
188
178
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
189
179
|
|
|
190
180
|
|
package/index.ts
CHANGED
|
@@ -44,12 +44,13 @@ import {
|
|
|
44
44
|
CRON_DELETE_SNIPPET,
|
|
45
45
|
CRON_LIST_DESCRIPTION,
|
|
46
46
|
CRON_LIST_SNIPPET,
|
|
47
|
-
cronTickGuidelines,
|
|
48
47
|
LOOP_FILE_SENTINEL,
|
|
49
48
|
loopGuidelines,
|
|
50
49
|
missedJobsMessage,
|
|
51
50
|
monitorGuidelines,
|
|
52
51
|
provenance,
|
|
52
|
+
RULES_PROVENANCE,
|
|
53
|
+
RUN_NOW_HINT,
|
|
53
54
|
SCHEDULE_WAKEUP_DESCRIPTION,
|
|
54
55
|
scheduledTaskProvenance,
|
|
55
56
|
SCHEDULE_WAKEUP_SNIPPET,
|
|
@@ -171,9 +172,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
171
172
|
* What the current run belongs to, derived from its starting message:
|
|
172
173
|
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
173
174
|
* `cronId` the fixed-interval job whose tick started it, `armed` the chains
|
|
174
|
-
* it armed, `tick` the tick number of a /loop run
|
|
175
|
+
* it armed, `tick` the tick number of a /loop run, `loopRequest` set when a
|
|
176
|
+
* typed /loop started it (its rules already say to do the task now).
|
|
175
177
|
*/
|
|
176
|
-
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
|
|
178
|
+
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number; loopRequest?: boolean } = {};
|
|
177
179
|
|
|
178
180
|
const scheduler = new LoopScheduler({
|
|
179
181
|
dispatch: (job) => deliverTick(job),
|
|
@@ -199,6 +201,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
199
201
|
const ctx = lastCtx;
|
|
200
202
|
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
201
203
|
},
|
|
204
|
+
onDispatchError: (job, error) => {
|
|
205
|
+
const ctx = lastCtx;
|
|
206
|
+
if (ctx) notify(ctx, `loop ${job.id}: this tick could not be delivered and was skipped (${error.message})`, "warning");
|
|
207
|
+
},
|
|
202
208
|
onScan: (now) => {
|
|
203
209
|
durable?.maintain(now);
|
|
204
210
|
// The countdown is coarse, so this is a string compare most seconds.
|
|
@@ -417,6 +423,27 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
417
423
|
*/
|
|
418
424
|
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
419
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The per-run rules, appended to the run as a hidden message instead of
|
|
428
|
+
* being written into the system prompt. Providers that cannot carry a system
|
|
429
|
+
* message mid-conversation get the leading system prompt rebuilt from every
|
|
430
|
+
* change, so a rule that comes and goes with each tick would invalidate the
|
|
431
|
+
* whole cached prefix on the way into the run and again on the way out.
|
|
432
|
+
* Claude Code does the same: its /loop instructions are part of the message.
|
|
433
|
+
*/
|
|
434
|
+
const RULES_MESSAGE_TYPE = "pi-cc-loop-rules";
|
|
435
|
+
|
|
436
|
+
function rulesFor(guidelines: string[]) {
|
|
437
|
+
if (!guidelines.length) return undefined;
|
|
438
|
+
return {
|
|
439
|
+
message: {
|
|
440
|
+
customType: RULES_MESSAGE_TYPE,
|
|
441
|
+
content: [RULES_PROVENANCE, ...guidelines].join("\n\n"),
|
|
442
|
+
display: false,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
420
447
|
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
421
448
|
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
422
449
|
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
@@ -429,8 +456,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
429
456
|
*/
|
|
430
457
|
function deliverTick(job: LoopJob): void {
|
|
431
458
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
432
|
-
tickCounters.set(job.prompt, tick);
|
|
433
|
-
job.tickCount = tick;
|
|
434
459
|
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
435
460
|
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
436
461
|
// self-paced tick keeps the loop framing. The rules for the run are
|
|
@@ -455,6 +480,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
455
480
|
// only recorded and nothing ever happens.
|
|
456
481
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
457
482
|
);
|
|
483
|
+
// Counted only once the message is in: a throw here means the tick never
|
|
484
|
+
// happened, so it must not use up a number.
|
|
485
|
+
tickCounters.set(job.prompt, tick);
|
|
486
|
+
job.tickCount = tick;
|
|
458
487
|
|
|
459
488
|
if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
|
|
460
489
|
const ctx = lastCtx;
|
|
@@ -471,7 +500,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
471
500
|
}
|
|
472
501
|
}
|
|
473
502
|
|
|
474
|
-
/** The tool names to exclude from pi-cc-extensions' global styling. */
|
|
475
503
|
/**
|
|
476
504
|
* A run finished without an abort: self-paced chains whose tick the model
|
|
477
505
|
* did not reschedule get their one keepalive, or end.
|
|
@@ -585,13 +613,35 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
585
613
|
// drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
|
|
586
614
|
// The per-run rules travel with the run through before_agent_start.
|
|
587
615
|
const text = raw ? `/loop ${raw}` : "/loop";
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
616
|
+
const guidelines = [loopCommandGuidelines(raw)];
|
|
617
|
+
const send = () => {
|
|
618
|
+
// The user asking for this loop again is a new loop: the mark that
|
|
619
|
+
// keeps a late schedule_wakeup from reviving a stopped chain must
|
|
620
|
+
// not block it.
|
|
621
|
+
stoppedPrompts.delete(normalizePrompt(raw) || LOOP_FILE_SENTINEL);
|
|
622
|
+
queueRun({ content: text, guidelines });
|
|
623
|
+
try {
|
|
624
|
+
pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
|
|
625
|
+
} catch {
|
|
626
|
+
// The session was replaced while this request waited.
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// A run that is already streaming has passed before_agent_start, so
|
|
631
|
+
// sending now would split the request in two: the text would be queued
|
|
632
|
+
// into that run without its rules, and the model would work through it
|
|
633
|
+
// twice. Wait for the run to end and start a clean one instead - the
|
|
634
|
+
// same wait a follow-up lane would impose, without losing the rules.
|
|
635
|
+
if (!ctx.isIdle() && typeof ctx.waitForIdle === "function") {
|
|
636
|
+
notify(ctx, `${text} will start when the current turn ends`, "info");
|
|
637
|
+
void ctx.waitForIdle().then(send, () => {});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// The followUp lane is the fallback for a Pi without waitForIdle: a
|
|
641
|
+
// sendUserMessage from a streaming turn throws without one ("Agent is
|
|
642
|
+
// already processing. Specify streamingBehavior"). When idle the option
|
|
643
|
+
// is ignored and the turn starts.
|
|
644
|
+
send();
|
|
595
645
|
},
|
|
596
646
|
});
|
|
597
647
|
|
|
@@ -725,6 +775,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
725
775
|
lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
|
|
726
776
|
}
|
|
727
777
|
lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
|
|
778
|
+
// Asked for in plain words, not through /loop: do it once now, as /loop would.
|
|
779
|
+
if (recurring && !currentRun.loopRequest && !currentRun.cronId && !currentRun.chainId) lines.push(RUN_NOW_HINT);
|
|
728
780
|
|
|
729
781
|
return {
|
|
730
782
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
@@ -991,11 +1043,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
991
1043
|
if (event.messageType === "user") {
|
|
992
1044
|
const queued = takeQueuedRun(event.prompt);
|
|
993
1045
|
if (!queued) return;
|
|
994
|
-
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick };
|
|
995
|
-
|
|
996
|
-
const options = event.systemPromptOptions;
|
|
997
|
-
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...queued.guidelines];
|
|
998
|
-
return;
|
|
1046
|
+
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick, loopRequest: true };
|
|
1047
|
+
return rulesFor(queued.guidelines);
|
|
999
1048
|
}
|
|
1000
1049
|
if (event.messageType !== "custom") return;
|
|
1001
1050
|
|
|
@@ -1011,21 +1060,16 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1011
1060
|
cronId: details.kind === "cron" ? details.jobId : undefined,
|
|
1012
1061
|
tick: details.tickCount,
|
|
1013
1062
|
};
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (details.kind
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
if (!guidelines.length) return;
|
|
1026
|
-
const options = event.systemPromptOptions;
|
|
1027
|
-
options.promptGuidelines = [...(options.promptGuidelines ?? []), ...guidelines];
|
|
1028
|
-
return;
|
|
1063
|
+
// A cron fire needs nothing beyond its envelope ("it fires the next tick
|
|
1064
|
+
// on its own, so do not call schedule_wakeup; to cancel it, call
|
|
1065
|
+
// cron_delete"): an extra message every minute would only grow the
|
|
1066
|
+
// context. A self-paced tick carries the rules that keep the loop alive.
|
|
1067
|
+
if (details.kind !== "wakeup") return;
|
|
1068
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1069
|
+
if (!chain) return;
|
|
1070
|
+
const guidelines = [loopGuidelines(chain.prompt)];
|
|
1071
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
|
|
1072
|
+
return rulesFor(guidelines);
|
|
1029
1073
|
}
|
|
1030
1074
|
|
|
1031
1075
|
if (event.message.customType === MONITOR_EVENT_TYPE) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cc-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
|
|
5
5
|
"author": "tarnish233",
|
|
6
6
|
"type": "module",
|
package/prompts.ts
CHANGED
|
@@ -33,7 +33,10 @@ export const PROVENANCE = "[loop tick #{n} \u00b7 scheduled by /loop, not typed
|
|
|
33
33
|
* it "loop" here is what made the model try to reschedule.
|
|
34
34
|
*/
|
|
35
35
|
export const SCHEDULED_TASK_PROVENANCE =
|
|
36
|
-
"[scheduled task {id} \u00b7 {schedule} \u00b7 started by the schedule, not typed by the user; it fires the next tick on its own, so do not call schedule_wakeup]";
|
|
36
|
+
"[scheduled task {id} \u00b7 {schedule} \u00b7 started by the schedule, not typed by the user; it fires the next tick on its own, so do not call schedule_wakeup; to cancel it, call cron_delete with id {id}]";
|
|
37
|
+
|
|
38
|
+
/** The first line of the hidden rules message a /loop request or a self-paced tick carries. */
|
|
39
|
+
export const RULES_PROVENANCE = "[/loop instructions for this run \u00b7 added by pi-cc-loop, not typed by the user]";
|
|
37
40
|
|
|
38
41
|
const DELAY_ADVICE = [
|
|
39
42
|
"- Pick the delay from what you are waiting for. For idle ticks with nothing specific to watch, use 1200-1800s.",
|
|
@@ -103,18 +106,6 @@ const LOOP_GUIDELINES = [
|
|
|
103
106
|
" with `stop: true` instead and say why.",
|
|
104
107
|
].join("\n");
|
|
105
108
|
|
|
106
|
-
/**
|
|
107
|
-
* Fixed-interval loops fire their own next tick, so a wakeup scheduled from
|
|
108
|
-
* one would run the same prompt twice. Claude Code's tick says the same:
|
|
109
|
-
* "The recurring cron will fire the next tick automatically — do not call
|
|
110
|
-
* ScheduleWakeup from this tick."
|
|
111
|
-
*/
|
|
112
|
-
const CRON_TICK_GUIDELINES = [
|
|
113
|
-
"This is a fixed-interval /loop (job {id}, {schedule}): the schedule fires the next tick on its own.",
|
|
114
|
-
"Do not call `schedule_wakeup` from this tick — it would run this prompt a second time on a self-paced",
|
|
115
|
-
"schedule. To cancel the loop, call `cron_delete` with id {id}.",
|
|
116
|
-
].join("\n");
|
|
117
|
-
|
|
118
109
|
/**
|
|
119
110
|
* How a self-paced loop uses the Monitor tool, after Claude Code's /loop
|
|
120
111
|
* skill: arm a monitor only when the next iteration waits on an event, arm it
|
|
@@ -176,10 +167,6 @@ export function loopGuidelines(prompt: string): string {
|
|
|
176
167
|
return fill(LOOP_GUIDELINES, { prompt: describePrompt(prompt), quoted: quote(prompt) });
|
|
177
168
|
}
|
|
178
169
|
|
|
179
|
-
export function cronTickGuidelines(id: string, schedule: string): string {
|
|
180
|
-
return fill(CRON_TICK_GUIDELINES, { id, schedule });
|
|
181
|
-
}
|
|
182
|
-
|
|
183
170
|
export function monitorGuidelines(prompt: string): string {
|
|
184
171
|
return fill(MONITOR_GUIDELINES, { quoted: quote(prompt) });
|
|
185
172
|
}
|
|
@@ -230,6 +217,19 @@ Jobs only fire while the agent is idle (not mid-turn). The scheduler shifts each
|
|
|
230
217
|
Recurring jobs auto-expire after 7 days \u2014 they fire one final time, then are deleted. Tell the user about the 7-day limit when scheduling recurring jobs.
|
|
231
218
|
Returns a job ID you can pass to cron_delete.`;
|
|
232
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Appended to the result of a recurring `cron_create` in a plain turn (not a
|
|
222
|
+
* /loop request, not a scheduled tick). Claude Code's /loop skill says "Then
|
|
223
|
+
* immediately run it now ... Don't wait for the first cron fire", and its model
|
|
224
|
+
* reaches that skill for "every 5 minutes ..." typed without /loop: the skill is
|
|
225
|
+
* listed every turn and CronCreate is a deferred tool. Pi has no deferred tools,
|
|
226
|
+
* so the model calls cron_create directly and the result says it instead. A
|
|
227
|
+
* tool result is appended at the end of the transcript; the cache is untouched.
|
|
228
|
+
*/
|
|
229
|
+
export const RUN_NOW_HINT =
|
|
230
|
+
'If the user asked for this to repeat on an interval ("every 5 minutes"), do the task once now as well \u2014 ' +
|
|
231
|
+
'don\'t wait for the first fire, as /loop does. A job at set times ("every day at 9am") only runs at those times.';
|
|
232
|
+
|
|
233
233
|
export const CRON_DELETE_DESCRIPTION =
|
|
234
234
|
"Cancel a job scheduled with cron_create, or end a self-paced /loop, by its id. Removes it from .pi/scheduled_tasks.json (durable jobs) or from this session (session-only jobs).";
|
|
235
235
|
|
package/renderers.ts
CHANGED
|
@@ -67,7 +67,9 @@ export function renderScheduledMessage(message: any, options: any, theme: Theme)
|
|
|
67
67
|
: "\u273b loop";
|
|
68
68
|
|
|
69
69
|
if (!loadConfig().compactMessages) {
|
|
70
|
-
|
|
70
|
+
// Same naming as the block: a cron fire is a scheduled task, only a
|
|
71
|
+
// self-paced tick is a loop.
|
|
72
|
+
const header = details.missed ? label : `${label.replace("\u273b ", "")} \u00b7 ${displayPrompt(prompt)}`;
|
|
71
73
|
return renderCard({ header, body: prompt, expanded: options.expanded, outputPad: options.outputPad, theme });
|
|
72
74
|
}
|
|
73
75
|
|
package/schedule.ts
CHANGED
|
@@ -159,6 +159,8 @@ export interface SchedulerHooks {
|
|
|
159
159
|
onExpire?: (job: LoopJob) => void;
|
|
160
160
|
/** Runs at the start of every scan (housekeeping such as durable jobs). */
|
|
161
161
|
onScan?: (now: number) => void;
|
|
162
|
+
/** A delivery threw (Pi's API is stale once the session is replaced); the tick is dropped. */
|
|
163
|
+
onDispatchError?: (job: LoopJob, error: Error) => void;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
/** Claude Code checks for due tasks once a second. */
|
|
@@ -277,7 +279,12 @@ export class LoopScheduler {
|
|
|
277
279
|
|
|
278
280
|
/** Run one scan: housekeeping, mark due jobs pending, dispatch the oldest when idle. */
|
|
279
281
|
tick(now = Date.now()): void {
|
|
280
|
-
|
|
282
|
+
try {
|
|
283
|
+
this.hooks.onScan?.(now);
|
|
284
|
+
} catch {
|
|
285
|
+
// Housekeeping (the durable file, the footer) is best effort. This runs
|
|
286
|
+
// from a timer, where an exception would take Pi down with it.
|
|
287
|
+
}
|
|
281
288
|
this.markDue(now);
|
|
282
289
|
this.tryDispatch(now);
|
|
283
290
|
}
|
|
@@ -336,7 +343,14 @@ export class LoopScheduler {
|
|
|
336
343
|
finished = next === null;
|
|
337
344
|
}
|
|
338
345
|
|
|
339
|
-
|
|
346
|
+
try {
|
|
347
|
+
this.hooks.dispatch(job);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Dropped, like any tick that fails to start; the job keeps its next
|
|
350
|
+
// run. Not rethrown: this runs from a timer, where an exception would
|
|
351
|
+
// take Pi down with it.
|
|
352
|
+
this.hooks.onDispatchError?.(job, error instanceof Error ? error : new Error(String(error)));
|
|
353
|
+
}
|
|
340
354
|
if (finished) this.jobs.delete(job.id);
|
|
341
355
|
this.hooks.afterDispatch?.(job, finished);
|
|
342
356
|
if (expired) this.hooks.onExpire?.(job);
|