cron-up 26.9.21 → 26.10.23

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
@@ -3,7 +3,7 @@
3
3
  为命令行 AI agent 的 cron 定时任务**提前启动并保持一个可用的交互会话**。任务本身仍由 agent 自己的定时机制执行,cron-up 只负责保证:任务到点时,对应目录里已经有一个开着的会话。
4
4
 
5
5
  > [!WARNING]
6
- > cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude 入口脚本)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**(均为 macOS)。升级 Claude Code 后建议重新核对。
6
+ > cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude 入口脚本)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**;定时任务的投递抖动与任务 id 哈希算法(autoMinId 依赖它)逆向核对自 **2.1.285**(均为 macOS)。升级 Claude Code 后建议重新核对。
7
7
 
8
8
  ## 平台要求
9
9
 
@@ -29,7 +29,7 @@ cron-up 是一个由 launchd 每 5 分钟运行一次的巡检进程,逻辑只
29
29
  | 环节 | 由谁负责 |
30
30
  |---|---|
31
31
  | 任务登记 | Claude Code 原生命令 `CronCreate(durable: true)`,写入工作目录下的 `.claude/scheduled_tasks.json` |
32
- | 定时执行 | Claude Code 交互会话自身(包括 cron 时间计算、随机延迟、一次性任务执行后自动删除) |
32
+ | 定时执行 | Claude Code 交互会话自身(包括 cron 时间计算、按任务 id 确定的投递抖动——每日周期任务通常顶格延后约 30 分钟、一次性任务执行后自动删除;详见下「投递抖动」) |
33
33
  | 保证有会话在场 | launchd 每 5 分钟调用一次 `cron-up run`(经自有启动脚本拉起,PATH 指向安装时解析的 node bin 目录),运行完立即退出,不常驻 |
34
34
  | 周期任务续期 | 巡检时给周期任务补 `permanent: true`(默认开启、可关),使其不受 7 天过期限制;见下 |
35
35
  | 后台会话 | 通过 macOS 自带的 `script(1)` 启动无窗口交互界面,输出写入日志,可随时恢复 |
@@ -60,7 +60,19 @@ Claude Code 的周期任务在**创建满 7 天**后会末次执行、随即被
60
60
  cron-up install --force --no-auto-renew
61
61
  ```
62
62
 
63
- > 续期会**改写各工作目录的 `.claude/scheduled_tasks.json`**:原子写入、保留原权限与中文,只新增 `permanent` 一个字段,绝不改动任务的 prompt、cron 或 id。被续期的任务不再被自动删除,为它保活的后台会话也会因此**长期常驻**——这等于关闭了 Claude Code 对自主会话的寿命护栏。相关风险见「安全说明」。
63
+ ### 自动把任务 id 改小以消除投递延迟(默认开启)
64
+
65
+ 投递延迟完全由任务 id 的哈希决定(见下节),而 id 就是 8 位十六进制字符串——cron-up 默认在每轮巡检时把周期任务里哈希过大的 id 改写成小号(`00000001` 起),把每日任务的投递延迟压到 **1 分钟以内**(id 够小时观感就是准点)。规则与自动续期同样保守:
66
+
67
+ - 只改显式**周期**任务,一次性任务不碰;只在同一任务文件内分配未被占用的序号,已够小的 id(含 `00000000`)原样保留、**绝不重排**,所以幂等——首轮改完,之后巡检字节级不动文件;
68
+ - 与续期合并为同一次原子写,任务文件在「巡检读到」与「写回」之间被 Claude Code 改动时整轮跳过、下轮再来,不会覆盖它写入的 `lastFiredAt`;
69
+ - 生效时机:window 模式(默认)下任务执行完即回收会话、下个窗口提前 10 分钟重拉,**下次触发的新会话就读到小 id**;always 模式常驻会话需换代(或手动重启)后才生效。
70
+
71
+ 关闭:配置 `autoMinId: false`(下轮巡检生效),或 `cron-up install --force --no-auto-min-id`。`cron-up renew` 会无视开关、立即把所有周期任务补 permanent 并改小 id。
72
+
73
+ > 这是在利用逆向出来的内部行为:id 哈希算法不是官方承诺的接口,Claude Code 若更换算法(改用完整 id、混入创建时间、加 salt 等),小 id 会失效。届时把 `autoMinId` 关掉、改回「cron 往前提 30 分钟」即可;排查见 findings 主题 `claude-code-scheduled-task-jitter`。另注意:改小后**不要再在交互会话里用 CronCreate 重建这些任务**,重建会生成新的随机大 id,需要等下轮巡检再改回来。
74
+
75
+ > 续期(autoRenew)会**改写各工作目录的 `.claude/scheduled_tasks.json`**:原子写入、保留原权限与中文,只新增 `permanent` 一个字段,不改动 prompt、cron 或 id(id 改小是上一节 autoMinId 的独立动作)。被续期的任务不再被自动删除,为它保活的后台会话也会因此**长期常驻**——这等于关闭了 Claude Code 对自主会话的寿命护栏。相关风险见「安全说明」。
64
76
 
65
77
  安装产物:
66
78
 
@@ -83,9 +95,9 @@ cron-up install --force --no-auto-renew
83
95
 
84
96
  ```bash
85
97
  cron-up # 总览:服务、配置、最近任务、保活会话明细,以及需要留意的告警
86
- cron-up list # 逐任务列出:下次执行时间、周期/一次性、cron 表达式、内容摘要、会话状态
98
+ cron-up list # 逐任务列出:设定/预计实际触发时间、周期/一次性、cron 表达式、内容摘要、会话状态
87
99
  cron-up run # 立即手动巡检一次(与定时巡检互斥,同时运行时后到者自动退出)
88
- cron-up renew # 立即给所有周期任务补 permanent(手动执行,不受 autoRenew 开关影响)
100
+ cron-up renew # 立即给所有周期任务补 permanent 并改小 id(手动执行,不受两个开关影响)
89
101
  cron-up logs # 查看巡检日志(stderr 有内容时在开头提示一行)
90
102
  cron-up logs <目录名片段> -f # 实时查看某个后台会话的界面输出
91
103
  cron-up uninstall # 停止并卸载服务,结束后台保活会话,保留配置和日志
@@ -101,7 +113,23 @@ npm i -g cron-up@latest
101
113
 
102
114
  launchd 每轮经启动脚本从 PATH 重新解析 cron-up 与 node,npm 替换包文件后下一轮巡检自然跑新版,无需重载服务;配置与任务文件都不动。
103
115
 
104
- `cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
116
+ `cron-up list` 按工作区分组,每个任务一行:状态列对有效任务显示「设定落点 → 预计实际触发」(机制见下节「投递抖动」,无抖动时只显示一个时间,非整分抖动带秒),异常时显示 `已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,「最近」一行按预计实际触发时间排序;总览还把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
117
+
118
+ ## 投递抖动:设定时间与实际触发时间
119
+
120
+ Claude Code 的任务**并不在 cron 落点投递**,会话内部的调度器会加一个按任务 id 确定的偏移(不是随机数,同一任务每次触发的偏移相同;会话秒级轮询,实际到点有秒级尾差):
121
+
122
+ - **周期任务**:实际触发 = 落点 + min( `hash(id)` × 0.5 × 周期 , 30 分钟 ),其中 `hash(id)` 是任务 id 前 8 位十六进制除以 2³²。每日任务里约 96% 的 id 会撞 30 分钟上限——所以日报类任务几乎必然固定晚 30 分钟;周/月等长周期同样顶格 30 分钟。
123
+ - **一次性任务**:落点分钟是 :00/:30 时提前 0~90 秒(同样按 id 哈希缩放),其余分钟不抖动。
124
+
125
+ 两种办法让任务准点:
126
+
127
+ 1. **用 cron-up 的自动改小 id(默认开启,推荐)**:id 改小后周期延迟降到 1 分钟内,cron 设几点就几点到,不用再心算补偿——见上文「自动把任务 id 改小以消除投递延迟」。
128
+ 2. **把 cron 往前提 30 分钟**(不用 cron-up 或关掉 autoMinId 时):如要 0 点到就设 `30 23 * * *`。
129
+
130
+ `cron-up list` 的「设定 → 预计」列就是按上式算的;id 已被改小后延迟不足 1 分钟,该列只显示一个时间。
131
+
132
+ 这套参数逆向自 Claude Code v2.1.285,**没有稳定性承诺**:版本升级可能调整,运行时还可能被远程配置键 `tengu_kairos_cron_config` 覆盖。若发现实际到点与预计时间系统性偏离,说明上游算法已变,排查步骤与验算脚本见 findings 主题 `claude-code-scheduled-task-jitter`。
105
133
 
