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 CHANGED
@@ -1,144 +1,171 @@
1
1
  # pi-cc-loop
2
2
 
3
- > 在 Pi 里复刻 Claude Code 的 `/loop`:固定间隔 cron、自选间隔、兜底心跳和可选状态条。
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
- | 固定间隔 | 5 字段 cron;`30s`、`5m`、`2h`、`1d`;秒向上取整到分钟 | `/loop 5m <prompt>` |
22
- | 自选间隔 | 模型每轮用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
23
- | 自然语言间隔 | 命令层认不出时交给模型解析(中英文均可) | `/loop 每30秒 ...` |
24
- | loop.md | 不带 prompt 时运行 `.pi/loop.md`(每次触发重读,改了下一轮生效),没有则跑内置维护 prompt | `/loop`、`/loop 15m` |
25
- | 立即执行 | 创建后马上跑一次,不等首个 cron | 自动 |
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
- | 持久化 | `--durable` 写入 `.pi/scheduled_tasks.json`;同一项目只由一个 Pi 会话执行 | 标志 |
33
- | 状态条 | 输入框上方状态行,默认关闭 | `/loop widget on\|off` |
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 [间隔] <prompt> 按间隔重复跑
39
- /loop <prompt> 间隔交给模型判断
40
- /loop [间隔] 运行 .pi/loop.md 或内置维护 prompt(不带间隔则自定步调)
41
- /loop status 列出当前 loop
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 widget on|off 状态条
44
- /loop settings ... 界面设置
45
- /loop help 帮助
51
+ /loop footer on|off 本地:底部摘要
52
+ /loop settings ... 本地:界面设置
53
+ /loop help 本地:帮助
46
54
  ```
47
55
 
48
- 标志:`--durable` 写入 `.pi/scheduled_tasks.json`;`--max N` 触发 N 次后自动停止。
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
- - `30s`、`5m`、`2h`、`1d`
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` | 创建固定间隔任务;支持 `recurring`、`durable`、`maxFires` |
64
- | `cron_delete` | 按 id 取消 |
65
- | `cron_list` | 列出任务 |
66
- | `schedule_wakeup` | 自选间隔:安排下次唤醒,或 `stop: true` 结束 |
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
- `schedule_wakeup` 参数:
72
+ ## 渲染
69
73
 
70
- - `delaySeconds`:60–3600,越界自动钳制
71
- - `reason`:一句原因
72
- - `prompt`:原样传回 `/loop` 的 prompt;裸 `/loop` 传 `<<loop.md>>`
73
- - `stop`:结束当前自选间隔 loop
74
+ 定时触发只显示任务本身,给模型看的信封不上屏:
74
75
 
75
- `schedule_wakeup` 调用后本回合结束(工具批次里只有它时),所以模型会先写进度再调用;
76
- 延迟按你给的值使用(四舍五入到整秒),只有超出 60–3600 秒才会被 clamp 并提示。CC 会把 `*/5` 任务提前 15 秒、把 286–300 秒的延迟压到 285 秒来保温 5 分钟的 prompt 缓存,我们没有跟:现在的缓存能活几十分钟到几小时,这样改只会让间隔变成 4 分 45 秒。
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
- - `--durable` / `durable: true` 的任务存在 `.pi/scheduled_tasks.json`,并自动加入 `.git/info/exclude`
81
- - 同一项目只有持有 `.pi/scheduled_tasks.lock` 的 Pi 会话执行它们;该会话退出(或 60 秒没有心跳)后由其他会话接管
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 扩展共享 `~/.pi/agent/pi-cc.json`:
89
+ 两个 pi-cc 扩展共用 `~/.pi/agent/pi-cc.json`,文件不存在时用默认值:
89
90
 
90
91
  ```jsonc
91
92
  {
92
- "compactMessages": true, // 两行 marker / 旧的带底色卡片
93
- "compactToolRows": true, // 一行工具结果 / Pi 默认工具行
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
- ```text
101
- /loop settings
102
- /loop settings messages on|off
103
- /loop settings tools on|off
104
- ```
100
+ ## 底部摘要
101
+
102
+ `ctx.ui.setStatus("loop", …)` 是 Pi 核心 API,不依赖任何其他扩展:
105
103
 
106
- - 已画出的旧行需要 `/reload` 才会换样式
107
- - 环境变量覆盖:`PI_CC_COMPACT_MESSAGES=0`、`PI_CC_COMPACT_TOOL_ROWS=0`、`PI_CC_MANAGE_CCSTYLE=0`
104
+ | 场景 | 显示 |
105
+ | --- | --- |
106
+ | 没有任务 | 不设置状态,footer 不会被多占一行 |
107
+ | 有任务 | `loop 3 · next 4m`(数量 + 最近一次触发) |
108
+ | 一分钟内 | `loop 1 · next <1m` |
109
+ | 超过一小时 | `loop 1 · next 2h 15m` |
108
110
 
109
- > 如果装了 `pi-cc-extensions`,`tools on` 会把本扩展的工具名加入它的 `excludeRenderers`;
110
- > `tools off` 只移除自己加过的名字。它只在启动时读配置,切换后需要 `/reload`。
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` | 任务最长存活天数,`0` 不过期 |
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 `1.3.0`(开发环境)
125
- - 与 `pi-cc-extensions` 共存,见上面的设置说明
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
- - 行为对齐 Claude Code v2.1.306 的 `/loop`,用 Pi 扩展 API 实现。
137
- - Esc 只取消被打断回合所属的自定步调 loop;CC 在任何时候按 Esc(包括空闲、打断无关回合)都会取消所有自定步调 loop。其他情况用 `/loop stop`。
138
- - 状态条默认关闭,和 CC 主会话一致。
139
- - `cron_create` 的 `maxFires` 是额外加固;CC 没有次数上限。
140
- - `/loop stop` 会连带静默停掉该 chain 绑定的 Monitor。
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
- * "compactToolRows": true
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
- * PI_CC_COMPACT_TOOL_ROWS=0|1
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
- * Keep pi-cc tools out of pi-cc-extensions' global tool styling so the
31
- * compact rows above are visible. Only names this extension added are
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
- managePiCcExtensions: boolean;
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
- compactToolRows: true,
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
- compactToolRows:
73
- envBool("PI_CC_COMPACT_TOOL_ROWS") ??
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
- const temp = `${file}.tmp`;
105
- fs.writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
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 (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
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
- return false;
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
- saveConfig({ managedExcludes: [...managed].sort((a, b) => a.localeCompare(b)) });
172
- return changed;
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",