pi-cc-loop 0.1.1 → 0.3.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 +47 -63
- package/config.ts +15 -86
- package/cron.ts +52 -152
- package/durable.ts +447 -0
- package/index.ts +826 -1218
- package/loopfile.ts +103 -0
- package/package.json +2 -6
- package/prompts.ts +257 -0
- package/render.ts +32 -9
- package/renderers.ts +98 -0
- package/schedule.ts +147 -169
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-cc-loop
|
|
2
2
|
|
|
3
|
-
> 在 Pi 里复刻 Claude Code 的 `/loop`:固定间隔 cron
|
|
3
|
+
> 在 Pi 里复刻 Claude Code 的 `/loop`:固定间隔 cron、自选间隔、兜底心跳和底部状态摘要。
|
|
4
4
|
|
|
5
5
|
## 快速开始
|
|
6
6
|
|
|
@@ -14,83 +14,64 @@ pi install npm:pi-cc-loop
|
|
|
14
14
|
|
|
15
15
|
安装后执行 `/reload`。
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## 工作方式
|
|
18
|
+
|
|
19
|
+
`/loop` 和 CC 一样是一个**技能**,不是用代码直接排任务:你输入的内容会作为一条普通 user 消息进入对话,
|
|
20
|
+
模型自己解析间隔并调用工具完成。所以对话里始终能看到你要求了什么、模型做了什么。
|
|
18
21
|
|
|
19
22
|
| 功能 | 说明 | 入口 |
|
|
20
23
|
| --- | --- | --- |
|
|
21
|
-
| 固定间隔 |
|
|
22
|
-
| 自选间隔 |
|
|
23
|
-
|
|
|
24
|
-
|
|
|
24
|
+
| 固定间隔 | 模型用 `cron_create` 建一个 cron(`5m` → `*/5 * * * *`),然后立即执行一次;触发时按"定时任务"呈现 | `/loop 5m <prompt>`、`/loop 每分钟 ...` |
|
|
25
|
+
| 自选间隔 | 运行一次后用 `schedule_wakeup` 决定下次延迟(60–3600s) | `/loop <prompt>` |
|
|
26
|
+
| loop.md | 请求本身没有任务时(裸 `/loop` 或只给间隔)由 `.pi/loop.md` 提供,每次触发重读;首轮由模型自己读文件 | `/loop`、`/loop 15m` |
|
|
27
|
+
| 停止 / 查看 | 也走对话:模型用 `cron_list` / `cron_delete`(自选间隔用 `schedule_wakeup` + `stop: true`) | `/loop stop`、`/loop status` |
|
|
25
28
|
| 漏跑合并 | agent 忙时到点只记一次 pending,空闲后发一条 | 自动 |
|
|
26
29
|
| 兜底心跳 | 模型忘记调度时 arm 一次 20 分钟心跳,预算 1 次 | 自动 |
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
+
| Esc 取消 | 打断属于某个自定步调 loop 的回合(它的 tick、它的 Monitor 唤醒的回合)时取消该 loop;无关回合、空闲时不受影响;固定间隔不受影响 | Esc |
|
|
31
|
+
| 错峰 | 周期任务按 id 固定延后最多半个周期(≤30 分钟),间隔本身不变;`:00`/`:30` 的一次性任务最多提前 90 秒 | 自动 |
|
|
32
|
+
| 过期 | 周期任务 7 天后最后执行一次再删除;一次性任务不过期 | 自动 |
|
|
33
|
+
| 次数上限 | `maxFires`,到顶自动删除 | 模型传参 |
|
|
34
|
+
| 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json`;同一项目只由一个 Pi 会话执行 | 模型传参 |
|
|
35
|
+
| 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/loop footer on\|off` |
|
|
36
|
+
| 界面设置 | 仍是本地命令,不进对话 | `/loop settings ...` |
|
|
30
37
|
|
|
31
38
|
## 命令
|
|
32
39
|
|
|
33
40
|
```text
|
|
34
|
-
/loop [间隔] <prompt>
|
|
35
|
-
/loop <prompt>
|
|
36
|
-
/loop
|
|
37
|
-
/loop status
|
|
38
|
-
/loop stop [id]
|
|
39
|
-
/loop
|
|
40
|
-
/loop settings ...
|
|
41
|
-
/loop help
|
|
41
|
+
/loop [间隔] <prompt> 交给模型:按间隔重复,或自选节奏
|
|
42
|
+
/loop <prompt> 交给模型:自选间隔
|
|
43
|
+
/loop [间隔] 交给模型:跑 .pi/loop.md 或内置维护 prompt
|
|
44
|
+
/loop status 交给模型:列出当前 loop
|
|
45
|
+
/loop stop [id] 交给模型:停止一个或全部
|
|
46
|
+
/loop footer on|off 本地:底部摘要
|
|
47
|
+
/loop settings ... 本地:界面设置
|
|
48
|
+
/loop help 本地:帮助
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
前五个会作为普通 user 消息进入对话(内容就是你输入的那行),模型再按技能说明调用
|
|
52
|
+
`cron_create`、`cron_list`、`cron_delete`、`schedule_wakeup`。后三个只改界面,不进对话。
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
间隔写法没有约束,模型自己解析:`30s`、`5m`、`2h`、`1d`、`check the deploy every 20m`、
|
|
55
|
+
`每2分钟输出一次`、`每分钟打印一次hi` 都可以。小于一分钟会向上取整到每分钟。
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
- `check the deploy every 20m`
|
|
50
|
-
- 不整除的间隔按 CC 规则取整并提示:`7m` → `*/6`,`30s` → 每分钟
|
|
51
|
-
- 其他语言(如「每30秒」)交给模型解析
|
|
57
|
+
## 底部摘要
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
`ctx.ui.setStatus("loop", …)` 是 Pi 核心 API,不依赖任何其他扩展:
|
|
54
60
|
|
|
55
|
-
|
|
|
61
|
+
| 场景 | 显示 |
|
|
56
62
|
| --- | --- |
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
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
|
-
```
|
|
63
|
+
| 没有任务 | 不设置状态,footer 不会被多占一行 |
|
|
64
|
+
| 有任务 | `loop 3 · next 4m`(数量 + 最近一次触发) |
|
|
65
|
+
| 一分钟内 | `loop 1 · next <1m` |
|
|
66
|
+
| 超过一小时 | `loop 1 · next 2h 15m` |
|
|
88
67
|
|
|
89
|
-
-
|
|
90
|
-
-
|
|
68
|
+
- 倒计时取整到分钟,配合"文本相同就不调用"的去重,最多每分钟重绘一次(Pi 每次 `setStatus` 都会触发重绘)
|
|
69
|
+
- 装了 `pi-cc-extensions`:这一行会作为它的一个 chip 出现在第 2 行,可在它的配置面板里移动或隐藏;它会把 ANSI 去掉,所以这里只用纯文本
|
|
70
|
+
- 没装 `pi-cc-extensions`:Pi 内置 footer 会把它和其他扩展的状态拼成一行,加在用量行下面,只在有任务时出现
|
|
91
71
|
|
|
92
|
-
>
|
|
93
|
-
> `
|
|
72
|
+
> 本扩展不写其他插件的配置文件。如果你之前在 `pi-cc-extensions.json` 里因为旧版本加过
|
|
73
|
+
> `cron_create`、`cron_delete`、`cron_list`、`schedule_wakeup` 这几条 `excludeRenderers`,
|
|
74
|
+
> 现在可以删掉:工具行由 Pi 或 pi-cc-extensions 自己渲染。
|
|
94
75
|
|
|
95
76
|
## 环境变量
|
|
96
77
|
|
|
@@ -98,8 +79,9 @@ pi install npm:pi-cc-loop
|
|
|
98
79
|
| --- | --- | --- |
|
|
99
80
|
| `PI_LOOP_MAX_AGE_DAYS` | `7` | 任务最长存活天数,`0` 不过期 |
|
|
100
81
|
| `PI_LOOP_KEEPALIVE_SECONDS` | `1200` | 兜底心跳;`0` 禁用 |
|
|
101
|
-
| `PI_LOOP_JITTER` | `0` |
|
|
82
|
+
| `PI_LOOP_JITTER` | `0.5` | 周期任务最多延后的周期比例;`0` 关闭错峰 |
|
|
102
83
|
| `PI_LOOP_SCAN_MS` | `1000` | 调度扫描间隔 |
|
|
84
|
+
| `PI_CC_FOOTER` | `1` | 底部摘要;`0` 关闭(两个 pi-cc 包共用) |
|
|
103
85
|
|
|
104
86
|
## 兼容性
|
|
105
87
|
|
|
@@ -117,9 +99,11 @@ npx --yes typescript@5.9.3 tsc --noEmit -p tsconfig.json
|
|
|
117
99
|
## 说明
|
|
118
100
|
|
|
119
101
|
- 行为对齐 Claude Code v2.1.306 的 `/loop`,用 Pi 扩展 API 实现。
|
|
120
|
-
-
|
|
102
|
+
- Esc 只取消被打断回合所属的自定步调 loop;CC 在任何时候按 Esc(包括空闲、打断无关回合)都会取消所有自定步调 loop。其他情况用 `/loop stop`。
|
|
103
|
+
- 底部摘要默认开启,是 CC 没有的(CC 主会话不显示 loop 状态);详细面板默认关闭。
|
|
121
104
|
- `cron_create` 的 `maxFires` 是额外加固;CC 没有次数上限。
|
|
122
|
-
- `/loop stop`
|
|
105
|
+
- `/loop stop` 会连带静默停掉该 chain 绑定的 Monitor。
|
|
106
|
+
- Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
|
|
123
107
|
|
|
124
108
|
## 许可证
|
|
125
109
|
|
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,35 +154,7 @@ export function isUnreachable(expression: string): boolean {
|
|
|
154
154
|
return nextFireAfter(fields, new Date(), 366) === null;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
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
|
-
|
|
157
|
+
/** The unit words Claude Code's /loop accepts in a trailing "every N <unit>" clause. */
|
|
186
158
|
/**
|
|
187
159
|
* The divisor of `total` closest to `n`.
|
|
188
160
|
*
|
|
@@ -205,143 +177,71 @@ function nearestDivisor(n: number, total: number): number {
|
|
|
205
177
|
}
|
|
206
178
|
|
|
207
179
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
*
|
|
180
|
+
* Parse `每2分钟输出一次` / `输出一次 每隔 2 小时`: an explicit interval written
|
|
181
|
+
* in Chinese. The digit is required on purpose: `每天都有一个测试失败` is a
|
|
182
|
+
* prompt, not a schedule, and Claude Code's own splitting is just as literal.
|
|
183
|
+
*/
|
|
184
|
+
/**
|
|
185
|
+
* Parse a trailing `every 20m` / `every 5 min` / `every 2 hours` clause, with
|
|
186
|
+
* the unit words Claude Code accepts.
|
|
214
187
|
*/
|
|
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
188
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
303
189
|
|
|
190
|
+
|
|
304
191
|
/**
|
|
305
|
-
* A human readable cadence for a cron expression,
|
|
306
|
-
*
|
|
192
|
+
* A human readable cadence for a cron expression, with Claude Code's labels:
|
|
193
|
+
* "Every minute", "Every 5 minutes", "Every hour at :07", "Every 2 hours",
|
|
194
|
+
* "Every day at 9:00 AM", "Every Sunday at 9:00 AM", "Weekdays at 9:00 AM".
|
|
195
|
+
*
|
|
196
|
+
* Like Claude Code, anything else is shown as written rather than as a
|
|
197
|
+
* half-filled sentence. The one addition is "Every N days at ...", which
|
|
198
|
+
* `/loop Nd` produces.
|
|
307
199
|
*/
|
|
308
200
|
export function humanSchedule(expression: string): string {
|
|
309
|
-
const fields =
|
|
310
|
-
if (!
|
|
311
|
-
const [minute, hour, dom, month, dow] =
|
|
312
|
-
const
|
|
313
|
-
|
|
201
|
+
const fields = expression.trim().split(/\s+/);
|
|
202
|
+
if (fields.length !== CRON_FIELD_COUNT || !parseCron(expression)) return expression;
|
|
203
|
+
const [minute, hour, dom, month, dow] = fields;
|
|
204
|
+
const isNumber = (value: string) => /^\d+$/.test(value);
|
|
205
|
+
const step = (value: string) => {
|
|
206
|
+
const match = /^\*\/(\d+)$/.exec(value);
|
|
207
|
+
return match ? Number(match[1]) : undefined;
|
|
208
|
+
};
|
|
209
|
+
const atMinute = (value: number) => `:${String(value).padStart(2, "0")}`;
|
|
210
|
+
const everyDay = dom === "*" && month === "*" && dow === "*";
|
|
211
|
+
|
|
212
|
+
if (hour === "*" && everyDay) {
|
|
213
|
+
if (minute === "*") return "Every minute";
|
|
214
|
+
const n = step(minute);
|
|
215
|
+
if (n !== undefined) return n === 1 ? "Every minute" : `Every ${n} minutes`;
|
|
216
|
+
if (isNumber(minute)) return Number(minute) === 0 ? "Every hour" : `Every hour at ${atMinute(Number(minute))}`;
|
|
217
|
+
return expression;
|
|
218
|
+
}
|
|
314
219
|
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
317
|
-
const
|
|
318
|
-
return
|
|
220
|
+
const hours = step(hour);
|
|
221
|
+
if (isNumber(minute) && hours !== undefined && everyDay) {
|
|
222
|
+
const at = Number(minute) === 0 ? "" : ` at ${atMinute(Number(minute))}`;
|
|
223
|
+
return hours === 1 ? `Every hour${at}` : `Every ${hours} hours${at}`;
|
|
319
224
|
}
|
|
320
225
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return `
|
|
226
|
+
if (!isNumber(minute) || !isNumber(hour)) return expression;
|
|
227
|
+
const clock = clockText(Number(minute), Number(hour));
|
|
228
|
+
if (everyDay) return `Every day at ${clock}`;
|
|
229
|
+
if (dom === "*" && month === "*") {
|
|
230
|
+
if (/^\d$/.test(dow)) return `Every ${DAY_NAMES[Number(dow) % 7]} at ${clock}`;
|
|
231
|
+
if (dow === "1-5") return `Weekdays at ${clock}`;
|
|
326
232
|
}
|
|
327
|
-
|
|
328
|
-
if (month === "*") {
|
|
329
|
-
|
|
330
|
-
.split(",")
|
|
331
|
-
.map((d) => DAY_NAMES[Number(d) % 7])
|
|
332
|
-
.join(", ");
|
|
333
|
-
return `Every ${days} at ${clock}`;
|
|
233
|
+
const days = step(dom);
|
|
234
|
+
if (days !== undefined && month === "*" && dow === "*") {
|
|
235
|
+
return days === 1 ? `Every day at ${clock}` : `Every ${days} days at ${clock}`;
|
|
334
236
|
}
|
|
335
237
|
return expression;
|
|
336
238
|
}
|
|
337
239
|
|
|
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}`;
|
|
240
|
+
/** "9:05 AM" style time. */
|
|
241
|
+
function clockText(minute: number, hour: number): string {
|
|
242
|
+
const suffix = hour < 12 ? "AM" : "PM";
|
|
243
|
+
const display = hour % 12 === 0 ? 12 : hour % 12;
|
|
244
|
+
return `${display}:${String(minute).padStart(2, "0")} ${suffix}`;
|
|
345
245
|
}
|
|
346
246
|
|
|
347
247
|
/** Human readable summary of a cron expression. */
|