106
134
  ## 使用定时任务时的三个约定
107
135
 
@@ -126,23 +154,25 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
126
154
 
127
155
  - **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
128
156
  - **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
157
+ - **开关自动改小 id**:配置项 `autoMinId`,命令行 `cron-up install --force --auto-min-id` / `--no-auto-min-id`,语义与自动续期完全对称。
129
158
  - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;不带 `--force` 时配置原样保留,命令行显式给的参数会被忽略并在安装输出里点名提示;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
130
159
  - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号与发布流程见下文「发布」一节。
131
160
  - **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
132
161
  - **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
133
- - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,安装时直接报错中止。
134
- - **长驻会话自动换代**:被续期任务保活的会话会长期常驻,但常驻不等于长生不老——连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
135
- - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
136
- - **日志大小**:后台会话的界面输出是持续的全屏渲染流。单次运行产生的日志超过 10MB 时,会在下次启动该目录会话时轮转成 `.log.1`;而 `script(1)` 每次启动会清空旧日志,所以反复重启不会造成堆积。长驻会话的单代日志总量由自动换代(见上条)约束;唯一无法覆盖的是连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
162
+ - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
163
+ - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
164
+ - **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
165
+ - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
166
+ - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
137
167
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
138
168
 
139
169
  ## 安全说明
140
170
 
141
- - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew(默认)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉),且只给周期任务新增 `permanent: true`,绝不改动 prompt、cron、id;每个任务只在首次打标时写一次,之后幂等不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再续(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
171
+ - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew 或 autoMinId(两者默认都开)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉)。改写只针对显式周期任务、只动两个字段——补 `permanent: true`(autoRenew)、把哈希过大的 `id` 换成文件内未占用的小号(autoMinId),**绝不改动 prompt 与 cron,也不碰一次性任务**;两项都幂等,每个任务只在首次需要时写一次,之后字节级不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再来(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
142
172
  - **续期会改变三条生命周期/边界**:
143
173
  1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
144
174
  2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
145
- 3. 若某个仓库把 `.claude/scheduled_tasks.json` 纳入了版本控制,首次续期会让该文件因新增 `"permanent": true`(及按统一的两空格缩进排版)出现 diff,提交前请知悉;本机自用仓通常已在 `.git/info/exclude` 忽略它,不受影响。
175
+ 3. 若某个仓库把 `.claude/scheduled_tasks.json` 纳入了版本控制,首轮自动维护会让该文件出现 diff——新增 `"permanent": true`、把任务 `id` 改写成小号(autoMinId),外加统一的两空格缩进排版,提交前请知悉;本机自用仓通常已在 `.git/info/exclude` 忽略它,不受影响。
146
176
  - **任务文件本身就是执行输入,而不只是数据**:任何能在 roots 下某个已信任目录落盘 `.claude/scheduled_tasks.json` 的主体,等于登记了一条定时指令——最快 5 分钟后,cron-up 就会为该目录拉起一个以你的身份运行、自动执行其中 prompt 的会话。这包括一次普通的 `git pull`(任务文件随提交进入工作树)和共享仓库的协作者。因此不要把他人可写仓库的父目录放进 roots;拉取外部代码后若发现 `.claude/scheduled_tasks.json` 被新增或改动,先看内容再让它留在那里。
147
177
  - 会话日志可能包含任务执行过程和输出,默认仅本人可读(600),对外分享或截图前请留意内容。
148
178
  - 巡检、保活与续期都不发起网络请求,也不依赖任何第三方 npm 包;唯一的网络活动发生在**安装与升级**,即 npm 访问 registry.npmjs.org 拉取包本身(由你本机的 npm 配置与凭证负责)。除此之外的外部通信全部来自被启动的 agent 会话。
@@ -151,10 +181,13 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
151
181
 
152
182
  - 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行;唤醒后 launchd 会立即跑一轮巡检,错过的**一次性**任务会在后台会话启动时由 Claude Code 补执行。
153
183
  - Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
154
- - 周期任务预热成功后,后台会话会一直保留到任务清空、进程退出或自动换代(连续运行超 7 天且静默超 1 小时,见「维护」),**不会在每次触发后回收**:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。autoRenew 默认开启使这成为常态;自动换代把累积周期约束在 7 天空闲以内,更细粒度的「触发后回收、下次执行前重新预热」仍是后续方向。
184
+ - autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
185
+ - 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 Claude Code 官方支持任务路由。
155
186
  - 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
156
187
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
157
188
  - 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话。
189
+ - 「已有会话在消费」按目录判断,分不清消费者是我们的预热会话还是你本人打开的会话:卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。
190
+ - 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
158
191
  - 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
159
192
  - 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
160
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.21",
3
+ "version": "26.10.23",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "license": "MIT",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+ssh://git@github.com/adaex/cron-up.git"
22
+ "url": "git+https://github.com/adaex/cron-up.git"
23
23
  },
