pi-cc-loop 0.1.1 → 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 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 使用 .pi/loop.md 或内置维护 prompt
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
- - 不整除的间隔按 CC 规则取整并提示:`7m` → `*/6`,`30s` → 每分钟
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` | 抖动比例 0–1 |
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
- - `/loop stop` 代替 CC 的 Esc;状态条默认关闭,和 CC 主会话一致。
137
+ - Esc 只取消被打断回合所属的自定步调 loop;CC 在任何时候按 Esc(包括空闲、打断无关回合)都会取消所有自定步调 loop。其他情况用 `/loop stop`。
138
+ - 状态条默认关闭,和 CC 主会话一致。
121
139
  - `cron_create` 的 `maxFires` 是额外加固;CC 没有次数上限。
122
- - `/loop stop` 会连带停掉该 chain 绑定的 Monitor。
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
- const UNIT_NAMES: Record<string, [string, string]> = {
180
- s: ["second", "seconds"],
181
- m: ["minute", "minutes"],
182
- h: ["hour", "hours"],
183
- d: ["day", "days"],
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
- if (seconds < 3600) {
231
- const requested = Math.round(seconds / 60);
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
- /** Parse `every 20m` / `every 5 minutes` / `every 2 hours` style clauses. */
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
- let unitSeconds: number | undefined;
287
- for (const [key, names] of Object.entries(UNIT_NAMES)) {
288
- if (names[0] === word || names[1] === word) {
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, the way Claude Code
306
- * reports it ("Every minute", "Every 5 minutes", "Daily at 9:00 AM").
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 = parseCron(expression);
310
- if (!fields) return expression;
311
- const [minute, hour, dom, month, dow] = expression.trim().split(/\s+/);
312
- const everyMinute = minute === "*" && hour === "*" && dom === "*" && month === "*" && dow === "*";
313
- if (everyMinute) return "Every minute";
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 step = /^\*\/(\d+)$/.exec(minute);
316
- if (step && hour === "*" && dom === "*" && month === "*" && dow === "*") {
317
- const n = Number(step[1]);
318
- return n === 1 ? "Every minute" : `Every ${n} minutes`;
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
- const clock = clockText(minute, hour);
322
- if (dom === "*" && month === "*" && dow === "*") {
323
- if (hour === "*") return `Every hour at :${minute.padStart(2, "0")}`;
324
- if (minute === "0") return `Hourly`;
325
- return `Daily at ${clock}`;
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
- if (month === "*" && dow === "*") return `On day ${dom} at ${clock}`;
328
- if (month === "*") {
329
- const days = dow
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 for the minute and hour fields. */
339
- function clockText(minute: string, hour: string): string {
340
- if (hour === "*") return `:${minute.padStart(2, "0")}`;
341
- const h = Number(hour);
342
- const suffix = h < 12 ? "AM" : "PM";
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. */