pi-cc-loop 0.3.0 → 0.5.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 +118 -35
- package/config.ts +31 -13
- package/cron.ts +0 -10
- package/index.ts +136 -42
- package/package.json +1 -1
- package/prompts.ts +17 -17
- package/renderers.ts +3 -1
- package/schedule.ts +16 -2
- package/settings-panel.ts +135 -0
package/README.md
CHANGED
|
@@ -1,59 +1,123 @@
|
|
|
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
|
+
| 界面设置 | 和 Pi 的 `/settings` 一样的面板,本地命令,不进对话 | `/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
|
-
/loop settings
|
|
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 配置目录下的 `pi-cc.json`(默认 `~/.pi/agent/pi-cc.json`,跟随 `PI_CODING_AGENT_DIR`),文件不存在时用默认值:
|
|
90
|
+
|
|
91
|
+
```jsonc
|
|
92
|
+
{
|
|
93
|
+
"compactMessages": true, // 触发块和 Monitor 事件用上面的样式;false 为旧的带底色卡片
|
|
94
|
+
"footer": true // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
在 TUI 里输入 `/loop settings` 会打开设置面板,样子和操作都和 Pi 的 `/settings` 一样:上下键选择,Enter/空格切换,Esc 关闭;
|
|
99
|
+
改动立即保存,两个扩展同时生效。Pi 的 `/settings` 不接受扩展加项,所以面板放在 `/loop settings` 下。
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
─────────────────────────────────────────────────────────────
|
|
103
|
+
pi-cc settings · ~/.pi/agent/pi-cc.json
|
|
104
|
+
|
|
105
|
+
→ Message style compact
|
|
106
|
+
Footer summary on
|
|
107
|
+
|
|
108
|
+
How scheduled fires and Monitor events are drawn. compact: ✻ header
|
|
109
|
+
and indented body; card: the old padded card.
|
|
110
|
+
|
|
111
|
+
Enter/Space to change · Esc to cancel
|
|
112
|
+
─────────────────────────────────────────────────────────────
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
也可以用文字命令:`/loop settings messages on|off`、`/loop settings footer on|off`(或 `/loop footer on|off`),适合脚本和 RPC;
|
|
116
|
+
非 TUI 模式下,不带参数的 `/loop settings` 输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
|
|
117
|
+
|
|
118
|
+
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES` 优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
|
|
119
|
+
说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
|
|
120
|
+
|
|
57
121
|
## 底部摘要
|
|
58
122
|
|
|
59
123
|
`ctx.ui.setStatus("loop", …)` 是 Pi 核心 API,不依赖任何其他扩展:
|
|
@@ -68,42 +132,61 @@ pi install npm:pi-cc-loop
|
|
|
68
132
|
- 倒计时取整到分钟,配合"文本相同就不调用"的去重,最多每分钟重绘一次(Pi 每次 `setStatus` 都会触发重绘)
|
|
69
133
|
- 装了 `pi-cc-extensions`:这一行会作为它的一个 chip 出现在第 2 行,可在它的配置面板里移动或隐藏;它会把 ANSI 去掉,所以这里只用纯文本
|
|
70
134
|
- 没装 `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 自己渲染。
|
|
135
|
+
- 装了 `pi-cc-monitor`:运行中的 monitor 另有一项 `monitor 2`,和这一行用同一个开关
|
|
75
136
|
|
|
76
137
|
## 环境变量
|
|
77
138
|
|
|
78
139
|
| 变量 | 默认 | 说明 |
|
|
79
140
|
| --- | --- | --- |
|
|
80
|
-
| `PI_LOOP_MAX_AGE_DAYS` | `7` |
|
|
141
|
+
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 周期任务最长存活天数,`0` 不过期 |
|
|
81
142
|
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
82
143
|
| `PI_LOOP_JITTER` | `0.5` | 周期任务最多延后的周期比例;`0` 关闭错峰 |
|
|
83
144
|
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
84
|
-
| `
|
|
145
|
+
| `PI_LOOP_DURABLE_SYNC_MS` | `5000` | 持久化任务文件的同步间隔 |
|
|
146
|
+
| `PI_CC_FOOTER` | — | `0` / `1`,覆盖设置文件里的 `footer` |
|
|
147
|
+
| `PI_CC_COMPACT_MESSAGES` | — | `0` / `1`,覆盖设置文件里的 `compactMessages` |
|
|
148
|
+
| `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` | 设置文件路径;不设时在 Pi 的配置目录里,跟随 `PI_CODING_AGENT_DIR` |
|
|
85
149
|
|
|
86
150
|
## 兼容性
|
|
87
151
|
|
|
88
152
|
- Node.js `>=22.19.0`
|
|
89
|
-
- Pi
|
|
90
|
-
-
|
|
153
|
+
- Pi 1.3.0、1.4.0(测试通过)
|
|
154
|
+
- `pi-cc-monitor` 可选:自定步调 loop 需要等外部事件时会用它,loop 结束时连带停止;两者通过 `pi.events` 通信
|
|
155
|
+
- 与 `pi-cc-extensions` 共存。本扩展不写其他插件的配置文件;旧版本在 `pi-cc-extensions.json` 里加过的
|
|
156
|
+
`cron_create`、`cron_delete`、`cron_list`、`schedule_wakeup` 这几条 `excludeRenderers` 可以删掉
|
|
91
157
|
|
|
92
158
|
## 本地开发
|
|
93
159
|
|
|
94
160
|
```bash
|
|
95
161
|
node --test tests/*.test.mjs
|
|
96
162
|
npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
163
|
+
node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免费模型,发版前跑
|
|
97
164
|
```
|
|
98
165
|
|
|
99
|
-
|
|
166
|
+
### 缓存约束
|
|
167
|
+
|
|
168
|
+
提示缓存按前缀匹配(工具 → system prompt → 消息),扩展只能往对话末尾追加:
|
|
169
|
+
|
|
170
|
+
- **不改 system prompt**:每轮需要的规则作为隐藏消息附在这一轮后面(`before_agent_start` 返回 `message`),不写 `promptGuidelines`,不返回 `systemPrompt`。
|
|
171
|
+
- **不动工具集**:工具在加载时一次注册,描述是固定文本;不调用 `setActiveTools`,会话中途不改描述。
|
|
172
|
+
- **不改历史**:不注册 `context` / `context_with_system` / `before_provider_request`。
|
|
173
|
+
|
|
174
|
+
`tests/cache.test.mjs` 守住这三条。`scripts/cache-check.mjs` 用真实 provider 依次走一遍固定间隔 `/loop`、触发、普通对话、`/loop stop`、Monitor 事件、自定步调 `/loop` 和它的触发,逐个请求看缓存读取量:
|
|
175
|
+
读到的明显少于上一个请求发出的内容就报 MISS(退出码 1)。provider 偶尔会自己丢缓存,同一步骤重跑仍然 MISS 才是扩展的问题。
|
|
176
|
+
默认模型 `opencode-go/beam-free` 免费;`--model` 可换,脚本最后会打印本次花费。
|
|
177
|
+
|
|
178
|
+
## 与 Claude Code 的差异
|
|
179
|
+
|
|
180
|
+
行为对齐 Claude Code 2.1.308 的 `/loop`,用 Pi 扩展 API 实现。有意不同的地方:
|
|
100
181
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
-
|
|
182
|
+
- Esc 只取消被打断回合所属的自定步调 loop;CC 按 Esc(包括空闲时、打断无关回合时)会取消所有自定步调 loop。其他情况用 `/loop stop`。
|
|
183
|
+
- 固定间隔的触发里调用 `schedule_wakeup` 会被当作空操作并返回说明,防止同一个任务又被自定步调重复调度;CC 没有这条硬规则。
|
|
184
|
+
- 不带 `/loop` 的请求(比如"每分钟打印一次hello")也会在建好任务后立即做一次。CC 靠模型自己调用 loop 技能做到(`CronCreate` 在 CC 里是延迟加载的工具,模型会先走技能);Pi 没有延迟加载工具,这里改为在 `cron_create` 的结果里加一句提示,只在普通对话里建周期任务时出现。
|
|
185
|
+
- 底部摘要和 `maxFires` 次数上限是额外加的;CC 主会话不显示 loop 状态,也没有次数上限。
|
|
186
|
+
- 自定步调 loop 结束(stop、Esc、`cron_delete`、兜底心跳用完)时,绑定的 Monitor 会被静默停止;CC 靠模型自己调用 `TaskStop`。
|
|
106
187
|
- Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
|
|
188
|
+
- 没有做 CC 的 `noop` 折叠、触发时"不是用户实时输入"的安全提示、间隔边界提示和云端调度。
|
|
189
|
+
- 没有做 CC 针对 5 分钟提示缓存的两处调整(`*/5` 每次提前 15 秒、接近 300 秒的唤醒往回拉)。在 5 分钟缓存的 provider 上(比如 Anthropic 默认),`*/5` 的触发和 300 秒左右的唤醒可能刚好错过缓存。
|
|
107
190
|
|
|
108
191
|
## 许可证
|
|
109
192
|
|
package/config.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both pi-cc-loop and pi-cc-monitor read the same small JSON file so a single
|
|
5
5
|
* toggle changes the look of both packages. The file is optional; when it is
|
|
6
|
-
* missing or malformed the defaults apply.
|
|
6
|
+
* missing or malformed the defaults apply. It sits in Pi's agent directory
|
|
7
|
+
* (PI_CODING_AGENT_DIR, default ~/.pi/agent), next to Pi's own settings.
|
|
7
8
|
*
|
|
8
9
|
* ~/.pi/agent/pi-cc.json
|
|
9
10
|
* {
|
|
@@ -11,14 +12,15 @@
|
|
|
11
12
|
* "footer": true
|
|
12
13
|
* }
|
|
13
14
|
*
|
|
14
|
-
* Environment overrides (useful for tests and quick experiments)
|
|
15
|
+
* Environment overrides (useful for tests and quick experiments) win over the
|
|
16
|
+
* file while they are set, and are never written to it:
|
|
15
17
|
* PI_CC_CONFIG_FILE=/path/to/pi-cc.json
|
|
16
18
|
* PI_CC_COMPACT_MESSAGES=0|1
|
|
17
19
|
* PI_CC_FOOTER=0|1
|
|
18
20
|
*/
|
|
19
21
|
|
|
22
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
23
|
import fs from "node:fs";
|
|
21
|
-
import os from "node:os";
|
|
22
24
|
import path from "node:path";
|
|
23
25
|
|
|
24
26
|
export interface PiCcConfig {
|
|
@@ -36,6 +38,12 @@ export const DEFAULT_CONFIG: PiCcConfig = {
|
|
|
36
38
|
footer: true,
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
/** The environment variable that overrides each setting. */
|
|
42
|
+
const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
|
|
43
|
+
compactMessages: "PI_CC_COMPACT_MESSAGES",
|
|
44
|
+
footer: "PI_CC_FOOTER",
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
function envBool(name: string): boolean | undefined {
|
|
40
48
|
const raw = process.env[name];
|
|
41
49
|
if (raw === undefined) return undefined;
|
|
@@ -44,11 +52,16 @@ function envBool(name: string): boolean | undefined {
|
|
|
44
52
|
return undefined;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/** The
|
|
55
|
+
/** The variable overriding a setting right now, as `PI_CC_FOOTER=0`; undefined when none is. */
|
|
56
|
+
export function envOverride(key: keyof PiCcConfig): string | undefined {
|
|
57
|
+
const name = ENV_OVERRIDES[key];
|
|
58
|
+
return envBool(name) === undefined ? undefined : `${name}=${process.env[name]}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The settings file shared by pi-cc-loop and pi-cc-monitor, in Pi's agent directory. */
|
|
48
62
|
export function configPath(): string {
|
|
49
63
|
if (process.env.PI_CC_CONFIG_FILE) return process.env.PI_CC_CONFIG_FILE;
|
|
50
|
-
|
|
51
|
-
return path.join(agentDir, "pi-cc.json");
|
|
64
|
+
return path.join(getAgentDir(), "pi-cc.json");
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
function readConfig(): PiCcConfig {
|
|
@@ -61,10 +74,10 @@ function readConfig(): PiCcConfig {
|
|
|
61
74
|
}
|
|
62
75
|
return {
|
|
63
76
|
compactMessages:
|
|
64
|
-
envBool(
|
|
77
|
+
envBool(ENV_OVERRIDES.compactMessages) ??
|
|
65
78
|
(typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
|
|
66
79
|
footer:
|
|
67
|
-
envBool(
|
|
80
|
+
envBool(ENV_OVERRIDES.footer) ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
|
|
68
81
|
};
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -81,9 +94,14 @@ export function invalidateConfig(): void {
|
|
|
81
94
|
cached = undefined;
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Write the changed keys to the settings file and return the settings now in
|
|
99
|
+
* effect. Only the patch is written: every other key stays as the user left
|
|
100
|
+
* it, and an environment override is never copied into the file. It still
|
|
101
|
+
* wins over the file while it is set, so the returned value can differ from
|
|
102
|
+
* the patch (see `envOverride`).
|
|
103
|
+
*/
|
|
85
104
|
export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
86
|
-
const next = { ...loadConfig(), ...patch };
|
|
87
105
|
const file = configPath();
|
|
88
106
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
107
|
// Keep keys this version does not know about: the file is the user's.
|
|
@@ -95,8 +113,8 @@ export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
|
95
113
|
// A missing or malformed file is rewritten from scratch.
|
|
96
114
|
}
|
|
97
115
|
const temp = `${file}.tmp`;
|
|
98
|
-
fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...
|
|
116
|
+
fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
|
|
99
117
|
fs.renameSync(temp, file);
|
|
100
|
-
cached =
|
|
101
|
-
return
|
|
118
|
+
cached = undefined;
|
|
119
|
+
return loadConfig();
|
|
102
120
|
}
|
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
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { Type } from "typebox";
|
|
32
|
-
import { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
|
|
32
|
+
import { configPath, envOverride, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
|
|
33
33
|
import {
|
|
34
34
|
humanSchedule,
|
|
35
35
|
isUnreachable,
|
|
@@ -44,18 +44,20 @@ 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,
|
|
56
57
|
loopCommandGuidelines,
|
|
57
58
|
} from "./prompts.ts";
|
|
58
59
|
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
60
|
+
import { settingsPanel } from "./settings-panel.ts";
|
|
59
61
|
import {
|
|
60
62
|
DEFAULT_TIMING,
|
|
61
63
|
generateId,
|
|
@@ -171,9 +173,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
171
173
|
* What the current run belongs to, derived from its starting message:
|
|
172
174
|
* `chainId` is the self-paced chain whose tick (or monitor) started it,
|
|
173
175
|
* `cronId` the fixed-interval job whose tick started it, `armed` the chains
|
|
174
|
-
* it armed, `tick` the tick number of a /loop run
|
|
176
|
+
* it armed, `tick` the tick number of a /loop run, `loopRequest` set when a
|
|
177
|
+
* typed /loop started it (its rules already say to do the task now).
|
|
175
178
|
*/
|
|
176
|
-
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number } = {};
|
|
179
|
+
let currentRun: { chainId?: string; cronId?: string; armed?: Set<string>; tick?: number; loopRequest?: boolean } = {};
|
|
177
180
|
|
|
178
181
|
const scheduler = new LoopScheduler({
|
|
179
182
|
dispatch: (job) => deliverTick(job),
|
|
@@ -199,6 +202,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
199
202
|
const ctx = lastCtx;
|
|
200
203
|
if (ctx) notify(ctx, `loop ${job.id} ran its final time: recurring jobs expire after ${maxAgeDays()} days`);
|
|
201
204
|
},
|
|
205
|
+
onDispatchError: (job, error) => {
|
|
206
|
+
const ctx = lastCtx;
|
|
207
|
+
if (ctx) notify(ctx, `loop ${job.id}: this tick could not be delivered and was skipped (${error.message})`, "warning");
|
|
208
|
+
},
|
|
202
209
|
onScan: (now) => {
|
|
203
210
|
durable?.maintain(now);
|
|
204
211
|
// The countdown is coarse, so this is a string compare most seconds.
|
|
@@ -417,6 +424,27 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
417
424
|
*/
|
|
418
425
|
const TICK_MESSAGE_TYPE = "pi-cc-loop-tick";
|
|
419
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The per-run rules, appended to the run as a hidden message instead of
|
|
429
|
+
* being written into the system prompt. Providers that cannot carry a system
|
|
430
|
+
* message mid-conversation get the leading system prompt rebuilt from every
|
|
431
|
+
* change, so a rule that comes and goes with each tick would invalidate the
|
|
432
|
+
* whole cached prefix on the way into the run and again on the way out.
|
|
433
|
+
* Claude Code does the same: its /loop instructions are part of the message.
|
|
434
|
+
*/
|
|
435
|
+
const RULES_MESSAGE_TYPE = "pi-cc-loop-rules";
|
|
436
|
+
|
|
437
|
+
function rulesFor(guidelines: string[]) {
|
|
438
|
+
if (!guidelines.length) return undefined;
|
|
439
|
+
return {
|
|
440
|
+
message: {
|
|
441
|
+
customType: RULES_MESSAGE_TYPE,
|
|
442
|
+
content: [RULES_PROVENANCE, ...guidelines].join("\n\n"),
|
|
443
|
+
display: false,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
/** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
|
|
421
449
|
function resolvePrompt(prompt: string, mode: TickMode): string {
|
|
422
450
|
return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
|
|
@@ -429,8 +457,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
429
457
|
*/
|
|
430
458
|
function deliverTick(job: LoopJob): void {
|
|
431
459
|
const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
|
|
432
|
-
tickCounters.set(job.prompt, tick);
|
|
433
|
-
job.tickCount = tick;
|
|
434
460
|
const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
|
|
435
461
|
// A fixed-interval tick is what it is - a scheduled task - and a
|
|
436
462
|
// self-paced tick keeps the loop framing. The rules for the run are
|
|
@@ -455,6 +481,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
455
481
|
// only recorded and nothing ever happens.
|
|
456
482
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
457
483
|
);
|
|
484
|
+
// Counted only once the message is in: a throw here means the tick never
|
|
485
|
+
// happened, so it must not use up a number.
|
|
486
|
+
tickCounters.set(job.prompt, tick);
|
|
487
|
+
job.tickCount = tick;
|
|
458
488
|
|
|
459
489
|
if (job.maxFires !== undefined && (job.fireCount ?? 0) >= job.maxFires) {
|
|
460
490
|
const ctx = lastCtx;
|
|
@@ -471,7 +501,6 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
471
501
|
}
|
|
472
502
|
}
|
|
473
503
|
|
|
474
|
-
/** The tool names to exclude from pi-cc-extensions' global styling. */
|
|
475
504
|
/**
|
|
476
505
|
* A run finished without an abort: self-paced chains whose tick the model
|
|
477
506
|
* did not reschedule get their one keepalive, or end.
|
|
@@ -546,7 +575,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
546
575
|
case "statusline": {
|
|
547
576
|
const arg = (rest[0] ?? "").toLowerCase();
|
|
548
577
|
if (!arg) {
|
|
549
|
-
|
|
578
|
+
const current = `${loadConfig().footer ? "on" : "off"}${overrideNote("footer")}`;
|
|
579
|
+
notify(ctx, `Usage: /loop footer on|off (currently ${current})`, "info");
|
|
550
580
|
return;
|
|
551
581
|
}
|
|
552
582
|
setSetting(ctx, "footer", arg);
|
|
@@ -556,7 +586,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
556
586
|
case "config": {
|
|
557
587
|
const [key, value] = rest;
|
|
558
588
|
if (!key) {
|
|
559
|
-
showSettings(ctx);
|
|
589
|
+
if (!(await openSettingsPanel(ctx))) showSettings(ctx);
|
|
560
590
|
return;
|
|
561
591
|
}
|
|
562
592
|
const name = key.toLowerCase();
|
|
@@ -585,13 +615,35 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
585
615
|
// drives cron_create / cron_list / cron_delete / schedule_wakeup itself.
|
|
586
616
|
// The per-run rules travel with the run through before_agent_start.
|
|
587
617
|
const text = raw ? `/loop ${raw}` : "/loop";
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
618
|
+
const guidelines = [loopCommandGuidelines(raw)];
|
|
619
|
+
const send = () => {
|
|
620
|
+
// The user asking for this loop again is a new loop: the mark that
|
|
621
|
+
// keeps a late schedule_wakeup from reviving a stopped chain must
|
|
622
|
+
// not block it.
|
|
623
|
+
stoppedPrompts.delete(normalizePrompt(raw) || LOOP_FILE_SENTINEL);
|
|
624
|
+
queueRun({ content: text, guidelines });
|
|
625
|
+
try {
|
|
626
|
+
pi.sendUserMessage(text, { expandPromptTemplates: false, deliverAs: "followUp" });
|
|
627
|
+
} catch {
|
|
628
|
+
// The session was replaced while this request waited.
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// A run that is already streaming has passed before_agent_start, so
|
|
633
|
+
// sending now would split the request in two: the text would be queued
|
|
634
|
+
// into that run without its rules, and the model would work through it
|
|
635
|
+
// twice. Wait for the run to end and start a clean one instead - the
|
|
636
|
+
// same wait a follow-up lane would impose, without losing the rules.
|
|
637
|
+
if (!ctx.isIdle() && typeof ctx.waitForIdle === "function") {
|
|
638
|
+
notify(ctx, `${text} will start when the current turn ends`, "info");
|
|
639
|
+
void ctx.waitForIdle().then(send, () => {});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
// The followUp lane is the fallback for a Pi without waitForIdle: a
|
|
643
|
+
// sendUserMessage from a streaming turn throws without one ("Agent is
|
|
644
|
+
// already processing. Specify streamingBehavior"). When idle the option
|
|
645
|
+
// is ignored and the turn starts.
|
|
646
|
+
send();
|
|
595
647
|
},
|
|
596
648
|
});
|
|
597
649
|
|
|
@@ -601,7 +653,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
601
653
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
602
654
|
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
603
655
|
"/loop footer on|off show or hide the one-line summary in the footer",
|
|
604
|
-
"/loop settings
|
|
656
|
+
"/loop settings settings panel (a summary outside the TUI)",
|
|
605
657
|
"/loop settings messages on|off compact two-line markers",
|
|
606
658
|
"/loop settings footer on|off the one-line footer summary",
|
|
607
659
|
"",
|
|
@@ -612,12 +664,46 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
612
664
|
notify(ctx, help, "info");
|
|
613
665
|
}
|
|
614
666
|
|
|
667
|
+
/** ` (PI_CC_FOOTER=0)` when an environment variable decides the setting. */
|
|
668
|
+
function overrideNote(key: keyof PiCcConfig): string {
|
|
669
|
+
const override = envOverride(key);
|
|
670
|
+
return override ? ` (${override})` : "";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* A bare /loop settings in the TUI: a panel like Pi's /settings, which
|
|
675
|
+
* extensions cannot add to. Resolves when the panel closes; false when there
|
|
676
|
+
* is no TUI to show it in, so the caller prints the summary instead.
|
|
677
|
+
*/
|
|
678
|
+
async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
679
|
+
if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") return false;
|
|
680
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
681
|
+
const panel = settingsPanel({
|
|
682
|
+
theme,
|
|
683
|
+
file: configPath(),
|
|
684
|
+
onChange: (field, value) => {
|
|
685
|
+
applySetting(field, value);
|
|
686
|
+
},
|
|
687
|
+
onClose: () => done(undefined),
|
|
688
|
+
});
|
|
689
|
+
return {
|
|
690
|
+
render: (width: number) => panel.render(width),
|
|
691
|
+
invalidate: () => panel.invalidate(),
|
|
692
|
+
handleInput: (data: string) => {
|
|
693
|
+
panel.handleInput(data);
|
|
694
|
+
tui.requestRender();
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
615
701
|
function showSettings(ctx: ExtensionContext): void {
|
|
616
702
|
const config = loadConfig();
|
|
617
703
|
const lines = [
|
|
618
704
|
`pi-cc settings \u00b7 ${configPath()}`,
|
|
619
|
-
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
620
|
-
` footer: ${config.footer ? "summary" : "hidden"}`,
|
|
705
|
+
` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
|
|
706
|
+
` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
|
|
621
707
|
"",
|
|
622
708
|
"Usage: /loop settings messages on|off",
|
|
623
709
|
" /loop settings footer on|off",
|
|
@@ -633,15 +719,29 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
633
719
|
showSettings(ctx);
|
|
634
720
|
return;
|
|
635
721
|
}
|
|
636
|
-
const
|
|
722
|
+
const field: keyof PiCcConfig = key === "messages" ? "compactMessages" : "footer";
|
|
723
|
+
const next = applySetting(field, on);
|
|
724
|
+
const label = key === "messages" ? "Compact message style" : "Footer summary";
|
|
725
|
+
const override = envOverride(field);
|
|
726
|
+
if (override && next[field] !== on) {
|
|
727
|
+
// The file has the choice, but the variable decides while it is set.
|
|
728
|
+
notify(ctx, `${label} saved as ${on ? "on" : "off"}, but ${override} overrides it while set`, "warning");
|
|
729
|
+
} else {
|
|
730
|
+
notify(ctx, `${label} ${on ? "enabled" : "disabled"}`, "info");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Save one setting, tell the other pi-cc packages, and repaint the footer. Returns the settings in effect. */
|
|
735
|
+
function applySetting(field: keyof PiCcConfig, on: boolean): PiCcConfig {
|
|
736
|
+
const patch: Partial<PiCcConfig> = field === "compactMessages" ? { compactMessages: on } : { footer: on };
|
|
637
737
|
const next = saveConfig(patch);
|
|
638
738
|
try {
|
|
639
739
|
pi.events.emit(CONFIG_CHANNEL, next);
|
|
640
740
|
} catch {
|
|
641
741
|
// another extension must not break the command
|
|
642
742
|
}
|
|
643
|
-
|
|
644
|
-
|
|
743
|
+
if (field === "footer") refreshStatus();
|
|
744
|
+
return next;
|
|
645
745
|
}
|
|
646
746
|
|
|
647
747
|
// ------------------------------------------------------------------ tools
|
|
@@ -725,6 +825,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
725
825
|
lines.push(`Recurring jobs auto-expire after ${maxAgeDays()} days: the job runs one final time, then it is deleted.`);
|
|
726
826
|
}
|
|
727
827
|
lines.push(`Cancel it with cron_delete { "id": "${job.id}" }.`);
|
|
828
|
+
// Asked for in plain words, not through /loop: do it once now, as /loop would.
|
|
829
|
+
if (recurring && !currentRun.loopRequest && !currentRun.cronId && !currentRun.chainId) lines.push(RUN_NOW_HINT);
|
|
728
830
|
|
|
729
831
|
return {
|
|
730
832
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
@@ -991,11 +1093,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
991
1093
|
if (event.messageType === "user") {
|
|
992
1094
|
const queued = takeQueuedRun(event.prompt);
|
|
993
1095
|
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;
|
|
1096
|
+
currentRun = { chainId: queued.chainId, cronId: queued.cronId, tick: queued.tick, loopRequest: true };
|
|
1097
|
+
return rulesFor(queued.guidelines);
|
|
999
1098
|
}
|
|
1000
1099
|
if (event.messageType !== "custom") return;
|
|
1001
1100
|
|
|
@@ -1011,21 +1110,16 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
1011
1110
|
cronId: details.kind === "cron" ? details.jobId : undefined,
|
|
1012
1111
|
tick: details.tickCount,
|
|
1013
1112
|
};
|
|
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;
|
|
1113
|
+
// A cron fire needs nothing beyond its envelope ("it fires the next tick
|
|
1114
|
+
// on its own, so do not call schedule_wakeup; to cancel it, call
|
|
1115
|
+
// cron_delete"): an extra message every minute would only grow the
|
|
1116
|
+
// context. A self-paced tick carries the rules that keep the loop alive.
|
|
1117
|
+
if (details.kind !== "wakeup") return;
|
|
1118
|
+
const chain = details.jobId ? scheduler.get(details.jobId) : undefined;
|
|
1119
|
+
if (!chain) return;
|
|
1120
|
+
const guidelines = [loopGuidelines(chain.prompt)];
|
|
1121
|
+
if (monitorToolAvailable()) guidelines.push(monitorGuidelines(chain.prompt));
|
|
1122
|
+
return rulesFor(guidelines);
|
|
1029
1123
|
}
|
|
1030
1124
|
|
|
1031
1125
|
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.5.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);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /loop settings panel: the same kind of screen as Pi's /settings.
|
|
3
|
+
*
|
|
4
|
+
* Pi's /settings cannot take items from an extension (its panel is a fixed
|
|
5
|
+
* list, and the command is handled before extension commands), so the shared
|
|
6
|
+
* pi-cc settings get their own panel, built from the same parts: SettingsList
|
|
7
|
+
* between two borders, the selected setting's description below it, and
|
|
8
|
+
* Enter/Space to change a value. A change is saved at once and Esc closes.
|
|
9
|
+
*
|
|
10
|
+
* A setting that an environment variable overrides shows the value in effect
|
|
11
|
+
* and cannot be changed here; its description names the variable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Container, SettingsList, Spacer, Text, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { envOverride, loadConfig, type PiCcConfig } from "./config.ts";
|
|
19
|
+
|
|
20
|
+
/** The part of Pi's theme the panel uses. */
|
|
21
|
+
export interface PanelTheme {
|
|
22
|
+
fg(color: string, text: string): string;
|
|
23
|
+
bold(text: string): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SettingSpec {
|
|
27
|
+
field: keyof PiCcConfig;
|
|
28
|
+
label: string;
|
|
29
|
+
/** Display values for true and false. */
|
|
30
|
+
on: string;
|
|
31
|
+
off: string;
|
|
32
|
+
description: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SETTINGS: SettingSpec[] = [
|
|
36
|
+
{
|
|
37
|
+
field: "compactMessages",
|
|
38
|
+
label: "Message style",
|
|
39
|
+
on: "compact",
|
|
40
|
+
off: "card",
|
|
41
|
+
description:
|
|
42
|
+
"How scheduled fires and Monitor events are drawn. compact: \u273b header and indented body; card: the old padded card.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
field: "footer",
|
|
46
|
+
label: "Footer summary",
|
|
47
|
+
on: "on",
|
|
48
|
+
off: "off",
|
|
49
|
+
description: "One line in Pi's footer while loops or monitors run: loop 2 \u00b7 next 3m, monitor 2.",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pi's getSettingsListTheme(), built from the theme the panel is given: the
|
|
55
|
+
* global theme that function reads is not shared with extensions in every
|
|
56
|
+
* Pi runtime (see the note on Pi's DynamicBorder).
|
|
57
|
+
*/
|
|
58
|
+
function listTheme(theme: PanelTheme): SettingsListTheme {
|
|
59
|
+
return {
|
|
60
|
+
label: (text, selected) => (selected ? theme.fg("accent", text) : text),
|
|
61
|
+
value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
62
|
+
description: (text) => theme.fg("dim", text),
|
|
63
|
+
cursor: theme.fg("accent", "\u2192 "),
|
|
64
|
+
hint: (text) => theme.fg("dim", text),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A path with the home directory shortened to ~. */
|
|
69
|
+
function displayPath(file: string): string {
|
|
70
|
+
const home = os.homedir();
|
|
71
|
+
return file === home || file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function item(spec: SettingSpec): SettingItem {
|
|
75
|
+
const value = loadConfig()[spec.field] ? spec.on : spec.off;
|
|
76
|
+
const override = envOverride(spec.field);
|
|
77
|
+
if (override) {
|
|
78
|
+
// No values: Enter/Space leaves it alone, and the description says why.
|
|
79
|
+
return {
|
|
80
|
+
id: spec.field,
|
|
81
|
+
label: spec.label,
|
|
82
|
+
currentValue: value,
|
|
83
|
+
description: `${spec.description}\n${override} overrides this while set; unset it to change it here.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { id: spec.field, label: spec.label, currentValue: value, values: [spec.on, spec.off], description: spec.description };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SettingsPanelOptions {
|
|
90
|
+
theme: PanelTheme;
|
|
91
|
+
/** Where the settings are saved, shown in the title. */
|
|
92
|
+
file: string;
|
|
93
|
+
/** Save one setting (and tell the other packages). */
|
|
94
|
+
onChange(field: keyof PiCcConfig, value: boolean): void;
|
|
95
|
+
onClose(): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SettingsPanel {
|
|
99
|
+
render(width: number): string[];
|
|
100
|
+
invalidate(): void;
|
|
101
|
+
handleInput(data: string): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function settingsPanel(options: SettingsPanelOptions): SettingsPanel {
|
|
105
|
+
const { theme } = options;
|
|
106
|
+
const list = new SettingsList(
|
|
107
|
+
SETTINGS.map(item),
|
|
108
|
+
SETTINGS.length,
|
|
109
|
+
listTheme(theme),
|
|
110
|
+
(id, value) => {
|
|
111
|
+
const spec = SETTINGS.find((candidate) => candidate.field === id);
|
|
112
|
+
if (!spec) return;
|
|
113
|
+
options.onChange(spec.field, value === spec.on);
|
|
114
|
+
// Show what is in effect now.
|
|
115
|
+
list.updateValue(id, loadConfig()[spec.field] ? spec.on : spec.off);
|
|
116
|
+
},
|
|
117
|
+
() => options.onClose(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const border = () => new DynamicBorder((text: string) => theme.fg("border", text));
|
|
121
|
+
const container = new Container();
|
|
122
|
+
container.addChild(border());
|
|
123
|
+
container.addChild(
|
|
124
|
+
new Text(`${theme.fg("accent", theme.bold("pi-cc settings"))}${theme.fg("dim", ` \u00b7 ${displayPath(options.file)}`)}`, 0, 0),
|
|
125
|
+
);
|
|
126
|
+
container.addChild(new Spacer(1));
|
|
127
|
+
container.addChild(list);
|
|
128
|
+
container.addChild(border());
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
render: (width) => container.render(width),
|
|
132
|
+
invalidate: () => container.invalidate(),
|
|
133
|
+
handleInput: (data) => list.handleInput(data),
|
|
134
|
+
};
|
|
135
|
+
}
|