24
24
  "keywords": [
25
25
  "cron",
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { cmdOverview } from './overview.mjs';
12
12
  import { cmdLogs } from './logs.mjs';
13
13
 
14
14
  // 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
15
- // 开关;tri 支持 --name / --no-name(install 的 auto-renew)。
15
+ // 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
16
16
  const SPECS = {
17
17
  install: {
18
18
  options: {
@@ -21,6 +21,8 @@ const SPECS = {
21
21
  lead: 'int',
22
22
  force: 'bool',
23
23
  'auto-renew': 'tri',
24
+ 'auto-min-id': 'tri',
25
+ 'session-retain': 'string',
24
26
  },
25
27
  },
26
28
  uninstall: {
@@ -159,11 +161,11 @@ export const USAGE = `cron-up —— 为定时任务提前备好交互会话
159
161
 
160
162
  用法:
161
163
  cron-up 查看总览(服务、配置、任务、会话)
162
- cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --force --no-auto-renew)
164
+ cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --force --no-auto-renew --no-auto-min-id)
163
165
  cron-up uninstall 卸载(--purge 连配置日志一起删,--keep-sessions 保留后台会话)
164
- cron-up list 逐个列出定时任务及下次执行时间
166
+ cron-up list 逐个列出定时任务的设定/预计实际触发时间
165
167
  cron-up run 执行一轮巡检(launchd 入口)
166
- cron-up renew 立即给所有周期任务补 permanent
168
+ cron-up renew 立即给所有周期任务补 permanent 并改小 id
167
169
  cron-up logs [目录片段] 查看巡检日志或后台会话记录(-f 跟踪)
168
170
 
169
171
  升级:npm i -g cron-up@latest`;
package/src/config.mjs CHANGED
@@ -70,6 +70,14 @@ export function configFieldErrors(cfg) {
70
70
  errs.push(['autoRenew',
71
71
  `autoRenew 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoRenew)}`]);
72
72
  }
73
+ if (typeof cfg.autoMinId !== 'boolean') {
74
+ errs.push(['autoMinId',
75
+ `autoMinId 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoMinId)}`]);
76
+ }
77
+ if (cfg.sessionRetain !== 'window' && cfg.sessionRetain !== 'always') {
78
+ errs.push(['sessionRetain',
79
+ `sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
80
+ }
73
81
  return errs;
74
82
  }
75
83
 
@@ -149,10 +157,29 @@ export function loadState() {
149
157
  return out;
150
158
  }
151
159
 
160
+ // 自产物按私密收紧(与会话日志、任务文件写入同一纪律):state 含工作区路
161
+ // 径与 pid,多用户机器上不给其他账号读面。chmod 顺带收回老版本留下的宽松
162
+ // 权限——state 每轮都写、config 每次 install 都写,存量会自动跟进;失败维
163
+ // 持现状。
152
164
  function atomicSave(file, doc) {
153
- fs.mkdirSync(path.dirname(file), { recursive: true });
165
+ const dir = path.dirname(file);
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ try {
168
+ fs.chmodSync(dir, 0o700);
169
+ } catch {
170
+ // 维持现状。
171
+ }
154
172
  const tmp = `${file}.tmp`;
155
- fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
173
+ // 以 0600 创建;残留的旧 tmp 更宽时也收回来,rename 保持该模式位。
174
+ fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, {
175
+ flag: 'w',
176
+ mode: 0o600,
177
+ });
178
+ try {
179
+ fs.chmodSync(tmp, 0o600);
180
+ } catch {
181
+ // 维持 0600。
182
+ }
156
183
  fs.renameSync(tmp, file);
157
184
  }
158
185
 
package/src/constants.mjs CHANGED
@@ -14,6 +14,17 @@ export const DEFAULT_CONFIG = {
14
14
  // 全部补标。配置里 autoRenew:false 可关(下轮生效),或安装时
15
15
  // --no-auto-renew。
16
16
  autoRenew: true,
17
+ // 每轮巡检把周期任务的 id 改写成小哈希值,把投递抖动压到
18
+ // AUTO_MIN_ID_TARGET_DELAY_MS 以内(每日任务约 1 分钟),观感近乎准点。
19
+ // 只改显式周期任务、只分配文件内空号、幂等。配置 autoMinId:false 可关,
20
+ // 或安装时 --no-auto-min-id。机制见 CC_JITTER 与 findings 主题
21
+ // claude-code-scheduled-task-jitter。
22
+ autoMinId: true,
23
+ // 会话保留策略:'window'(默认)——会话生命周期对齐「一次执行」:任务
24
+ // 触发过、执行完毕且距下次需要还有余量即回收,下次窗口重拉,任务之间
25
+ // 上下文不互相累积;'always'——旧常驻行为,执行间隔里也保留会话,仅受
26
+ // 7 天超龄换代约束。
27
+ sessionRetain: 'window',
17
28
  };
18
29
 
19
30
  // 预热会话连续三次启动后死亡,大概率是环境坏了(缺信任、env 不对),退避
@@ -36,9 +47,14 @@ export const WARMUP_GRACE_SECONDS = 180;
36
47
  // 才退役——日志安静是没有任务在执行的证据,运行中的任务绝不会被切掉。
37
48
  export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
38
49
  export const SESSION_IDLE_SECONDS = 3600;
39
- // TUI typescript 是全屏渲染流,长驻时无限增长,拉起时轮转(script(1) 打开
40
- // 即截断,所以轮转粒度是一个预热会话的生命周期)。
41
- export const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
50
+ // window 模式回收判据一:界面静默超过此时长视为「本次执行已结束」。TUI
51
+ // 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;与
52
+ // WARMUP_GRACE 同值,对齐「3 分钟定生死」的既有心智。
53
+ export const SESSION_RECYCLE_IDLE_SECONDS = 180;
54
+ // window 模式回收判据二:距下次「需要会话在场」的时刻至少还有这么多秒。
55
+ // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
56
+ // 发直接复用现有会话(时间紧邻的任务共享)。
57
+ export const SESSION_FIRE_MARGIN_SECONDS = 120;
42
58
  // 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
43
59
  // 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
44
60
  export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
@@ -52,6 +68,33 @@ export const PRUNE_DIRS = new Set([
52
68
  '.cache', '.npm', '.local', '.config',
53
69
  ]);
54
70
 
71
+ // Claude Code 会话投递定时任务时的抖动参数,两处使用:list/总览预测
72
+ // 「预计实际触发时间」;巡检把「落点之后、延迟投递点之前」这段投递尾窗
73
+ // 也算作需要会话在场,避免 window 模式在任务真正投递前回收会话。逆向自
74
+ // Claude Code v2.1.285
75
+ // (darwin-arm64 原生二进制,2026-09-30 安装)内部 createCronScheduler 的
76
+ // gV 默认值:周期任务 fire = 落点 + min(hash(id)·recurringFrac·周期,
77
+ // recurringCapMs);一次性任务在 :00/:30 网格点提前 0~oneShotMaxMs。
78
+ // 这些值没有稳定性承诺——版本升级可能改,运行时还可能被远程配置键
79
+ // tengu_kairos_cron_config(60 秒刷新,校验失败回退默认)覆盖。预测与
80
+ // 实际系统性对不上时,按 findings 主题
81
+ // claude-code-scheduled-task-jitter 的排查步骤复核新版本。
82
+ export const CC_JITTER = {
83
+ recurringFrac: 0.5,
84
+ recurringCapMs: 30 * 60 * 1000,
85
+ oneShotMaxMs: 90 * 1000,
86
+ oneShotFloorMs: 0,
87
+ oneShotMinuteMod: 30,
88
+ };
89
+
90
+ // autoMinId 改小 id 的目标投递延迟上界。周期延迟 = hash(id)·frac·周期,
91
+ // 按每日周期(86400000ms,周期最短、要求最严)反解 id 数值上界:
92
+ // maxN = floor(目标延迟 / (frac·每日ms) · 2^32) ≈ 5_965_232 (0x005b05b0)
93
+ // id 数值不超过它的每日任务延迟 ≤ 60 秒;周/月任务周期更长,同阈值下延迟
94
+ // 更小。改小逻辑(src/tasks.mjs 的 minifyIds)从这里与 CC_JITTER 现算
95
+ // maxN,常量只保留「目标延迟」这一个可调项。
96
+ export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
97
+
55
98
  // 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
56
99
  // 不需要看得更远。展示页另算。
57
100
  export const SEARCH_DAYS = 7;
package/src/cron.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // 分钟级 cron 解析与下一次触发计算。字段全是别的程序写的,畸形表达式在
2
2
  // 边界处返回 null/不可满足,绝不抛进巡检循环。
3
3
 
4
- import { SEARCH_DAYS } from './constants.mjs';
4
+ import { SEARCH_DAYS, DISPLAY_SEARCH_DAYS, CC_JITTER } from './constants.mjs';
5
5
 
6
6
  const MS_DAY = 86400_000;
7
7
  const MS_MINUTE = 60_000;
@@ -113,6 +113,42 @@ export class Cron {
113
113
  }
114
114
  return null;
115
115
  }
116
+
117
+ // 不晚于 t 所在分钟的最近一次匹配(含当前分钟);withinDays 天前还没有
118
+ // 则 null。与 nextAfter 对称做整月/整小时快进,366 天窗口也便宜。用于
119
+ // 判断「现在是否落在某次触发的投递延迟尾窗里」。
120
+ prevAtOrBefore(t, withinDays = SEARCH_DAYS) {
121
+ if (!this.satisfiable) return null;
122
+ const cur = new Date(t.getTime());
123
+ cur.setSeconds(0, 0);
124
+ const deadline = cur.getTime() - withinDays * MS_DAY;
125
+ while (cur.getTime() >= deadline) {
126
+ if (!this.months.has(cur.getMonth() + 1)) {
127
+ // 退到上个月最后一分钟:置本月 1 号 00:00 再退 1 分钟。
128
+ cur.setDate(1);
129
+ cur.setHours(0, 0, 0, 0);
130
+ cur.setMinutes(cur.getMinutes() - 1);
131
+ continue;
132
+ }
133
+ if (!this.hours.has(cur.getHours())) {
134
+ // 退到严格更早的最近匹配小时的 59 分;当天已无更早匹配则落到前一
135
+ // 天的最大匹配小时(小时集合每天相同,satisfiable 保证其非空)。
136
+ let h = cur.getHours() - 1;
137
+ while (h >= 0 && !this.hours.has(h)) h -= 1;
138
+ if (h < 0) {
139
+ const maxH = Math.max(...this.hours);
140
+ cur.setDate(cur.getDate() - 1);
141
+ cur.setHours(maxH, 59, 0, 0);
142
+ } else {
143
+ cur.setHours(h, 59, 0, 0);
144
+ }
145
+ continue;
146
+ }
147
+ if (this.matches(cur)) return cur;
148
+ cur.setMinutes(cur.getMinutes() - 1);
149
+ }
150
+ return null;
151
+ }
116
152
  }
117
153
 
118
154
  // CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
@@ -168,6 +204,18 @@ function wanted(cron, task, now, leadMs) {
168
204
  span,
169
205
  );
170
206
  if (first && first.getTime() <= now.getTime()) return true;
207
+ return false;
208
+ }
209
+ // 周期任务:投递不是在落点,而是落点 + 按 id 确定的抖动(每日任务通常
210
+ // 30 分)。落点已过、抖动投递时刻未到的这段「投递尾窗」里会话仍必须在
211
+ // 场——否则 window 模式会把执行完近邻任务后空闲下来的会话回收且不重
212
+ // 拉,让本次触发漏跑(周期任务没有补执行)。找上一落点用 366 天窗口,
213
+ // 覆盖月/年任务;prevAtOrBefore 有日历快进,不贵。
214
+ const prev = cron.prevAtOrBefore(now, DISPLAY_SEARCH_DAYS);
215
+ if (prev !== null) {
216
+ const fireAt = prev.getTime()
217
+ + recurringDelayMs(cron, prev, task.id, DISPLAY_SEARCH_DAYS + 1);
218
+ if (now.getTime() < fireAt) return true;
171
219
  }
172
220
  return false;
173
221
  }
@@ -181,3 +229,55 @@ export function taskWanted(task, now, leadMs) {
181
229
 
182
230
  // wanted 供展示视图复用(同一个已解析 cron 顺带回答巡检问题)。
183
231
  export { wanted };
232
+
233
+ // ---- Claude Code 投递抖动(预计实际触发时间)----
234
+ // 复刻 v2.1.285 内部的 M():任务 id 前 8 位十六进制除以 2^32,得 [0,1) 的
235
+ // 固定哈希——同一任务每次触发的抖动完全相同,不是随机数。id 缺失或前 8
236
+ // 位都不是十六进制时 parseInt 得 NaN,上游回退 0;任务文件由别的程序书
237
+ // 写,这里同样永不抛。
238
+ export function taskIdHash(taskId) {
239
+ if (typeof taskId !== 'string') return 0;
240
+ const n = parseInt(taskId.slice(0, 8), 16) / 2 ** 32;
241
+ return Number.isFinite(n) ? n : 0;
242
+ }
243
+
244
+ // 任务按 Claude Code 的抖动规则预计的实际触发时刻;cron 无效、不可满足或
245
+ // 窗口内没有落点时返回 null。基准为 now(上游调度器以上次触发时刻为基
246
+ // 准,展示只关心从现在起的下一次,两者算出的落点一致)。
247
+ export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
248
+ const cron = parseCronOrNone(task?.cron);
249
+ if (cron === null || !cron.satisfiable) return null;
250
+ const i = cron.nextAfter(now, withinDays);
251
+ if (i === null) return null;
252
+ const h = taskIdHash(task?.id);
253
+
254
+ if (taskIsOneshot(task)) {
255
+ // 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
256
+ // 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
257
+ if (i.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) return i;
258
+ const ahead = CC_JITTER.oneShotFloorMs
259
+ + h * (CC_JITTER.oneShotMaxMs - CC_JITTER.oneShotFloorMs);
260
+ return new Date(Math.max(i.getTime() - ahead, now.getTime()));
261
+ }
262
+
263
+ // 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
264
+ // 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
265
+ // 边缘取不到时与上游一致:视为无后续,不抖动。
266
+ return new Date(i.getTime() + recurringDelayMs(cron, i, task?.id, withinDays));
267
+ // 注:上游 bOt 另有一个 cacheLeadMs 分支——仅当相邻落点间隔落在
268
+ // [300000, 315000)ms(约 5 分钟周期的高频 cron,如 */5)且表达式匹配
269
+ // 其内部步进正则时,调度时刻提前 15 秒。日/周/月任务周期远大于此,不
270
+ // 经过该分支,故不予复刻。
271
+ }
272
+
273
+ // 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
274
+ // slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
275
+ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
276
+ const next = cron.nextAfter(new Date(slot.getTime()), withinDays);
277
+ if (next === null) return 0;
278
+ const period = next.getTime() - slot.getTime();
279
+ return Math.min(
280
+ taskIdHash(taskId) * CC_JITTER.recurringFrac * period,
281
+ CC_JITTER.recurringCapMs,
282
+ );
283
+ }
package/src/display.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  // 等宽终端展示原语与任务的展示视图。
2
2
 
3
- import { parseCronOrNone, taskIsOneshot, wanted } from './cron.mjs';
3
+ import {
4
+ parseCronOrNone,
5
+ taskIsOneshot,
6
+ wanted,
7
+ predictedFire,
8
+ } from './cron.mjs';
4
9
  import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
5
10
 
6
11
  // 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
@@ -81,6 +86,7 @@ export function taskView(task, now, leadMs = undefined) {
81
86
  summary: taskSummary(task),
82
87
  permanent: false,
83
88
  wanted: null,
89
+ fire: null,
84
90
  };
85
91
  }
86
92
  const nxt = cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
@@ -98,9 +104,46 @@ export function taskView(task, now, leadMs = undefined) {
98
104
  // wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
99
105
  // 都是 null,键的形状不随参数变化。
100
106
  wanted: leadMs !== undefined ? wanted(cron, task, now, leadMs) : null,
107
+ // 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
108
+ // 没有未来触发,为 null。与 nxt 同为 Date 或 null。
109
+ fire: missed ? null : predictedFire(task, now),
101
110
  };
102
111
  }
103
112
 
113
+ // MM-DD HH:MM 口径下是否同一天/同一分钟。
114
+ function sameDay(a, b) {
115
+ return a.getFullYear() === b.getFullYear()
116
+ && a.getMonth() === b.getMonth()
117
+ && a.getDate() === b.getDate();
118
+ }
119
+ function sameMinute(a, b) {
120
+ return sameDay(a, b) && a.getHours() === b.getHours()
121
+ && a.getMinutes() === b.getMinutes();
122
+ }
123
+
124
+ // 「设定落点 → 预计实际触发」文案。抖动四舍五入到秒(调度器秒级轮询):
125
+ // 同日省略第二个日期;预计值带非零秒时显示 HH:MM:SS(如 05:15:37);落
126
+ // 在同一分钟(无抖动)则只显示落点,不画无意义的箭头。
127
+ export function fmtFireRange(nxt, fire) {
128
+ if (!(fire instanceof Date) || !Number.isFinite(fire.getTime())) {
129
+ return fmtMDHM(nxt);
130
+ }
131
+ const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
132
+ if (sameMinute(nxt, r)) return fmtMDHM(nxt);
133
+ const p = (n) => String(n).padStart(2, '0');
134
+ const tail = sameDay(nxt, r)
135
+ ? `${p(r.getHours())}:${p(r.getMinutes())}`
136
+ + (r.getSeconds() ? `:${p(r.getSeconds())}` : '')
137
+ : `${fmtMDHM(r)}${r.getSeconds() ? `:${p(r.getSeconds())}` : ''}`;
138
+ return `${fmtMDHM(nxt)} → ${tail}`;
139
+ }
140
+
141
+ // 仅 HH:MM(总览「(设定 HH:MM)」附注用)。
142
+ export function fmtHM(d) {
143
+ const p = (n) => String(n).padStart(2, '0');
144
+ return `${p(d.getHours())}:${p(d.getMinutes())}`;
145
+ }
146
+
104
147
  // MM-DD HH:MM(任务触发点的统一展示格式)。
105
148
  export function fmtMDHM(d) {
106
149
  const p = (n) => String(n).padStart(2, '0');
package/src/list.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  pad,
9
9
  dispWidth,
10
10
  taskView,
11
- fmtMDHM,
11
+ fmtFireRange,
12
12
  } from './display.mjs';
13
13
 
14
14
  function rank(v) {
@@ -17,6 +17,14 @@ function rank(v) {
17
17
  return v.nxt ? 1 : 2;
18
18
  }
19
19
 
20
+ // 状态列文案:有效任务是「设定落点 → 预计实际触发」(无抖动时只有落点)。
21
+ function statusText(v) {
22
+ if (!v.valid) return 'cron 无效';
23
+ if (v.missed) return '已错过';
24
+ if (!v.nxt) return '一年内无';
25
+ return fmtFireRange(v.nxt, v.fire);
26
+ }
27
+
20
28
  export async function cmdList(args) {
21
29
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
22
30
  const now = new Date();
@@ -60,41 +68,41 @@ export async function cmdList(args) {
60
68
  blocks.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
61
69
  deps.print(`共 ${blocks.length} 个工作区、${total} 个定时任务`);
62
70
 
63
- // 列宽从全部候选文案动态算(时间戳 "09-25 08:30" 恒为 11 列),改文案
64
- // 不会静默错位;cadence 是截断列,取固定上限。
71
+ // 列宽从全部实际文案动态算(跨天箭头最长 "09-25 08:30 → 09-26 09:00"),
72
+ // 改文案不会静默错位;cadence 是截断列,取固定上限。
65
73
  const statusW = Math.max(11,
66
- ...['cron 无效', '已错过', '一年内无'].map(dispWidth));
74
+ ...blocks.flatMap(([, , views]) => views.map((v) => dispWidth(statusText(v)))));
67
75
  const kindW = Math.max(...['一次性', '周期', '周期·永久'].map(dispWidth)) + 1;
68
76
  const cadW = 16;
69
- // " " + status + " " + kind + " " + cadence + " " + summary
70
- const summaryW = Math.max(12,
71
- cols - (2 + statusW + 2 + kindW + 2 + cadW + 2));
77
+ // 固定列(不含摘要段):" " status " " kind " " cadence。摘要段再占
78
+ // " " + summaryW。空间紧张时摘要先让位;为 0 则整段省略——状态箭头在
79
+ // 60 列下限下本身就可能占 25 列(跨天),不能再用 12 列摘要下限把行撑爆。
80
+ const fixedW = 2 + statusW + 2 + kindW + 2 + cadW;
81
+ const summaryW = Math.max(0, cols - fixedW - 2);
72
82
  for (const [ws, consumer, views] of blocks) {
73
83
  deps.print('');
74
84
  const label = consumer ? '交互会话:有' : '交互会话:无';
75
85
  const pathW = cols - dispWidth(label) - 1;
76
86
  deps.print(`${pad(clip(ws, pathW), pathW)} ${label}`);
77
87
  for (const v of views) {
78
- let status;
79
88
  let kind;
80
89
  let cadence;
81
90
  let summary;
82
91
  if (!v.valid) {
83
- status = 'cron 无效';
84
92
  kind = '—';
85
93
  cadence = '—';
86
94
  summary = `原字段值:${JSON.stringify(v.expr)};${v.summary}`;
87
95
  } else {
88
- // 已错过:一次性任务触发点已过,下次会话启动时补执行。missed 时
89
- // permanent 恒为 false(taskView 对一次性任务剔除了该标记)。
90
- status = v.missed ? '已错过'
91
- : (v.nxt ? fmtMDHM(v.nxt) : '一年内无');
92
96
  kind = v.permanent ? '周期·永久' : v.kind;
93
97
  cadence = v.cadence;
94
98
  summary = v.summary;
95
99
  }
96
- deps.print(' ' + pad(status, statusW) + ' ' + pad(kind, kindW) + ' '
97
- + pad(clip(cadence, cadW), cadW) + ' ' + clip(summary, summaryW));
100
+ let line = ' ' + pad(statusText(v), statusW) + ' '
101
+ + pad(kind, kindW) + ' '
102
+ + pad(clip(cadence, cadW), cadW);
103
+ // 极窄屏摘要列算到 0:整段省略(含前导间隔),保证行宽不超终端。
104
+ if (summaryW > 0) line += ' ' + clip(summary, summaryW);
105
+ deps.print(line);
98
106
  }
99
107
  }
100
108
  }
package/src/overview.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  taskView,
18
18
  sessionAgeZh,
19
19
  fmtMDHM,
20
+ fmtHM,
20
21
  } from './display.mjs';
21
22
 
22
23
  // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
@@ -59,8 +60,10 @@ function overviewTasks(cfg, cols, consumers, state) {
59
60
  bad += 1;
60
61
  continue;
61
62
  }
62
- if (v.nxt && (!soonest || v.nxt.getTime() < soonest[0].getTime())) {
63
- soonest = [v.nxt, ws, v.summary];
63
+ // 「最近」按计入抖动后的预计实际触发排,而非 cron 落点。
64
+ const fire = v.fire ?? v.nxt;
65
+ if (fire && (!soonest || fire.getTime() < soonest[0].getTime())) {
66
+ soonest = [fire, ws, v.summary, v.nxt];
64
67
  }
65
68
  if (v.wanted) wanted = true;
66
69
  }
@@ -96,9 +99,13 @@ function overviewTasks(cfg, cols, consumers, state) {
96
99
  ? `任务:${nTasks} 个,分布在 ${nWs} 个工作区`
97
100
  : '任务:暂无');
98
101
  if (soonest) {
99
- const [nxt, ws, summary] = soonest;
100
- const line = `最近:${fmtMDHM(nxt)}(${humanDelta(nxt.getTime() - now.getTime())}) `
101
- + `${path.basename(ws)} · ${summary}`;
102
+ const [fire, ws, summary, nxt] = soonest;
103
+ // 预计与设定不在同一分钟时,附注 cron 落点,解释两个时间的差。
104
+ const setTag = (nxt && (fmtHM(fire) !== fmtHM(nxt)
105
+ || fire.getDate() !== nxt.getDate()))
106
+ ? `,设定 ${fmtHM(nxt)}` : '';
107
+ const line = `最近:${fmtMDHM(fire)}(${humanDelta(fire.getTime() - now.getTime())}`
108
+ + `${setTag}) ${path.basename(ws)} · ${summary}`;
102
109
  deps.print(clip(line, cols));
103
110
  }
104
111
  if (!cfg.autoRenew && unrenewed > 0) {
@@ -155,17 +162,19 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
155
162
  }
156
163
 
157
164
  const COMMANDS = [
158
- ['list', '逐个列出任务的内容摘要、执行时间与会话状态'],
165
+ ['list', '逐个列出任务的内容摘要、设定/预计触发时间与会话状态'],
159
166
  ['logs [目录片段] [-f]', '查看巡检日志或后台会话记录'],
160
167
  ['run', '立即手动巡检一轮'],
161
- ['renew', '立即给所有周期任务补 permanent(跳过 7 天过期)'],
168
+ ['renew', '立即给所有周期任务补 permanent 并改小 id'],
162
169
  ['install --force', '更新配置并重新安装(全部参数见 --help)'],
163
170
  ];
164
171
 
165
172
  export async function cmdOverview(args) {
166
173
  const cols = deps.terminalWidth();
167
174
  const v = packageVersion();
168
- deps.print(`cron-up ${v ?? ''} —— 为定时任务提前备好交互会话`);
175
+ deps.print(v === null
176
+ ? 'cron-up —— 为定时任务提前备好交互会话'
177
+ : `cron-up ${v} —— 为定时任务提前备好交互会话`);
169
178
 
170
179
  const cfgPath = args?.config ?? deps.paths.configPath;
171
180
  let cfg = null;
@@ -200,12 +209,15 @@ export async function cmdOverview(args) {
200
209
  } else {
201
210
  const actualIv = info ? info.interval : null;
202
211
  const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
212
+ const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
213
+ const retainTag = cfg.sessionRetain === 'always'
214
+ ? ',会话常驻' : ',会话按执行回收';
203
215
  if (actualIv === null || actualIv === undefined) {
204
216
  deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
205
- + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}`);
217
+ + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}`);
206
218
  } else {
207
219
  deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
208
- + `会话${renewTag}`);
220
+ + `会话${renewTag}${minIdTag}${retainTag}`);
209
221
  if (actualIv !== cfg.intervalSeconds) {
210
222
  alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
211
223
  + '秒不一致,重新运行 cron-up install 后生效');
package/src/patrol.mjs CHANGED
@@ -13,11 +13,13 @@ import {
13
13
  WARMUP_GRACE_SECONDS,
14
14
  SESSION_MAX_AGE_SECONDS,
15
15
  SESSION_IDLE_SECONDS,
16
+ SESSION_RECYCLE_IDLE_SECONDS,
17
+ SESSION_FIRE_MARGIN_SECONDS,
16
18
  PATROL_LOG_ROTATE_BYTES,
17
19
  } from './constants.mjs';
18
20
  import { loadConfig, loadState, saveState } from './config.mjs';
19
- import { renewWorkspace, TaskFileChanged } from './tasks.mjs';
20
- import { taskWanted } from './cron.mjs';
21
+ import { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
22
+ import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
21
23
  import {
22
24
  sessionLogPath,
23
25
  sessionLogIdleSeconds,
@@ -124,9 +126,25 @@ export function rotatePatrolLogs() {
124
126
  }
125
127
  }
126
128
 
127
- // 保证一个工作区里有一个健康的消费者会话。原地修改 state。
129
+ // 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
130
+ // 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。
131
+ function needHorizonMs(tasks, now) {
132
+ let horizon = Infinity;
133
+ for (const t of tasks) {
134
+ const cron = parseCronOrNone(t?.cron);
135
+ if (cron === null) continue;
136
+ if (wanted(cron, t, now, 0)) return now.getTime();
137
+ const nxt = cron.nextAfter(now)?.getTime();
138
+ if (nxt !== undefined && nxt < horizon) horizon = nxt;
139
+ }
140
+ return horizon;
141
+ }
142
+
143
+ // 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
144
+ // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
128
145
  export async function patrolWorkspace(
129
146
  ws, state, now, leadMs, cur, consumers, scriptProcs = [],
147
+ retain = 'always',
130
148
  ) {
131
149
  const tasks = deps.readTasks(ws);
132
150
  let ent = state[ws] ?? null;
@@ -169,9 +187,42 @@ export async function patrolWorkspace(
169
187
  delete state[ws];
170
188
  ent = null;
171
189
  mineAlive = false;
190
+ // 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
191
+ // 次,别让陈旧登记挡住下面的重拉。
192
+ const [fresh] = await deps.scanSessions();
193
+ consumers = fresh;
172
194
  }
173
195
  }
174
196
 
197
+ // window 模式回收:会话生命周期对齐「一次执行」而非「常驻」。三个条件
198
+ // 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
199
+ // ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
200
+ // 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
201
+ // 即不在干活);距下次需要会话还有余量(回收后同轮重拉,新会话来得及
202
+ // 登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任务路
203
+ // 由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复执行的
204
+ // 风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计数一并
205
+ // 清零。
206
+ if (ent !== null && mineAlive && retain === 'window'
207
+ && tasks.some((t) => typeof t?.lastFiredAt === 'number'
208
+ && t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
209
+ && sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
210
+ > SESSION_RECYCLE_IDLE_SECONDS
211
+ && needHorizonMs(tasks, now) - now.getTime()
212
+ > SESSION_FIRE_MARGIN_SECONDS * 1000) {
213
+ const pid = ent.pid;
214
+ await deps.stopSession(ent);
215
+ deps.log(`回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
216
+ + '结束会话,下次需要时重拉');
217
+ delete state[ws];
218
+ ent = null;
219
+ mineAlive = false;
220
+ // 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
221
+ // 重扫依旧看见,不会在同目录双开。
222
+ const [fresh] = await deps.scanSessions();
223
+ consumers = fresh;
224
+ }
225
+
175
226
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
176
227
  // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
177
228
  // 却,连一次新尝试都不给。
@@ -184,13 +235,10 @@ export async function patrolWorkspace(
184
235
  const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
185
236
 
186
237
  if (consumers.has(ws)) {
187
- // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
188
- // (活消费者证明健康 → 计数清零),后者放手——但不在这里删死条目,失败
189
- // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
190
- // 由轮末 kept 过滤统一处理。
191
- if (ent !== null && mineAlive) {
192
- ent.fails = 0;
193
- }
238
+ // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
239
+ // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
240
+ // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
241
+ // 末 kept 过滤统一处理。
194
242
  return;
195
243
  }
196
244
 
@@ -305,27 +353,37 @@ export async function cmdRun(args) {
305
353
 
306
354
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
307
355
  // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
308
- // 后面所有工作区。续期与保活各自独立守卫——续期失败绝不压制该工作
309
- // 区的会话保活。
310
- if (cfg.autoRenew) {
356
+ // 后面所有工作区。续期/改 id 与保活各自独立守卫——维护失败绝不压制
357
+ // 该工作区的会话保活。两者合并为同一次原子写(见 maintainWorkspace)。
358
+ if (cfg.autoRenew || cfg.autoMinId) {
311
359
  try {
312
- const n = renewWorkspace(taskfile);
313
- if (n) {
314
- deps.log(`已续期 ${ws}:${n} 个周期任务标记为 permanent,`
360
+ const { renewed, minified } = maintainWorkspace(taskfile, {
361
+ renew: cfg.autoRenew,
362
+ minify: cfg.autoMinId,
363
+ }) ?? { renewed: null, minified: null };
364
+ if (renewed) {
365
+ deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
315
366
  + '不再受 7 天过期限制');
316
367
  }
368
+ if (minified) {
369
+ deps.log(`已改小 id ${ws}:${minified} 个周期任务的 id 改为小哈希`
370
+ + '值,投递延迟降到约 1 分钟内;下次预热拉起的新会话起生效,'
371
+ + '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
372
+ + 'always 模式换代)后才读到新 id');
373
+ }
317
374
  } catch (e) {
318
375
  if (e instanceof TaskFileChanged) {
319
- deps.log(`续期暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
320
- + '本轮不覆盖,下轮再续');
376
+ deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
377
+ + '本轮不覆盖,下轮再来');
321
378
  } else {
322
- deps.log(`续期错误 ${ws}:${e.constructor.name}:${e.message}`);
379
+ deps.log(`维护错误 ${ws}:${e.constructor.name}:${e.message}`);
323
380
  }
324
381
  }
325
382
  }
326
383
  try {
327
384
  await patrolWorkspace(
328
- ws, state, now, leadMs, cur, consumers, scriptProcs);
385
+ ws, state, now, leadMs, cur, consumers, scriptProcs,
386
+ cfg.sessionRetain);
329
387
  } catch (e) {
330
388
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
331
389
  }
@@ -353,8 +411,9 @@ export async function cmdRun(args) {
353
411
  }
354
412
  }
355
413
 
356
- // 立即给所有周期任务补 permanent。忽略 cfg.autoRenew——手动跑命令本身就
357
- // 是显式指令。与巡检共用同一把锁。
414
+ // 立即给所有周期任务补 permanent 并把 id 改小。忽略 cfg.autoRenew /
415
+ // cfg.autoMinId——手动跑命令本身就是显式指令(想只续期不改 id 的场景极
416
+ // 少,真需要可临时关掉配置后跑巡检)。与巡检共用同一把锁。
358
417
  export async function cmdRenew(args) {
359
418
  const lock = deps.acquireRunLock();
360
419
  if (lock === null) {
@@ -365,13 +424,14 @@ export async function cmdRenew(args) {
365
424
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
366
425
  let files = 0;
367
426
  let tagged = 0;
427
+ let minified = 0;
368
428
  let unchanged = 0;
369
429
  const skipped = [];
370
430
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
371
431
  files += 1;
372
- let n;
432
+ let r;
373
433
  try {
374
- n = renewWorkspace(taskfile);
434
+ r = maintainWorkspace(taskfile, { renew: true, minify: true });
375
435
  } catch (e) {
376
436
  if (e instanceof TaskFileChanged) {
377
437
  deps.print(`暂缓 ${ws}:任务文件刚被 Claude Code 改动,本次未写入,`
@@ -383,20 +443,30 @@ export async function cmdRenew(args) {
383
443
  skipped.push(ws);
384
444
  continue;
385
445
  }
386
- if (n === null) {
446
+ if (r === null) {
387
447
  deps.print(`跳过 ${ws}:任务文件读不出或格式损坏`);
388
448
  skipped.push(ws);
389
- } else if (n) {
390
- tagged += n;
391
- deps.print(`已续期 ${ws}:${n} 个周期任务标记为 permanent`);
449
+ continue;
450
+ }
451
+ if (r.renewed || r.minified) {
452
+ if (r.renewed) {
453
+ tagged += r.renewed;
454
+ deps.print(`已续期 ${ws}:${r.renewed} 个周期任务标记为 permanent`);
455
+ }
456
+ if (r.minified) {
457
+ minified += r.minified;
458
+ deps.print(`已改小 id ${ws}:${r.minified} 个周期任务投递延迟降到约`
459
+ + ' 1 分钟内(下次预热的新会话起生效)');
460
+ }
392
461
  } else {
393
462
  unchanged += 1;
394
- deps.print(`已是最新 ${ws}:周期任务均已 permanent,无需改动`);
463
+ deps.print(`已是最新 ${ws}:周期任务均已 permanent 且 id 已足够小,`
464
+ + '无需改动');
395
465
  }
396
466
  }
397
467
  deps.print('');
398
- let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个任务,`
399
- + `${unchanged} 个文件无需改动`;
468
+ let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个、改小 id `
469
+ + `${minified} 个任务,${unchanged} 个文件无需改动`;
400
470
  if (skipped.length) line += `,${skipped.length} 个跳过`;
401
471
  deps.print(line);
402
472
  if (tagged) {
package/src/service.mjs CHANGED
@@ -244,6 +244,13 @@ export function launcherHealth() {
244
244
  // ---- install / uninstall ----
245
245
 
246
246
  export async function cmdInstall(args) {
247
+ // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
248
+ // 路径的配置,指定别的路径不改变任何行为——静默忽略等于让人以为装到了
249
+ // 别处。查询与巡检命令(run/renew/list/总览)才真正支持 --config。
250
+ if (args.config !== undefined) {
251
+ throw new ExitError(2, 'install 不支持 --config:配置与 LaunchAgent 固定'
252
+ + `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
253
+ }
247
254
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
248
255
  fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
249
256
  try {
@@ -273,6 +280,8 @@ export async function cmdInstall(args) {
273
280
  if (args.interval !== undefined) ignored.push('--interval');
274
281
  if (args.lead !== undefined) ignored.push('--lead');
275
282
  if (args.autoRenew !== undefined) ignored.push('--auto-renew');
283
+ if (args.autoMinId !== undefined) ignored.push('--auto-min-id');
284
+ if (args.sessionRetain !== undefined) ignored.push('--session-retain');
276
285
  if (ignored.length > 0) {
277
286
  deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
278
287
  + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
@@ -312,10 +321,32 @@ export async function cmdInstall(args) {
312
321
  overrides.roots = roots;
313
322
  }
314
323
  // is not undefined,而非 truthiness:--lead 0(「不要提前量」)是合法
315
- // 选择,不能被悄悄丢掉;--interval 0 也要留到下面的校验被响亮拒绝。
316
- if (args.interval !== undefined) overrides.intervalSeconds = args.interval;
317
- if (args.lead !== undefined) overrides.leadSeconds = args.lead;
324
+ // 选择,不能被悄悄丢掉。值域就地拦且必须在 saveConfig 之前:文件继承
325
+ // 的值已由 loadConfig / configFieldErrors 把关,命令行值只经过整数解
326
+ // 析——被拒的安装若把非法值写进配置,此后每轮巡检都会以退出码 2 死掉。
327
+ if (args.interval !== undefined) {
328
+ if (!isInt(args.interval) || args.interval <= 0) {
329
+ deps.printErr(`--interval 必须是正整数秒,当前为 ${JSON.stringify(args.interval)}`);
330
+ throw new ExitError(1);
331
+ }
332
+ overrides.intervalSeconds = args.interval;
333
+ }
334
+ if (args.lead !== undefined) {
335
+ if (!isInt(args.lead) || args.lead < 0) {
336
+ deps.printErr(`--lead 必须是非负整数秒,当前为 ${JSON.stringify(args.lead)}`);
337
+ throw new ExitError(1);
338
+ }
339
+ overrides.leadSeconds = args.lead;
340
+ }
318
341
  if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
342
+ if (args.autoMinId !== undefined) overrides.autoMinId = args.autoMinId;
343
+ if (args.sessionRetain !== undefined) {
344
+ if (args.sessionRetain !== 'window' && args.sessionRetain !== 'always') {
345
+ deps.printErr(`--session-retain 只接受 window 或 always,当前为 ${JSON.stringify(args.sessionRetain)}`);
346
+ throw new ExitError(1);
347
+ }
348
+ overrides.sessionRetain = args.sessionRetain;
349
+ }
319
350
  cfg = { ...cfg, ...overrides };
320
351
  cfg.roots = normalizeRoots(cfg.roots ?? []);
321
352
  saveConfig(cfg);
@@ -324,18 +355,7 @@ export async function cmdInstall(args) {
324
355
  deps.print(`已写入配置:${deps.paths.configPath}(更新字段:${updated},${rest})`);
325
356
  }
326
357
 
327
- // 动系统前先校验生效配置:文件继承的值由 loadConfig / configFieldErrors
328
- // 保证类型,但命令行给的 --interval/--lead 只经过整数解析,取值范围要在
329
- // 这里拦(--interval 0 非法、--lead 0 是合法选择);软问题(提前量过
330
- // 小、目录缺失)只警告。
331
- if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
332
- deps.printErr(`intervalSeconds 必须是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`);
333
- throw new ExitError(1);
334
- }
335
- if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
336
- deps.printErr(`leadSeconds 必须是非负整数秒,当前为 ${JSON.stringify(cfg.leadSeconds)}`);
337
- throw new ExitError(1);
338
- }
358
+ // 命令行值的硬校验已在落盘前完成;软问题(提前量过小、目录缺失)只警告。
339
359
  validateConfig(cfg, (m) => deps.print(m));
340
360
  if (JSON.stringify(cfg.roots)
341
361
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
@@ -376,6 +396,12 @@ export async function cmdInstall(args) {
376
396
  }
377
397
 
378
398
  export async function cmdUninstall(args) {
399
+ // 同 install:uninstall 动的是本机固定的安装产物,--config 只会让人以为
400
+ // 卸载的是另一套安装(--purge 删的也是真实目录)。
401
+ if (args.config !== undefined) {
402
+ throw new ExitError(2, 'uninstall 不支持 --config:卸载操作的是本机固定'
403
+ + `的安装产物(${deps.paths.plistPath} 等)`);
404
+ }
379
405
  const r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
380
406
  if (r.status === 0) deps.print('已从 launchd 卸载');
381
407
 
package/src/sessions.mjs CHANGED
@@ -7,7 +7,6 @@ import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
9
  import {
10
- LOG_ROTATE_BYTES,
11
10
  SESSION_IDLE_SECONDS,
12
11
  } from './constants.mjs';
13
12
 
@@ -98,10 +97,12 @@ export function spawnSession(ws) {
98
97
  } catch {
99
98
  // 目录权限维持现状。
100
99
  }
101
- // script(1) 不带 O_APPEND 打开 typescript,下次 spawn 会截断,所以只轮
102
- // 转仍在增长的会话留下的超大日志。
100
+ // window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
101
+ // 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
102
+ // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
103
+ // 轮转放大。
103
104
  try {
104
- if (fs.existsSync(logPath) && fs.statSync(logPath).size > LOG_ROTATE_BYTES) {
105
+ if (fs.existsSync(logPath)) {
105
106
  const old = `${logPath}.1`;
106
107
  fs.rmSync(old, { force: true });
107
108
  fs.renameSync(logPath, old);
package/src/tasks.mjs CHANGED
@@ -7,7 +7,11 @@ import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
9
  import { TASK_REL } from './paths.mjs';
10
- import { PRUNE_DIRS } from './constants.mjs';
10
+ import {
11
+ PRUNE_DIRS,
12
+ CC_JITTER,
13
+ AUTO_MIN_ID_TARGET_DELAY_MS,
14
+ } from './constants.mjs';
11
15
 
12
16
  export function expandHome(p) {
13
17
  const home = deps.paths.home;
@@ -192,12 +196,60 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
192
196
  fs.renameSync(tmp, file);
193
197
  }
194
198
 
195
- // 给所有缺 truthy permanent 标记的周期任务打上标记。
196
- // 返回:打标的数量(文件只重写一次);0 表示文件已全部标好(字节级不
197
- // 动,这是 5 分钟巡检不会永远重写别人文件的幂等性);null 表示文件缺失/
198
- // 损坏;读到的与写间被改抛 TaskFileChanged。一次性任务与无标志的手写文
199
- // 件永不修改。
200
- export function renewWorkspace(taskfile) {
199
+ // 严格 8 位十六进制的任务 id(Claude Code 自己生成的形状)转数值;其他
200
+ // 形状(短串、UUID、缺失、非字符串)返回 null——它们算「未达标」,由
201
+ // minifyIds 改写成合规小号。
202
+ export function parseTaskIdN(id) {
203
+ if (typeof id !== 'string' || !/^[0-9a-fA-F]{8}$/.test(id)) return null;
204
+ return parseInt(id, 16);
205
+ }
206
+
207
+ // autoMinId 的 id 数值上界:每日任务延迟 ≤ AUTO_MIN_ID_TARGET_DELAY_MS。
208
+ export function minTaskIdMaxN() {
209
+ return Math.floor(
210
+ AUTO_MIN_ID_TARGET_DELAY_MS
211
+ / (CC_JITTER.recurringFrac * 86400_000) * 2 ** 32);
212
+ }
213
+
214
+ // 把显式周期任务里 id 数值超过上界(或形状不合法)的,改写成文件内未占用
215
+ // 的小号(00000001 起的 8 位十六进制),使其投递抖动降到目标延迟内。原地
216
+ // 修改 tasks 元素,返回改写条数。关键纪律:
217
+ // - 只碰 t.recurring truthy 的显式周期任务,一次性与无标志手写条目不碰;
218
+ // - 已达标的小号(含 00000000)原样保留,永不重排——多次巡检结果稳定,
219
+ // 第二轮起 0 改动、文件字节不动;
220
+ // - 空号在「文件内全部任务」(含一次性)已占用的 id 之外分配,保证同文
221
+ // 件唯一;跨文件重复无妨(各工作区独立加载)。
222
+ export function minifyIds(tasks) {
223
+ const maxN = minTaskIdMaxN();
224
+ const used = new Set();
225
+ for (const t of tasks) {
226
+ if (!isPlainObject(t)) continue;
227
+ const n = parseTaskIdN(t.id);
228
+ if (n !== null) used.add(n);
229
+ }
230
+ let next = 1;
231
+ const takeFree = () => {
232
+ while (used.has(next)) next += 1;
233
+ used.add(next);
234
+ return next;
235
+ };
236
+ let changed = 0;
237
+ for (const t of tasks) {
238
+ if (!isPlainObject(t) || !t.recurring) continue;
239
+ const n = parseTaskIdN(t.id);
240
+ if (n !== null && n <= maxN) continue;
241
+ t.id = takeFree().toString(16).padStart(8, '0');
242
+ changed += 1;
243
+ }
244
+ return changed;
245
+ }
246
+
247
+ // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 minify(id 改
248
+ // 小)合并为同一次受 mtime 乐观锁保护的原子写。外壳与旧 renewWorkspace
249
+ // 完全一致:先取 mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整
250
+ // 文档;两项计数都为 0 时字节级不动。返回 {renewed,minified};null 表示
251
+ // 文件缺失/损坏;读到写间被改抛 TaskFileChanged。
252
+ export function maintainWorkspace(taskfile, { renew = false, minify = false } = {}) {
201
253
  let mtimeNs;
202
254
  try {
203
255
  mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
@@ -205,7 +257,7 @@ export function renewWorkspace(taskfile) {
205
257
  return null;
206
258
  }
207
259
  // 上次写一半被杀(SIGKILL/断电)留下的临时文件会永远躺在用户的仓库里
208
- // ——已全部标好的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
260
+ // ——已无需改动的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
209
261
  // 且不碰别人。
210
262
  try {
211
263
  fs.unlinkSync(tmpSibling(taskfile));
@@ -214,20 +266,29 @@ export function renewWorkspace(taskfile) {
214
266
  }
215
267
  const doc = deps.loadTaskDoc(taskfile);
216
268
  if (doc === null) return null;
217
- let tagged = 0;
218
- for (const t of doc.tasks) {
219
- // truthy 而非 === true:与 Claude Code 自己的加载器一致
220
- // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
221
- // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
222
- // 一次性任务不碰。
223
- if (isPlainObject(t) && t.recurring && !t.permanent) {
224
- t.permanent = true;
225
- tagged += 1;
269
+ let renewed = 0;
270
+ if (renew) {
271
+ for (const t of doc.tasks) {
272
+ // truthy 而非 === true:与 Claude Code 自己的加载器一致
273
+ // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
274
+ // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
275
+ // 一次性任务不碰。
276
+ if (isPlainObject(t) && t.recurring && !t.permanent) {
277
+ t.permanent = true;
278
+ renewed += 1;
279
+ }
226
280
  }
227
281
  }
228
- if (tagged === 0) return 0;
282
+ const minified = minify ? minifyIds(doc.tasks) : 0;
283
+ if (renewed === 0 && minified === 0) return { renewed: 0, minified: 0 };
229
284
  atomicWriteJson(taskfile, doc, mtimeNs);
230
- return tagged;
285
+ return { renewed, minified };
286
+ }
287
+
288
+ // 手动 renew 的薄封装:只补 permanent(不改 id),保持 null/0/N 三态契约。
289
+ export function renewWorkspace(taskfile) {
290
+ const r = maintainWorkspace(taskfile, { renew: true });
291
+ return r === null ? null : r.renewed;
231
292
  }
232
293
 
233
294
  Object.assign(deps, { discover, readTasks, loadTaskDoc });