pi-cc-loop 0.1.0 → 0.2.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 +27 -8
- package/cron.ts +62 -51
- package/durable.ts +447 -0
- package/index.ts +794 -1027
- package/loopfile.ts +107 -0
- package/package.json +2 -6
- package/prompts.ts +200 -0
- package/render.ts +11 -8
- package/renderers.ts +147 -0
- package/schedule.ts +147 -169
- package/tool-rows.ts +79 -0
package/README.md
CHANGED
|
@@ -21,11 +21,15 @@ pi install npm:pi-cc-loop
|
|
|
21
21
|
| 固定间隔 | 5 字段 cron;`30s`、`5m`、`2h`、`1d`;秒向上取整到分钟 | `/loop 5m <prompt>` |
|
|
22
22
|
| 自选间隔 | 模型每轮用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
|
|
23
23
|
| 自然语言间隔 | 命令层认不出时交给模型解析(中英文均可) | `/loop 每30秒 ...` |
|
|
24
|
+
| loop.md | 不带 prompt 时运行 `.pi/loop.md`(每次触发重读,改了下一轮生效),没有则跑内置维护 prompt | `/loop`、`/loop 15m` |
|
|
24
25
|
| 立即执行 | 创建后马上跑一次,不等首个 cron | 自动 |
|
|
25
26
|
| 漏跑合并 | agent 忙时到点只记一次 pending,空闲后发一条 | 自动 |
|
|
26
27
|
| 兜底心跳 | 模型忘记调度时 arm 一次 20 分钟心跳,预算 1 次 | 自动 |
|
|
28
|
+
| Esc 取消 | 打断属于某个自定步调 loop 的回合(它的 tick、它的 Monitor 唤醒的回合)时取消该 loop;无关回合、空闲时不受影响;固定间隔不受影响 | Esc |
|
|
29
|
+
| 错峰 | 周期任务按 id 固定延后最多半个周期(≤30 分钟),间隔本身不变;`:00`/`:30` 的一次性任务最多提前 90 秒 | 自动 |
|
|
30
|
+
| 过期 | 周期任务 7 天后最后执行一次再删除;一次性任务不过期 | 自动 |
|
|
27
31
|
| 次数上限 | `--max N`,到顶自动删除 | 标志 |
|
|
28
|
-
| 持久化 | `--durable` 写入 `.pi/scheduled_tasks.json
|
|
32
|
+
| 持久化 | `--durable` 写入 `.pi/scheduled_tasks.json`;同一项目只由一个 Pi 会话执行 | 标志 |
|
|
29
33
|
| 状态条 | 输入框上方状态行,默认关闭 | `/loop widget on\|off` |
|
|
30
34
|
|
|
31
35
|
## 命令
|
|
@@ -33,7 +37,7 @@ pi install npm:pi-cc-loop
|
|
|
33
37
|
```text
|
|
34
38
|
/loop [间隔] <prompt> 按间隔重复跑
|
|
35
39
|
/loop <prompt> 间隔交给模型判断
|
|
36
|
-
/loop
|
|
40
|
+
/loop [间隔] 运行 .pi/loop.md 或内置维护 prompt(不带间隔则自定步调)
|
|
37
41
|
/loop status 列出当前 loop
|
|
38
42
|
/loop stop [id] 停止一个或全部
|
|
39
43
|
/loop widget on|off 状态条
|
|
@@ -43,11 +47,13 @@ pi install npm:pi-cc-loop
|
|
|
43
47
|
|
|
44
48
|
标志:`--durable` 写入 `.pi/scheduled_tasks.json`;`--max N` 触发 N 次后自动停止。
|
|
45
49
|
|
|
50
|
+
agent 忙时输入 `/loop`,会等当前回合结束再开始。
|
|
51
|
+
|
|
46
52
|
间隔写法:
|
|
47
53
|
|
|
48
54
|
- `30s`、`5m`、`2h`、`1d`
|
|
49
|
-
- `check the deploy every 20m`
|
|
50
|
-
-
|
|
55
|
+
- `check the deploy every 20m`、`every 5 min`、`every 2 hrs`
|
|
56
|
+
- 秒向上取整到分钟;不整除的间隔按 CC 规则取整并提示:`7m` → `*/6`,`30s` → 每分钟
|
|
51
57
|
- 其他语言(如「每30秒」)交给模型解析
|
|
52
58
|
|
|
53
59
|
## 工具
|
|
@@ -63,9 +69,20 @@ pi install npm:pi-cc-loop
|
|
|
63
69
|
|
|
64
70
|
- `delaySeconds`:60–3600,越界自动钳制
|
|
65
71
|
- `reason`:一句原因
|
|
66
|
-
- `prompt`:原样传回 `/loop` 的 prompt
|
|
72
|
+
- `prompt`:原样传回 `/loop` 的 prompt;裸 `/loop` 传 `<<loop.md>>`
|
|
67
73
|
- `stop`:结束当前自选间隔 loop
|
|
68
74
|
|
|
75
|
+
`schedule_wakeup` 调用后本回合结束(工具批次里只有它时),所以模型会先写进度再调用;
|
|
76
|
+
延迟按你给的值使用(四舍五入到整秒),只有超出 60–3600 秒才会被 clamp 并提示。CC 会把 `*/5` 任务提前 15 秒、把 286–300 秒的延迟压到 285 秒来保温 5 分钟的 prompt 缓存,我们没有跟:现在的缓存能活几十分钟到几小时,这样改只会让间隔变成 4 分 45 秒。
|
|
77
|
+
|
|
78
|
+
## 持久化与恢复
|
|
79
|
+
|
|
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` 时保留
|
|
85
|
+
|
|
69
86
|
## 设置
|
|
70
87
|
|
|
71
88
|
两个 pi-cc 扩展共享 `~/.pi/agent/pi-cc.json`:
|
|
@@ -98,7 +115,7 @@ pi install npm:pi-cc-loop
|
|
|
98
115
|
| --- | --- | --- |
|
|
99
116
|
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 任务最长存活天数,`0` 不过期 |
|
|
100
117
|
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
101
|
-
| `PI_LOOP_JITTER` | `0` |
|
|
118
|
+
| `PI_LOOP_JITTER` | `0.5` | 周期任务最多延后的周期比例;`0` 关闭错峰 |
|
|
102
119
|
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
103
120
|
|
|
104
121
|
## 兼容性
|
|
@@ -117,9 +134,11 @@ npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
|
117
134
|
## 说明
|
|
118
135
|
|
|
119
136
|
- 行为对齐 Claude Code v2.1.306 的 `/loop`,用 Pi 扩展 API 实现。
|
|
120
|
-
-
|
|
137
|
+
- Esc 只取消被打断回合所属的自定步调 loop;CC 在任何时候按 Esc(包括空闲、打断无关回合)都会取消所有自定步调 loop。其他情况用 `/loop stop`。
|
|
138
|
+
- 状态条默认关闭,和 CC 主会话一致。
|
|
121
139
|
- `cron_create` 的 `maxFires` 是额外加固;CC 没有次数上限。
|
|
122
|
-
- `/loop stop`
|
|
140
|
+
- `/loop stop` 会连带静默停掉该 chain 绑定的 Monitor。
|
|
141
|
+
- Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
|
|
123
142
|
|
|
124
143
|
## 许可证
|
|
125
144
|
|
package/cron.ts
CHANGED
|
@@ -176,11 +176,12 @@ const UNIT_SECONDS: Record<string, number> = {
|
|
|
176
176
|
d: 86400,
|
|
177
177
|
};
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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"],
|
|
184
185
|
};
|
|
185
186
|
|
|
186
187
|
/**
|
|
@@ -227,8 +228,10 @@ export function intervalToCron(seconds: number): IntervalSpec | null {
|
|
|
227
228
|
};
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
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);
|
|
232
235
|
const minutes = nearestDivisor(requested, 60);
|
|
233
236
|
return {
|
|
234
237
|
seconds: minutes * 60,
|
|
@@ -243,7 +246,7 @@ export function intervalToCron(seconds: number): IntervalSpec | null {
|
|
|
243
246
|
}
|
|
244
247
|
|
|
245
248
|
if (seconds < 86400) {
|
|
246
|
-
const requested = Math.round(seconds / 3600);
|
|
249
|
+
const requested = Math.max(1, Math.round(seconds / 3600));
|
|
247
250
|
const hours = nearestDivisor(requested, 24);
|
|
248
251
|
return {
|
|
249
252
|
seconds: hours * 3600,
|
|
@@ -277,24 +280,19 @@ export function parseIntervalToken(token: string): number | null {
|
|
|
277
280
|
return amount * UNIT_SECONDS[unit];
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* Parse a trailing `every 20m` / `every 5 min` / `every 2 hours` clause, with
|
|
285
|
+
* the unit words Claude Code accepts.
|
|
286
|
+
*/
|
|
281
287
|
export function parseEveryClause(input: string): { interval: number; prompt: string } | null {
|
|
282
288
|
const m = /\bevery\s+(\d+)\s*([a-z]+)\s*$/i.exec(input.trim());
|
|
283
289
|
if (!m) return null;
|
|
284
290
|
const amount = Number(m[1]);
|
|
291
|
+
if (!(amount > 0)) return null;
|
|
285
292
|
const word = m[2].toLowerCase();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
unitSeconds = UNIT_SECONDS[key];
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
// Single letter units, e.g. "every 20m".
|
|
294
|
-
if (unitSeconds === undefined && word.length === 1 && word in UNIT_SECONDS) {
|
|
295
|
-
unitSeconds = UNIT_SECONDS[word];
|
|
296
|
-
}
|
|
297
|
-
if (unitSeconds === undefined) return null;
|
|
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];
|
|
298
296
|
const prompt = input.trim().slice(0, m.index).trim();
|
|
299
297
|
return { interval: amount * unitSeconds, prompt };
|
|
300
298
|
}
|
|
@@ -302,46 +300,59 @@ export function parseEveryClause(input: string): { interval: number; prompt: str
|
|
|
302
300
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
303
301
|
|
|
304
302
|
/**
|
|
305
|
-
* A human readable cadence for a cron expression,
|
|
306
|
-
*
|
|
303
|
+
* A human readable cadence for a cron expression, with Claude Code's labels:
|
|
304
|
+
* "Every minute", "Every 5 minutes", "Every hour at :07", "Every 2 hours",
|
|
305
|
+
* "Every day at 9:00 AM", "Every Sunday at 9:00 AM", "Weekdays at 9:00 AM".
|
|
306
|
+
*
|
|
307
|
+
* Like Claude Code, anything else is shown as written rather than as a
|
|
308
|
+
* half-filled sentence. The one addition is "Every N days at ...", which
|
|
309
|
+
* `/loop Nd` produces.
|
|
307
310
|
*/
|
|
308
311
|
export function humanSchedule(expression: string): string {
|
|
309
|
-
const fields =
|
|
310
|
-
if (!
|
|
311
|
-
const [minute, hour, dom, month, dow] =
|
|
312
|
-
const
|
|
313
|
-
|
|
312
|
+
const fields = expression.trim().split(/\s+/);
|
|
313
|
+
if (fields.length !== CRON_FIELD_COUNT || !parseCron(expression)) return expression;
|
|
314
|
+
const [minute, hour, dom, month, dow] = fields;
|
|
315
|
+
const isNumber = (value: string) => /^\d+$/.test(value);
|
|
316
|
+
const step = (value: string) => {
|
|
317
|
+
const match = /^\*\/(\d+)$/.exec(value);
|
|
318
|
+
return match ? Number(match[1]) : undefined;
|
|
319
|
+
};
|
|
320
|
+
const atMinute = (value: number) => `:${String(value).padStart(2, "0")}`;
|
|
321
|
+
const everyDay = dom === "*" && month === "*" && dow === "*";
|
|
322
|
+
|
|
323
|
+
if (hour === "*" && everyDay) {
|
|
324
|
+
if (minute === "*") return "Every minute";
|
|
325
|
+
const n = step(minute);
|
|
326
|
+
if (n !== undefined) return n === 1 ? "Every minute" : `Every ${n} minutes`;
|
|
327
|
+
if (isNumber(minute)) return Number(minute) === 0 ? "Every hour" : `Every hour at ${atMinute(Number(minute))}`;
|
|
328
|
+
return expression;
|
|
329
|
+
}
|
|
314
330
|
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
317
|
-
const
|
|
318
|
-
return
|
|
331
|
+
const hours = step(hour);
|
|
332
|
+
if (isNumber(minute) && hours !== undefined && everyDay) {
|
|
333
|
+
const at = Number(minute) === 0 ? "" : ` at ${atMinute(Number(minute))}`;
|
|
334
|
+
return hours === 1 ? `Every hour${at}` : `Every ${hours} hours${at}`;
|
|
319
335
|
}
|
|
320
336
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return `
|
|
337
|
+
if (!isNumber(minute) || !isNumber(hour)) return expression;
|
|
338
|
+
const clock = clockText(Number(minute), Number(hour));
|
|
339
|
+
if (everyDay) return `Every day at ${clock}`;
|
|
340
|
+
if (dom === "*" && month === "*") {
|
|
341
|
+
if (/^\d$/.test(dow)) return `Every ${DAY_NAMES[Number(dow) % 7]} at ${clock}`;
|
|
342
|
+
if (dow === "1-5") return `Weekdays at ${clock}`;
|
|
326
343
|
}
|
|
327
|
-
|
|
328
|
-
if (month === "*") {
|
|
329
|
-
|
|
330
|
-
.split(",")
|
|
331
|
-
.map((d) => DAY_NAMES[Number(d) % 7])
|
|
332
|
-
.join(", ");
|
|
333
|
-
return `Every ${days} at ${clock}`;
|
|
344
|
+
const days = step(dom);
|
|
345
|
+
if (days !== undefined && month === "*" && dow === "*") {
|
|
346
|
+
return days === 1 ? `Every day at ${clock}` : `Every ${days} days at ${clock}`;
|
|
334
347
|
}
|
|
335
348
|
return expression;
|
|
336
349
|
}
|
|
337
350
|
|
|
338
|
-
/** "9:05 AM" style time
|
|
339
|
-
function clockText(minute:
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
const display = h % 12 === 0 ? 12 : h % 12;
|
|
344
|
-
return `${display}:${minute.padStart(2, "0")} ${suffix}`;
|
|
351
|
+
/** "9:05 AM" style time. */
|
|
352
|
+
function clockText(minute: number, hour: number): string {
|
|
353
|
+
const suffix = hour < 12 ? "AM" : "PM";
|
|
354
|
+
const display = hour % 12 === 0 ? 12 : hour % 12;
|
|
355
|
+
return `${display}:${String(minute).padStart(2, "0")} ${suffix}`;
|
|
345
356
|
}
|
|
346
357
|
|
|
347
358
|
/** Human readable summary of a cron expression. */
|