pi-cc-loop 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +125 -2
- package/config.ts +173 -0
- package/cron.ts +359 -0
- package/index.ts +1550 -0
- package/package.json +53 -5
- package/render.ts +146 -0
- package/schedule.ts +375 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lei Fu (tarnish233)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-cc-loop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 在 Pi 里复刻 Claude Code 的 `/loop`:固定间隔 cron、自选间隔、兜底心跳和可选状态条。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 本地开发:把仓库软链接到 Pi 扩展目录
|
|
9
|
+
ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
10
|
+
|
|
11
|
+
# 发布后
|
|
12
|
+
pi install npm:pi-cc-loop
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
安装后执行 `/reload`。
|
|
16
|
+
|
|
17
|
+
## 功能
|
|
18
|
+
|
|
19
|
+
| 功能 | 说明 | 入口 |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| 固定间隔 | 5 字段 cron;`30s`、`5m`、`2h`、`1d`;秒向上取整到分钟 | `/loop 5m <prompt>` |
|
|
22
|
+
| 自选间隔 | 模型每轮用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
|
|
23
|
+
| 自然语言间隔 | 命令层认不出时交给模型解析(中英文均可) | `/loop 每30秒 ...` |
|
|
24
|
+
| 立即执行 | 创建后马上跑一次,不等首个 cron | 自动 |
|
|
25
|
+
| 漏跑合并 | agent 忙时到点只记一次 pending,空闲后发一条 | 自动 |
|
|
26
|
+
| 兜底心跳 | 模型忘记调度时 arm 一次 20 分钟心跳,预算 1 次 | 自动 |
|
|
27
|
+
| 次数上限 | `--max N`,到顶自动删除 | 标志 |
|
|
28
|
+
| 持久化 | `--durable` 写入 `.pi/scheduled_tasks.json` | 标志 |
|
|
29
|
+
| 状态条 | 输入框上方状态行,默认关闭 | `/loop widget on\|off` |
|
|
30
|
+
|
|
31
|
+
## 命令
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/loop [间隔] <prompt> 按间隔重复跑
|
|
35
|
+
/loop <prompt> 间隔交给模型判断
|
|
36
|
+
/loop 使用 .pi/loop.md 或内置维护 prompt
|
|
37
|
+
/loop status 列出当前 loop
|
|
38
|
+
/loop stop [id] 停止一个或全部
|
|
39
|
+
/loop widget on|off 状态条
|
|
40
|
+
/loop settings ... 界面设置
|
|
41
|
+
/loop help 帮助
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
标志:`--durable` 写入 `.pi/scheduled_tasks.json`;`--max N` 触发 N 次后自动停止。
|
|
45
|
+
|
|
46
|
+
间隔写法:
|
|
47
|
+
|
|
48
|
+
- `30s`、`5m`、`2h`、`1d`
|
|
49
|
+
- `check the deploy every 20m`
|
|
50
|
+
- 不整除的间隔按 CC 规则取整并提示:`7m` → `*/6`,`30s` → 每分钟
|
|
51
|
+
- 其他语言(如「每30秒」)交给模型解析
|
|
52
|
+
|
|
53
|
+
## 工具
|
|
54
|
+
|
|
55
|
+
| 工具 | 说明 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `cron_create` | 创建固定间隔任务;支持 `recurring`、`durable`、`maxFires` |
|
|
58
|
+
| `cron_delete` | 按 id 取消 |
|
|
59
|
+
| `cron_list` | 列出任务 |
|
|
60
|
+
| `schedule_wakeup` | 自选间隔:安排下次唤醒,或 `stop: true` 结束 |
|
|
61
|
+
|
|
62
|
+
`schedule_wakeup` 参数:
|
|
63
|
+
|
|
64
|
+
- `delaySeconds`:60–3600,越界自动钳制
|
|
65
|
+
- `reason`:一句原因
|
|
66
|
+
- `prompt`:原样传回 `/loop` 的 prompt
|
|
67
|
+
- `stop`:结束当前自选间隔 loop
|
|
68
|
+
|
|
69
|
+
## 设置
|
|
70
|
+
|
|
71
|
+
两个 pi-cc 扩展共享 `~/.pi/agent/pi-cc.json`:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"compactMessages": true, // 两行 marker / 旧的带底色卡片
|
|
76
|
+
"compactToolRows": true, // 一行工具结果 / Pi 默认工具行
|
|
77
|
+
"managePiCcExtensions": true // 自动维护 pi-cc-extensions 的 excludeRenderers
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
用命令切换:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/loop settings
|
|
85
|
+
/loop settings messages on|off
|
|
86
|
+
/loop settings tools on|off
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- 已画出的旧行需要 `/reload` 才会换样式
|
|
90
|
+
- 环境变量覆盖:`PI_CC_COMPACT_MESSAGES=0`、`PI_CC_COMPACT_TOOL_ROWS=0`、`PI_CC_MANAGE_CCSTYLE=0`
|
|
91
|
+
|
|
92
|
+
> 如果装了 `pi-cc-extensions`,`tools on` 会把本扩展的工具名加入它的 `excludeRenderers`;
|
|
93
|
+
> `tools off` 只移除自己加过的名字。它只在启动时读配置,切换后需要 `/reload`。
|
|
94
|
+
|
|
95
|
+
## 环境变量
|
|
96
|
+
|
|
97
|
+
| 变量 | 默认 | 说明 |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 任务最长存活天数,`0` 不过期 |
|
|
100
|
+
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
101
|
+
| `PI_LOOP_JITTER` | `0` | 抖动比例 0–1 |
|
|
102
|
+
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
103
|
+
|
|
104
|
+
## 兼容性
|
|
105
|
+
|
|
106
|
+
- Node.js `>=22.19.0`
|
|
107
|
+
- Pi `1.3.0`(开发环境)
|
|
108
|
+
- 与 `pi-cc-extensions` 共存,见上面的设置说明
|
|
109
|
+
|
|
110
|
+
## 本地开发
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
node --test tests/*.test.mjs
|
|
114
|
+
npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 说明
|
|
118
|
+
|
|
119
|
+
- 行为对齐 Claude Code v2.1.306 的 `/loop`,用 Pi 扩展 API 实现。
|
|
120
|
+
- `/loop stop` 代替 CC 的 Esc;状态条默认关闭,和 CC 主会话一致。
|
|
121
|
+
- `cron_create` 的 `maxFires` 是额外加固;CC 没有次数上限。
|
|
122
|
+
- `/loop stop` 会连带停掉该 chain 绑定的 Monitor。
|
|
123
|
+
|
|
124
|
+
## 许可证
|
|
125
|
+
|
|
126
|
+
MIT
|
package/config.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared settings for the pi-cc extensions.
|
|
3
|
+
*
|
|
4
|
+
* Both pi-cc-loop and pi-cc-monitor read the same small JSON file so a single
|
|
5
|
+
* toggle changes the look of both packages. The file is optional; when it is
|
|
6
|
+
* missing or malformed the defaults apply.
|
|
7
|
+
*
|
|
8
|
+
* ~/.pi/agent/pi-cc.json
|
|
9
|
+
* {
|
|
10
|
+
* "compactMessages": true,
|
|
11
|
+
* "compactToolRows": true
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Environment overrides (useful for tests and quick experiments):
|
|
15
|
+
* PI_CC_CONFIG_FILE=/path/to/pi-cc.json
|
|
16
|
+
* PI_CC_COMPACT_MESSAGES=0|1
|
|
17
|
+
* PI_CC_COMPACT_TOOL_ROWS=0|1
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
export interface PiCcConfig {
|
|
25
|
+
/** Two-line marker blocks instead of the padded card. */
|
|
26
|
+
compactMessages: boolean;
|
|
27
|
+
/** One-line tool results instead of Pi's "N lines returned" preview. */
|
|
28
|
+
compactToolRows: boolean;
|
|
29
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
managePiCcExtensions: boolean;
|
|
35
|
+
/** Internal: names this extension added to pi-cc-extensions.json. */
|
|
36
|
+
managedExcludes?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CONFIG: PiCcConfig = {
|
|
40
|
+
compactMessages: true,
|
|
41
|
+
compactToolRows: true,
|
|
42
|
+
managePiCcExtensions: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function envBool(name: string): boolean | undefined {
|
|
46
|
+
const raw = process.env[name];
|
|
47
|
+
if (raw === undefined) return undefined;
|
|
48
|
+
if (/^(0|false|off|no)$/i.test(raw)) return false;
|
|
49
|
+
if (/^(1|true|on|yes)$/i.test(raw)) return true;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The settings file shared by pi-cc-loop and pi-cc-monitor. */
|
|
54
|
+
export function configPath(): string {
|
|
55
|
+
if (process.env.PI_CC_CONFIG_FILE) return process.env.PI_CC_CONFIG_FILE;
|
|
56
|
+
const agentDir = process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
57
|
+
return path.join(agentDir, "pi-cc.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readConfig(): PiCcConfig {
|
|
61
|
+
let file: Partial<PiCcConfig> = {};
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(fs.readFileSync(configPath(), "utf8")) as unknown;
|
|
64
|
+
if (parsed && typeof parsed === "object") file = parsed as Partial<PiCcConfig>;
|
|
65
|
+
} catch {
|
|
66
|
+
// Missing or malformed files fall back to defaults.
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
compactMessages:
|
|
70
|
+
envBool("PI_CC_COMPACT_MESSAGES") ??
|
|
71
|
+
(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
|
+
: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let cached: PiCcConfig | undefined;
|
|
87
|
+
|
|
88
|
+
/** Load the settings. Cached until `invalidateConfig` or `saveConfig`. */
|
|
89
|
+
export function loadConfig(): PiCcConfig {
|
|
90
|
+
if (!cached) cached = readConfig();
|
|
91
|
+
return cached;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Forget the cached settings, e.g. after another extension changed the file. */
|
|
95
|
+
export function invalidateConfig(): void {
|
|
96
|
+
cached = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Merge a patch into the settings file and update the cache. */
|
|
100
|
+
export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
101
|
+
const next = { ...loadConfig(), ...patch };
|
|
102
|
+
const file = configPath();
|
|
103
|
+
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>;
|
|
137
|
+
try {
|
|
138
|
+
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>;
|
|
141
|
+
} 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
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!changed && managed.size === (config.managedExcludes ?? []).length) return false;
|
|
166
|
+
|
|
167
|
+
raw.excludeRenderers = [...next].sort((a, b) => a.localeCompare(b));
|
|
168
|
+
const temp = `${file}.tmp`;
|
|
169
|
+
fs.writeFileSync(temp, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
170
|
+
fs.renameSync(temp, file);
|
|
171
|
+
saveConfig({ managedExcludes: [...managed].sort((a, b) => a.localeCompare(b)) });
|
|
172
|
+
return changed;
|
|
173
|
+
}
|
package/cron.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal 5-field cron support for the pi-cc-loop extension.
|
|
3
|
+
*
|
|
4
|
+
* Fields: minute hour day-of-month month day-of-week
|
|
5
|
+
* Per-field syntax: `*`, `*\/N`, `a`, `a-b`, `a-b/N`, `a,b,c`
|
|
6
|
+
*
|
|
7
|
+
* Deliberately unsupported (same subset Claude Code accepts): `L`, `W`, `?`
|
|
8
|
+
* and name aliases such as `MON`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CronFields {
|
|
12
|
+
minute: Set<number>;
|
|
13
|
+
hour: Set<number>;
|
|
14
|
+
dom: Set<number>;
|
|
15
|
+
month: Set<number>;
|
|
16
|
+
dow: Set<number>;
|
|
17
|
+
/** True when the day-of-month field was not `*`. */
|
|
18
|
+
domRestricted: boolean;
|
|
19
|
+
/** True when the day-of-week field was not `*`. */
|
|
20
|
+
dowRestricted: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FieldSpec {
|
|
24
|
+
name: string;
|
|
25
|
+
min: number;
|
|
26
|
+
max: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FIELDS: FieldSpec[] = [
|
|
30
|
+
{ name: "minute", min: 0, max: 59 },
|
|
31
|
+
{ name: "hour", min: 0, max: 23 },
|
|
32
|
+
{ name: "day of month", min: 1, max: 31 },
|
|
33
|
+
{ name: "month", min: 1, max: 12 },
|
|
34
|
+
{ name: "day of week", min: 0, max: 7 },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const CRON_FIELD_COUNT = FIELDS.length;
|
|
38
|
+
|
|
39
|
+
function parseField(spec: FieldSpec, raw: string): Set<number> | null {
|
|
40
|
+
const out = new Set<number>();
|
|
41
|
+
const parts = raw.split(",");
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
const trimmed = part.trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
|
|
46
|
+
const [rangePart, stepPart] = trimmed.split("/");
|
|
47
|
+
let step = 1;
|
|
48
|
+
if (stepPart !== undefined) {
|
|
49
|
+
if (!/^\d+$/.test(stepPart)) return null;
|
|
50
|
+
step = Number(stepPart);
|
|
51
|
+
if (step < 1) return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let start: number;
|
|
55
|
+
let end: number;
|
|
56
|
+
if (rangePart === "*") {
|
|
57
|
+
start = spec.min;
|
|
58
|
+
end = spec.max;
|
|
59
|
+
} else {
|
|
60
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(rangePart);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
start = Number(m[1]);
|
|
63
|
+
end = m[2] !== undefined ? Number(m[2]) : start;
|
|
64
|
+
if (end < start) return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (start < spec.min || end > spec.max) return null;
|
|
68
|
+
for (let v = start; v <= end; v += step) {
|
|
69
|
+
// cron treats 7 and 0 as Sunday
|
|
70
|
+
out.add(spec.name === "day of week" && v === 7 ? 0 : v);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse a 5-field cron expression. Returns null when the expression is invalid. */
|
|
77
|
+
export function parseCron(expression: string): CronFields | null {
|
|
78
|
+
const trimmed = expression.trim();
|
|
79
|
+
if (!trimmed) return null;
|
|
80
|
+
const tokens = trimmed.split(/\s+/);
|
|
81
|
+
if (tokens.length !== CRON_FIELD_COUNT) return null;
|
|
82
|
+
|
|
83
|
+
const parsed: Set<number>[] = [];
|
|
84
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
85
|
+
const set = parseField(FIELDS[i], tokens[i]);
|
|
86
|
+
if (!set || set.size === 0) return null;
|
|
87
|
+
parsed.push(set);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
minute: parsed[0],
|
|
92
|
+
hour: parsed[1],
|
|
93
|
+
dom: parsed[2],
|
|
94
|
+
month: parsed[3],
|
|
95
|
+
dow: parsed[4],
|
|
96
|
+
domRestricted: tokens[2] !== "*",
|
|
97
|
+
dowRestricted: tokens[4] !== "*",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dayMatches(fields: CronFields, date: Date): boolean {
|
|
102
|
+
const domOk = fields.dom.has(date.getDate());
|
|
103
|
+
const dowOk = fields.dow.has(date.getDay());
|
|
104
|
+
// vixie-cron semantics: when both day fields are restricted, either may match.
|
|
105
|
+
if (fields.domRestricted && fields.dowRestricted) return domOk || dowOk;
|
|
106
|
+
if (fields.domRestricted) return domOk;
|
|
107
|
+
if (fields.dowRestricted) return dowOk;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Next time the expression matches strictly after `from`, or null when nothing
|
|
113
|
+
* matches within the search window.
|
|
114
|
+
*/
|
|
115
|
+
export function nextFireAfter(
|
|
116
|
+
fields: CronFields,
|
|
117
|
+
from: Date,
|
|
118
|
+
limitDays = 366 * 4,
|
|
119
|
+
): Date | null {
|
|
120
|
+
const cursor = new Date(from.getTime());
|
|
121
|
+
cursor.setSeconds(0, 0);
|
|
122
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
123
|
+
const limit = from.getTime() + limitDays * 24 * 60 * 60 * 1000;
|
|
124
|
+
|
|
125
|
+
while (cursor.getTime() <= limit) {
|
|
126
|
+
if (!fields.month.has(cursor.getMonth() + 1)) {
|
|
127
|
+
cursor.setMonth(cursor.getMonth() + 1, 1);
|
|
128
|
+
cursor.setHours(0, 0, 0, 0);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!dayMatches(fields, cursor)) {
|
|
132
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
133
|
+
cursor.setHours(0, 0, 0, 0);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!fields.hour.has(cursor.getHours())) {
|
|
137
|
+
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!fields.minute.has(cursor.getMinutes())) {
|
|
141
|
+
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
return cursor;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True when the expression can never match (e.g. February 30th). */
|
|
150
|
+
export function isUnreachable(expression: string): boolean {
|
|
151
|
+
const fields = parseCron(expression);
|
|
152
|
+
if (!fields) return true;
|
|
153
|
+
// Look a year ahead; if nothing matches, the expression is dead.
|
|
154
|
+
return nextFireAfter(fields, new Date(), 366) === null;
|
|
155
|
+
}
|
|
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
|
+
const UNIT_NAMES: Record<string, [string, string]> = {
|
|
180
|
+
s: ["second", "seconds"],
|
|
181
|
+
m: ["minute", "minutes"],
|
|
182
|
+
h: ["hour", "hours"],
|
|
183
|
+
d: ["day", "days"],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The divisor of `total` closest to `n`.
|
|
188
|
+
*
|
|
189
|
+
* Claude Code rounds a non-dividing interval to the nearest clean one: "If the
|
|
190
|
+
* interval doesn't cleanly divide its unit, round to the nearest clean interval
|
|
191
|
+
* and tell the user what you rounded to."
|
|
192
|
+
*/
|
|
193
|
+
function nearestDivisor(n: number, total: number): number {
|
|
194
|
+
let best = 1;
|
|
195
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
196
|
+
for (let d = 1; d <= total; d++) {
|
|
197
|
+
if (total % d !== 0) continue;
|
|
198
|
+
const distance = Math.abs(d - n);
|
|
199
|
+
if (distance < bestDistance) {
|
|
200
|
+
best = d;
|
|
201
|
+
bestDistance = distance;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return best;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Convert a duration into a cron expression, following Claude Code's rule:
|
|
209
|
+
*
|
|
210
|
+
* "Supported suffixes: `s` -> ceil to nearest minute, `m` (minutes),
|
|
211
|
+
* `h` (hours), `d` (days). Examples: `5m` -> a 5 minute step, `1h` -> `0 * * * *`,
|
|
212
|
+
* `1d` -> `0 0 * * *`. If the interval doesn't cleanly divide its unit, round to the
|
|
213
|
+
* nearest clean interval and tell the user what you rounded to."
|
|
214
|
+
*/
|
|
215
|
+
export function intervalToCron(seconds: number): IntervalSpec | null {
|
|
216
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
|
217
|
+
|
|
218
|
+
// Sub-minute: the scheduler's granularity is one minute, so round up.
|
|
219
|
+
if (seconds < 60) {
|
|
220
|
+
return {
|
|
221
|
+
seconds: 60,
|
|
222
|
+
cron: "* * * * *",
|
|
223
|
+
human: "every minute",
|
|
224
|
+
adjusted: true,
|
|
225
|
+
requestedSeconds: seconds,
|
|
226
|
+
note: `${seconds}s rounded up to 1 minute (the scheduler's minimum granularity is 1 minute)`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (seconds < 3600) {
|
|
231
|
+
const requested = Math.round(seconds / 60);
|
|
232
|
+
const minutes = nearestDivisor(requested, 60);
|
|
233
|
+
return {
|
|
234
|
+
seconds: minutes * 60,
|
|
235
|
+
cron: `*/${minutes} * * * *`,
|
|
236
|
+
human: `every ${minutes} minute${minutes === 1 ? "" : "s"}`,
|
|
237
|
+
adjusted: minutes !== requested,
|
|
238
|
+
requestedSeconds: seconds,
|
|
239
|
+
...(minutes !== requested
|
|
240
|
+
? { note: `${requested}m rounded to ${minutes}m, the nearest clean interval` }
|
|
241
|
+
: {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (seconds < 86400) {
|
|
246
|
+
const requested = Math.round(seconds / 3600);
|
|
247
|
+
const hours = nearestDivisor(requested, 24);
|
|
248
|
+
return {
|
|
249
|
+
seconds: hours * 3600,
|
|
250
|
+
cron: hours === 1 ? "0 * * * *" : `0 */${hours} * * *`,
|
|
251
|
+
human: `every ${hours} hour${hours === 1 ? "" : "s"}`,
|
|
252
|
+
adjusted: hours !== requested,
|
|
253
|
+
requestedSeconds: seconds,
|
|
254
|
+
...(hours !== requested
|
|
255
|
+
? { note: `${requested}h rounded to ${hours}h, the nearest clean interval` }
|
|
256
|
+
: {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const days = Math.max(1, Math.round(seconds / 86400));
|
|
261
|
+
return {
|
|
262
|
+
seconds: days * 86400,
|
|
263
|
+
cron: days === 1 ? "0 0 * * *" : `0 0 */${days} * *`,
|
|
264
|
+
human: days === 1 ? "every day" : `every ${days} days`,
|
|
265
|
+
adjusted: days * 86400 !== seconds,
|
|
266
|
+
requestedSeconds: seconds,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Parse a duration token such as `5m`, `30s`, `2h`, `1d`. */
|
|
271
|
+
export function parseIntervalToken(token: string): number | null {
|
|
272
|
+
const m = /^(\d+)\s*([smhd])$/i.exec(token.trim());
|
|
273
|
+
if (!m) return null;
|
|
274
|
+
const unit = m[2].toLowerCase();
|
|
275
|
+
const amount = Number(m[1]);
|
|
276
|
+
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
277
|
+
return amount * UNIT_SECONDS[unit];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Parse `every 20m` / `every 5 minutes` / `every 2 hours` style clauses. */
|
|
281
|
+
export function parseEveryClause(input: string): { interval: number; prompt: string } | null {
|
|
282
|
+
const m = /\bevery\s+(\d+)\s*([a-z]+)\s*$/i.exec(input.trim());
|
|
283
|
+
if (!m) return null;
|
|
284
|
+
const amount = Number(m[1]);
|
|
285
|
+
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;
|
|
298
|
+
const prompt = input.trim().slice(0, m.index).trim();
|
|
299
|
+
return { interval: amount * unitSeconds, prompt };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
303
|
+
|
|
304
|
+
/**
|
|
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").
|
|
307
|
+
*/
|
|
308
|
+
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";
|
|
314
|
+
|
|
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`;
|
|
319
|
+
}
|
|
320
|
+
|
|
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}`;
|
|
326
|
+
}
|
|
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}`;
|
|
334
|
+
}
|
|
335
|
+
return expression;
|
|
336
|
+
}
|
|
337
|
+
|
|
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}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Human readable summary of a cron expression. */
|
|
348
|
+
export function describeCron(expression: string): string {
|
|
349
|
+
const fields = parseCron(expression);
|
|
350
|
+
if (!fields) return expression;
|
|
351
|
+
const [minute, hour, dom, month, dow] = expression.trim().split(/\s+/);
|
|
352
|
+
const parts: string[] = [];
|
|
353
|
+
if (minute !== "*") parts.push(`minute ${minute}`);
|
|
354
|
+
if (hour !== "*") parts.push(`hour ${hour}`);
|
|
355
|
+
if (dom !== "*") parts.push(`day-of-month ${dom}`);
|
|
356
|
+
if (month !== "*") parts.push(`month ${month}`);
|
|
357
|
+
if (dow !== "*") parts.push(`day-of-week ${dow}`);
|
|
358
|
+
return parts.length ? parts.join(", ") : "every minute";
|
|
359
|
+
}
|