pi-cc-loop 0.2.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 +100 -73
- package/config.ts +15 -86
- package/cron.ts +1 -122
- package/index.ts +273 -388
- package/loopfile.ts +4 -8
- package/package.json +1 -1
- package/prompts.ts +84 -27
- package/render.ts +21 -1
- package/renderers.ts +60 -107
- package/schedule.ts +16 -2
- package/tool-rows.ts +0 -79
package/README.md
CHANGED
|
@@ -1,144 +1,171 @@
|
|
|
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
|
+
|
|
19
|
+
`/loop` 和 CC 一样是一个**技能**,不是用代码直接排任务:你输入的内容会作为一条普通 user 消息进入对话,
|
|
20
|
+
模型自己解析间隔并调用工具完成。所以对话里始终能看到你要求了什么、模型做了什么。
|
|
21
|
+
|
|
22
|
+
给模型的规则(怎么建任务、自定步调怎么续期)以一条隐藏消息附在这一轮后面,不改 system prompt:
|
|
23
|
+
不支持对话中途 system 消息的 provider 会把 system prompt 整体重建,规则一来一去会让整段提示缓存失效。
|
|
24
|
+
固定间隔的每次触发不附加任何东西,信封里一句"它会自己触发下一次,取消用 cron_delete"就够了。
|
|
18
25
|
|
|
19
26
|
| 功能 | 说明 | 入口 |
|
|
20
27
|
| --- | --- | --- |
|
|
21
|
-
| 固定间隔 |
|
|
22
|
-
| 自选间隔 |
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
28
|
+
| 固定间隔 | 模型用 `cron_create` 建一个 cron(`5m` → `*/5 * * * *`),然后立即执行一次;触发时按"定时任务"呈现。不带 `/loop`、直接说"每分钟打印一次hello"也一样 | `/loop 5m <prompt>`、`/loop 每分钟 ...` |
|
|
29
|
+
| 自选间隔 | 运行一次后用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
|
|
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",当前回合结束后作为新的一轮开始(不会混进正在进行的回合) | 自动 |
|
|
26
33
|
| 漏跑合并 | agent 忙时到点只记一次 pending,空闲后发一条 | 自动 |
|
|
27
34
|
| 兜底心跳 | 模型忘记调度时 arm 一次 20 分钟心跳,预算 1 次 | 自动 |
|
|
28
35
|
| Esc 取消 | 打断属于某个自定步调 loop 的回合(它的 tick、它的 Monitor 唤醒的回合)时取消该 loop;无关回合、空闲时不受影响;固定间隔不受影响 | Esc |
|
|
29
36
|
| 错峰 | 周期任务按 id 固定延后最多半个周期(≤30 分钟),间隔本身不变;`:00`/`:30` 的一次性任务最多提前 90 秒 | 自动 |
|
|
30
37
|
| 过期 | 周期任务 7 天后最后执行一次再删除;一次性任务不过期 | 自动 |
|
|
31
|
-
| 次数上限 | `--max N
|
|
32
|
-
| 持久化 |
|
|
33
|
-
|
|
|
38
|
+
| 次数上限 | `maxFires`,到顶自动删除 | `--max N` |
|
|
39
|
+
| 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json`,重启后继续;同一项目只由一个 Pi 会话执行 | `--durable` |
|
|
40
|
+
| 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/loop footer on\|off` |
|
|
41
|
+
| 界面设置 | 本地命令,不进对话 | `/loop settings ...` |
|
|
34
42
|
|
|
35
43
|
## 命令
|
|
36
44
|
|
|
37
45
|
```text
|
|
38
|
-
/loop
|
|
39
|
-
/loop <prompt>
|
|
40
|
-
/loop [间隔]
|
|
41
|
-
/loop status
|
|
46
|
+
/loop <间隔> <prompt> 固定间隔重复(模型用 cron_create)
|
|
47
|
+
/loop <prompt> 自选间隔(模型用 schedule_wakeup)
|
|
48
|
+
/loop [间隔] 跑 loop.md,没有就跑内置维护 prompt
|
|
49
|
+
/loop status 列出当前任务
|
|
42
50
|
/loop stop [id] 停止一个或全部
|
|
43
|
-
/loop
|
|
44
|
-
/loop settings ...
|
|
45
|
-
/loop help
|
|
51
|
+
/loop footer on|off 本地:底部摘要
|
|
52
|
+
/loop settings ... 本地:界面设置
|
|
53
|
+
/loop help 本地:帮助
|
|
46
54
|
```
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
agent 忙时输入 `/loop`,会等当前回合结束再开始。
|
|
56
|
+
前五个会作为普通 user 消息进入对话(内容就是你输入的那行),模型再按技能说明调用工具;后三个只改界面,不进对话。
|
|
51
57
|
|
|
52
|
-
|
|
58
|
+
间隔写法没有约束,模型自己解析:`30s`、`5m`、`2h`、`1d`、`check the deploy every 20m`、
|
|
59
|
+
`每2分钟输出一次`、`每分钟打印一次hi` 都可以。小于一分钟会向上取整到每分钟。
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
- `check the deploy every 20m`、`every 5 min`、`every 2 hrs`
|
|
56
|
-
- 秒向上取整到分钟;不整除的间隔按 CC 规则取整并提示:`7m` → `*/6`,`30s` → 每分钟
|
|
57
|
-
- 其他语言(如「每30秒」)交给模型解析
|
|
61
|
+
可以在请求里加 `--durable`(写入 `.pi/scheduled_tasks.json`,重启后继续)和 `--max N`(触发 N 次后自动删除)。
|
|
58
62
|
|
|
59
63
|
## 工具
|
|
60
64
|
|
|
61
|
-
| 工具 | 说明 |
|
|
62
|
-
| --- | --- |
|
|
63
|
-
| `cron_create` |
|
|
64
|
-
| `cron_delete` | 按 id
|
|
65
|
-
| `cron_list` |
|
|
66
|
-
| `schedule_wakeup` |
|
|
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` 结束 |
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
## 渲染
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
- `reason`:一句原因
|
|
72
|
-
- `prompt`:原样传回 `/loop` 的 prompt;裸 `/loop` 传 `<<loop.md>>`
|
|
73
|
-
- `stop`:结束当前自选间隔 loop
|
|
74
|
+
定时触发只显示任务本身,给模型看的信封不上屏:
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
```text
|
|
77
|
+
✻ Running scheduled task · 9:52pm
|
|
78
|
+
print hi
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
✻ loop #3 · 9:53pm · watching CI run
|
|
81
|
+
check the deploy
|
|
82
|
+
```
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
- Pi 没运行时错过的 durable 一次性任务会被移除,下一轮先问你要不要补跑
|
|
83
|
-
- 从别的目录拷来的任务只列出、不执行;`.pi` 或任务文件是符号链接时拒绝写入
|
|
84
|
-
- 恢复会话:会话内任务照常恢复,但过期的周期任务、已过时间的一次性任务除外,错过的运行不补跑;自定步调 loop 不恢复(需重新 `/loop`),`/reload` 时保留
|
|
84
|
+
固定间隔按"定时任务"呈现;只有自选间隔叫 loop,表头带上模型给的 `reason`。折叠时正文最多 3 行,
|
|
85
|
+
Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages` 关闭时回到旧的带底色卡片。
|
|
85
86
|
|
|
86
87
|
## 设置
|
|
87
88
|
|
|
88
|
-
两个 pi-cc
|
|
89
|
+
两个 pi-cc 扩展共用 `~/.pi/agent/pi-cc.json`,文件不存在时用默认值:
|
|
89
90
|
|
|
90
91
|
```jsonc
|
|
91
92
|
{
|
|
92
|
-
"compactMessages": true,
|
|
93
|
-
"
|
|
94
|
-
"managePiCcExtensions": true // 自动维护 pi-cc-extensions 的 excludeRenderers
|
|
93
|
+
"compactMessages": true, // 触发块和 Monitor 事件用上面的样式;false 为旧的带底色卡片
|
|
94
|
+
"footer": true // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
95
95
|
}
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
用 `/loop settings messages on|off`、`/loop settings footer on|off`(或 `/loop footer on|off`)切换,两个扩展同时生效。
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
/loop settings tools on|off
|
|
104
|
-
```
|
|
100
|
+
## 底部摘要
|
|
101
|
+
|
|
102
|
+
`ctx.ui.setStatus("loop", …)` 是 Pi 核心 API,不依赖任何其他扩展:
|
|
105
103
|
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
| 场景 | 显示 |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| 没有任务 | 不设置状态,footer 不会被多占一行 |
|
|
107
|
+
| 有任务 | `loop 3 · next 4m`(数量 + 最近一次触发) |
|
|
108
|
+
| 一分钟内 | `loop 1 · next <1m` |
|
|
109
|
+
| 超过一小时 | `loop 1 · next 2h 15m` |
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
- 倒计时取整到分钟,配合"文本相同就不调用"的去重,最多每分钟重绘一次(Pi 每次 `setStatus` 都会触发重绘)
|
|
112
|
+
- 装了 `pi-cc-extensions`:这一行会作为它的一个 chip 出现在第 2 行,可在它的配置面板里移动或隐藏;它会把 ANSI 去掉,所以这里只用纯文本
|
|
113
|
+
- 没装 `pi-cc-extensions`:Pi 内置 footer 会把它和其他扩展的状态拼成一行,加在用量行下面,只在有任务时出现
|
|
114
|
+
- 装了 `pi-cc-monitor`:运行中的 monitor 另有一项 `monitor 2`,和这一行用同一个开关
|
|
111
115
|
|
|
112
116
|
## 环境变量
|
|
113
117
|
|
|
114
118
|
| 变量 | 默认 | 说明 |
|
|
115
119
|
| --- | --- | --- |
|
|
116
|
-
| `PI_LOOP_MAX_AGE_DAYS` | `7` |
|
|
120
|
+
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 周期任务最长存活天数,`0` 不过期 |
|
|
117
121
|
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
118
122
|
| `PI_LOOP_JITTER` | `0.5` | 周期任务最多延后的周期比例;`0` 关闭错峰 |
|
|
119
123
|
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
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` | 设置文件路径 |
|
|
120
128
|
|
|
121
129
|
## 兼容性
|
|
122
130
|
|
|
123
131
|
- Node.js `>=22.19.0`
|
|
124
|
-
- Pi
|
|
125
|
-
-
|
|
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` 可以删掉
|
|
126
136
|
|
|
127
137
|
## 本地开发
|
|
128
138
|
|
|
129
139
|
```bash
|
|
130
140
|
node --test tests/*.test.mjs
|
|
131
141
|
npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
142
|
+
node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免费模型,发版前跑
|
|
132
143
|
```
|
|
133
144
|
|
|
134
|
-
|
|
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 实现。有意不同的地方:
|
|
135
160
|
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
140
|
-
-
|
|
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`。
|
|
141
166
|
- Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
|
|
167
|
+
- 没有做 CC 的 `noop` 折叠、触发时"不是用户实时输入"的安全提示、间隔边界提示和云端调度。
|
|
168
|
+
- 没有做 CC 针对 5 分钟提示缓存的两处调整(`*/5` 每次提前 15 秒、接近 300 秒的唤醒往回拉)。在 5 分钟缓存的 provider 上(比如 Anthropic 默认),`*/5` 的触发和 300 秒左右的唤醒可能刚好错过缓存。
|
|
142
169
|
|
|
143
170
|
## 许可证
|
|
144
171
|
|
package/config.ts
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
* ~/.pi/agent/pi-cc.json
|
|
9
9
|
* {
|
|
10
10
|
* "compactMessages": true,
|
|
11
|
-
* "
|
|
11
|
+
* "footer": true
|
|
12
12
|
* }
|
|
13
13
|
*
|
|
14
14
|
* Environment overrides (useful for tests and quick experiments):
|
|
15
15
|
* PI_CC_CONFIG_FILE=/path/to/pi-cc.json
|
|
16
16
|
* PI_CC_COMPACT_MESSAGES=0|1
|
|
17
|
-
*
|
|
17
|
+
* PI_CC_FOOTER=0|1
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import fs from "node:fs";
|
|
@@ -24,22 +24,16 @@ import path from "node:path";
|
|
|
24
24
|
export interface PiCcConfig {
|
|
25
25
|
/** Two-line marker blocks instead of the padded card. */
|
|
26
26
|
compactMessages: boolean;
|
|
27
|
-
/** One-line tool results instead of Pi's "N lines returned" preview. */
|
|
28
|
-
compactToolRows: boolean;
|
|
29
27
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* removed again when the setting is turned off.
|
|
28
|
+
* Show a one-line summary in Pi's footer (loops armed, monitors running).
|
|
29
|
+
* Shared so one switch silences both packages.
|
|
33
30
|
*/
|
|
34
|
-
|
|
35
|
-
/** Internal: names this extension added to pi-cc-extensions.json. */
|
|
36
|
-
managedExcludes?: string[];
|
|
31
|
+
footer: boolean;
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
export const DEFAULT_CONFIG: PiCcConfig = {
|
|
40
35
|
compactMessages: true,
|
|
41
|
-
|
|
42
|
-
managePiCcExtensions: true,
|
|
36
|
+
footer: true,
|
|
43
37
|
};
|
|
44
38
|
|
|
45
39
|
function envBool(name: string): boolean | undefined {
|
|
@@ -69,17 +63,8 @@ function readConfig(): PiCcConfig {
|
|
|
69
63
|
compactMessages:
|
|
70
64
|
envBool("PI_CC_COMPACT_MESSAGES") ??
|
|
71
65
|
(typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
|
|
72
|
-
|
|
73
|
-
envBool("
|
|
74
|
-
(typeof file.compactToolRows === "boolean" ? file.compactToolRows : DEFAULT_CONFIG.compactToolRows),
|
|
75
|
-
managePiCcExtensions:
|
|
76
|
-
envBool("PI_CC_MANAGE_CCSTYLE") ??
|
|
77
|
-
(typeof file.managePiCcExtensions === "boolean"
|
|
78
|
-
? file.managePiCcExtensions
|
|
79
|
-
: DEFAULT_CONFIG.managePiCcExtensions),
|
|
80
|
-
managedExcludes: Array.isArray(file.managedExcludes)
|
|
81
|
-
? file.managedExcludes.filter((name): name is string => typeof name === "string")
|
|
82
|
-
: [],
|
|
66
|
+
footer:
|
|
67
|
+
envBool("PI_CC_FOOTER") ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
|
|
83
68
|
};
|
|
84
69
|
}
|
|
85
70
|
|
|
@@ -101,73 +86,17 @@ export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
|
101
86
|
const next = { ...loadConfig(), ...patch };
|
|
102
87
|
const file = configPath();
|
|
103
88
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fs.renameSync(temp, file);
|
|
107
|
-
cached = next;
|
|
108
|
-
return next;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* pi-cc-extensions' config file. That extension loads its config once at load
|
|
113
|
-
* time, so changes made here apply after `/reload`.
|
|
114
|
-
*/
|
|
115
|
-
export function piCcExtensionsConfigPath(): string {
|
|
116
|
-
const agentDir =
|
|
117
|
-
process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
118
|
-
return process.env.PI_CC_EXTENSIONS_CONFIG_FILE ?? path.join(agentDir, "pi-cc-extensions.json");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Add or remove our tool names in pi-cc-extensions' `excludeRenderers`.
|
|
123
|
-
*
|
|
124
|
-
* Returns true when the file changed (the caller can tell the user to reload).
|
|
125
|
-
* Only names this extension added are removed again; a name the user put there
|
|
126
|
-
* themselves is left alone.
|
|
127
|
-
*/
|
|
128
|
-
export function syncPiCcExtensionsExcludes(toolNames: readonly string[], enabled: boolean): boolean {
|
|
129
|
-
// Another package may have written managedExcludes since our last read.
|
|
130
|
-
invalidateConfig();
|
|
131
|
-
const config = loadConfig();
|
|
132
|
-
if (!config.managePiCcExtensions) return false;
|
|
133
|
-
const file = piCcExtensionsConfigPath();
|
|
134
|
-
if (!fs.existsSync(file)) return false;
|
|
135
|
-
|
|
136
|
-
let raw: Record<string, unknown>;
|
|
89
|
+
// Keep keys this version does not know about: the file is the user's.
|
|
90
|
+
let raw: Record<string, unknown> = {};
|
|
137
91
|
try {
|
|
138
92
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
139
|
-
if (
|
|
140
|
-
raw = parsed as Record<string, unknown>;
|
|
93
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
|
|
141
94
|
} catch {
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const current = Array.isArray(raw.excludeRenderers)
|
|
146
|
-
? raw.excludeRenderers.filter((name): name is string => typeof name === "string")
|
|
147
|
-
: [];
|
|
148
|
-
const managed = new Set(config.managedExcludes ?? []);
|
|
149
|
-
const next = new Set(current);
|
|
150
|
-
let changed = false;
|
|
151
|
-
|
|
152
|
-
for (const name of toolNames) {
|
|
153
|
-
if (enabled) {
|
|
154
|
-
if (!next.has(name)) {
|
|
155
|
-
next.add(name);
|
|
156
|
-
managed.add(name);
|
|
157
|
-
changed = true;
|
|
158
|
-
}
|
|
159
|
-
} else if (managed.has(name)) {
|
|
160
|
-
if (next.delete(name)) changed = true;
|
|
161
|
-
managed.delete(name);
|
|
162
|
-
}
|
|
95
|
+
// A missing or malformed file is rewritten from scratch.
|
|
163
96
|
}
|
|
164
|
-
|
|
165
|
-
if (!changed && managed.size === (config.managedExcludes ?? []).length) return false;
|
|
166
|
-
|
|
167
|
-
raw.excludeRenderers = [...next].sort((a, b) => a.localeCompare(b));
|
|
168
97
|
const temp = `${file}.tmp`;
|
|
169
|
-
fs.writeFileSync(temp, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
98
|
+
fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...next }, null, 2)}\n`, "utf8");
|
|
170
99
|
fs.renameSync(temp, file);
|
|
171
|
-
|
|
172
|
-
return
|
|
100
|
+
cached = next;
|
|
101
|
+
return next;
|
|
173
102
|
}
|
package/cron.ts
CHANGED
|
@@ -154,36 +154,6 @@ export function isUnreachable(expression: string): boolean {
|
|
|
154
154
|
return nextFireAfter(fields, new Date(), 366) === null;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export interface IntervalSpec {
|
|
158
|
-
/** Number of seconds the schedule actually uses. */
|
|
159
|
-
seconds: number;
|
|
160
|
-
/** Cron expression that implements the interval. */
|
|
161
|
-
cron: string;
|
|
162
|
-
/** Human readable cadence, e.g. "every 5 minutes". */
|
|
163
|
-
human: string;
|
|
164
|
-
/** True when the requested interval had to be adjusted. */
|
|
165
|
-
adjusted: boolean;
|
|
166
|
-
/** What the user asked for, in seconds. */
|
|
167
|
-
requestedSeconds: number;
|
|
168
|
-
/** Human readable explanation of the adjustment, for the user. */
|
|
169
|
-
note?: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const UNIT_SECONDS: Record<string, number> = {
|
|
173
|
-
s: 1,
|
|
174
|
-
m: 60,
|
|
175
|
-
h: 3600,
|
|
176
|
-
d: 86400,
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/** The unit words Claude Code's /loop accepts in a trailing "every N <unit>" clause. */
|
|
180
|
-
const UNIT_WORDS: Record<string, string[]> = {
|
|
181
|
-
s: ["s", "sec", "secs", "second", "seconds"],
|
|
182
|
-
m: ["m", "min", "mins", "minute", "minutes"],
|
|
183
|
-
h: ["h", "hr", "hrs", "hour", "hours"],
|
|
184
|
-
d: ["d", "day", "days"],
|
|
185
|
-
};
|
|
186
|
-
|
|
187
157
|
/**
|
|
188
158
|
* The divisor of `total` closest to `n`.
|
|
189
159
|
*
|
|
@@ -205,100 +175,9 @@ function nearestDivisor(n: number, total: number): number {
|
|
|
205
175
|
return best;
|
|
206
176
|
}
|
|
207
177
|
|
|
208
|
-
/**
|
|
209
|
-
* Convert a duration into a cron expression, following Claude Code's rule:
|
|
210
|
-
*
|
|
211
|
-
* "Supported suffixes: `s` -> ceil to nearest minute, `m` (minutes),
|
|
212
|
-
* `h` (hours), `d` (days). Examples: `5m` -> a 5 minute step, `1h` -> `0 * * * *`,
|
|
213
|
-
* `1d` -> `0 0 * * *`. If the interval doesn't cleanly divide its unit, round to the
|
|
214
|
-
* nearest clean interval and tell the user what you rounded to."
|
|
215
|
-
*/
|
|
216
|
-
export function intervalToCron(seconds: number): IntervalSpec | null {
|
|
217
|
-
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
|
218
|
-
|
|
219
|
-
// Sub-minute: the scheduler's granularity is one minute, so round up.
|
|
220
|
-
if (seconds < 60) {
|
|
221
|
-
return {
|
|
222
|
-
seconds: 60,
|
|
223
|
-
cron: "* * * * *",
|
|
224
|
-
human: "every minute",
|
|
225
|
-
adjusted: true,
|
|
226
|
-
requestedSeconds: seconds,
|
|
227
|
-
note: `${seconds}s rounded up to 1 minute (the scheduler's minimum granularity is 1 minute)`,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// "Seconds are rounded up to the nearest minute since cron has one-minute
|
|
232
|
-
// granularity."
|
|
233
|
-
if (seconds < 3600 && Math.ceil(seconds / 60) < 60) {
|
|
234
|
-
const requested = Math.ceil(seconds / 60);
|
|
235
|
-
const minutes = nearestDivisor(requested, 60);
|
|
236
|
-
return {
|
|
237
|
-
seconds: minutes * 60,
|
|
238
|
-
cron: `*/${minutes} * * * *`,
|
|
239
|
-
human: `every ${minutes} minute${minutes === 1 ? "" : "s"}`,
|
|
240
|
-
adjusted: minutes !== requested,
|
|
241
|
-
requestedSeconds: seconds,
|
|
242
|
-
...(minutes !== requested
|
|
243
|
-
? { note: `${requested}m rounded to ${minutes}m, the nearest clean interval` }
|
|
244
|
-
: {}),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (seconds < 86400) {
|
|
249
|
-
const requested = Math.max(1, Math.round(seconds / 3600));
|
|
250
|
-
const hours = nearestDivisor(requested, 24);
|
|
251
|
-
return {
|
|
252
|
-
seconds: hours * 3600,
|
|
253
|
-
cron: hours === 1 ? "0 * * * *" : `0 */${hours} * * *`,
|
|
254
|
-
human: `every ${hours} hour${hours === 1 ? "" : "s"}`,
|
|
255
|
-
adjusted: hours !== requested,
|
|
256
|
-
requestedSeconds: seconds,
|
|
257
|
-
...(hours !== requested
|
|
258
|
-
? { note: `${requested}h rounded to ${hours}h, the nearest clean interval` }
|
|
259
|
-
: {}),
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const days = Math.max(1, Math.round(seconds / 86400));
|
|
264
|
-
return {
|
|
265
|
-
seconds: days * 86400,
|
|
266
|
-
cron: days === 1 ? "0 0 * * *" : `0 0 */${days} * *`,
|
|
267
|
-
human: days === 1 ? "every day" : `every ${days} days`,
|
|
268
|
-
adjusted: days * 86400 !== seconds,
|
|
269
|
-
requestedSeconds: seconds,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Parse a duration token such as `5m`, `30s`, `2h`, `1d`. */
|
|
274
|
-
export function parseIntervalToken(token: string): number | null {
|
|
275
|
-
const m = /^(\d+)\s*([smhd])$/i.exec(token.trim());
|
|
276
|
-
if (!m) return null;
|
|
277
|
-
const unit = m[2].toLowerCase();
|
|
278
|
-
const amount = Number(m[1]);
|
|
279
|
-
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
280
|
-
return amount * UNIT_SECONDS[unit];
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Parse a trailing `every 20m` / `every 5 min` / `every 2 hours` clause, with
|
|
285
|
-
* the unit words Claude Code accepts.
|
|
286
|
-
*/
|
|
287
|
-
export function parseEveryClause(input: string): { interval: number; prompt: string } | null {
|
|
288
|
-
const m = /\bevery\s+(\d+)\s*([a-z]+)\s*$/i.exec(input.trim());
|
|
289
|
-
if (!m) return null;
|
|
290
|
-
const amount = Number(m[1]);
|
|
291
|
-
if (!(amount > 0)) return null;
|
|
292
|
-
const word = m[2].toLowerCase();
|
|
293
|
-
const unit = Object.keys(UNIT_WORDS).find((key) => UNIT_WORDS[key].includes(word));
|
|
294
|
-
if (!unit) return null;
|
|
295
|
-
const unitSeconds = UNIT_SECONDS[unit];
|
|
296
|
-
const prompt = input.trim().slice(0, m.index).trim();
|
|
297
|
-
return { interval: amount * unitSeconds, prompt };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
178
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
301
179
|
|
|
180
|
+
|
|
302
181
|
/**
|
|
303
182
|
* A human readable cadence for a cron expression, with Claude Code's labels:
|
|
304
183
|
* "Every minute", "Every 5 minutes", "Every hour at :07", "Every 2 hours",
|