cron-up 26.9.6 → 26.9.7

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
@@ -46,7 +46,7 @@ cron-up install --roots ~/code,~/work --interval 300 --lead 600
46
46
 
47
47
  第二步会写入配置与 LaunchAgent 并立即跑首轮巡检;参数都可省略,不带参数则用默认扫描范围与间隔。安装不需要 sudo(前提是你的 npm 全局目录在用户家目录下,fnm/volta/nvm/Homebrew 默认都满足)。
48
48
 
49
- `--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描家目录下两层以内(`~/` 自身、`~/*` 与 `~/*/*`),并自动跳过 `Library`、`Documents` 等系统目录和 `.git`、`node_modules`、`.cache` 等目录;建议显式指定,范围越小越安全、越快。使用默认范围安装时 install 会再打印一次安全提示:今后 clone 进家目录的任何仓库都会进入巡检,其中的周期任务会被自动续期永久化(原理见「安全说明」)。
49
+ `--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描 `~/space`、`~/workspace`、`~/tasks` 三个容器(不存在的目录跳过),深度两层以内,并自动跳过 `Library`、`Documents` 等系统目录和 `.git`、`node_modules`、`.cache` 等目录;目录布局不同或需要收窄时建议显式指定,范围越小越安全、越快。使用默认范围安装时 install 会再打印一次安全提示:今后 clone 进这三个容器的仓库也会进入巡检,其中的周期任务会被自动续期永久化(原理见「安全说明」)。
50
50
 
51
51
  launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),npm 全局命令那个 `#!/usr/bin/env node` 壳在它下面找不到 node。因此安装时把**node 与 cron-up 所在的 bin 目录**(fnm 是 `~/.local/share/fnm/aliases/default/bin`——`fnm default` 切换时这个 symlink 自己跟着切;volta 是 `~/.volta/bin`;Homebrew/官网是系统 bin 目录;其余安装方式取当前 node 的 bin 目录)写进一个自有的启动脚本(`cron-up-service`)的 PATH,脚本直接 `exec cron-up run`——cron-up 与 node 每轮从 PATH 解析,**npm 升级自动跟随,无需重跑 install**,且 node 与包永远同版本。fnm/volta 切换默认版本后只需在新版本里装过一次 `npm i -g cron-up`,忘了装的话总览页会提示;nvm 等按版本目录安装的形态则绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切过去。自建启动脚本的另一个原因是显示名:macOS 系统设置「允许在后台」按 launchd 拉起的第一个可执行文件给条目归组,直连 node 会显示在「Node.js Foundation」(node 的签名者)名下,指向自有脚本则显示 **cron-up-service**。
52
52
 
@@ -127,12 +127,12 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
127
127
  - **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
128
128
  - **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
129
129
  - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
130
- - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号按「年.月.序号」:两位年份、月份、月内发布序号(序号每月重置),如 2026 年 9 月第一次发布是 `26.9.1`,同月再发是 `26.9.2`,到 10 月是 `26.10.1`。发布方式:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.1`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致后 publish,版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
130
+ - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号按「年.月.序号」:两位年份、月份、月内发布序号(序号每月重置),如 2026 年 9 月第一次发布是 `26.9.1`,同月再发是 `26.9.2`,到 10 月是 `26.10.1`。发布方式:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.1`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致后 publish,版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移。发布走 **trusted publishing**:npm 经 GitHub OIDC 验证「确为 `adaex/cron-up` 仓库 `release.yml` 由该 tag 触发的运行」后放行,不使用任何长期凭证(仓库里没有 NPM_TOKEN,token 泄漏冒发这条路不存在),且每个版本自动附带 provenance 出处证明(npm 包页可见构建来源与 commit);前提是 npmjs.com 包设置里已登记 Trusted Publisher(repository `adaex/cron-up`、workflow `.github/workflows/release.yml`,一次性配置),没登记的话 publish 会被拒绝。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。publish 成功后 npm 服务端约两分钟才完成处理,`latest` 标签与本机 npm 缓存各自再滞后一层——发布后立即 `npm i -g cron-up@latest` 可能装到旧版且毫无提示(`changed 1 package` 不含版本号);等 `npm view cron-up version` 刷新后用显式版本号安装,装完以总览首行版本号为准。
131
131
  - **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
132
132
  - **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
133
133
  - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,安装时直接报错中止。
134
134
  - **长驻会话自动换代**:被续期任务保活的会话会长期常驻,但常驻不等于长生不老——连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
135
- - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着但 3 分钟内始终没有登记成会话**(停在信任确认或工具授权提问上——这种进程不会自己退出,会被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
135
+ - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
136
136
  - **日志大小**:后台会话的界面输出是持续的全屏渲染流。单次运行产生的日志超过 10MB 时,会在下次启动该目录会话时轮转成 `.log.1`;而 `script(1)` 每次启动会清空旧日志,所以反复重启不会造成堆积。长驻会话的单代日志总量由自动换代(见上条)约束;唯一无法覆盖的是连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
137
137
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
138
138
 
package/bin/cron-up.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // cron-up 入口。launchd 以「node 绝对路径 + 本文件绝对路径 + run」直接
3
- // 启动本文件(npm shim 的 /usr/bin/env node 在 launchd 最小 PATH 下找不
4
- // 到 node)。
2
+ // cron-up 入口(npm bin 的 shim 目标)。launchd 经自有的 cron-up-service
3
+ // 启动脚本拉起:脚本把 node 与本包所在的 bin 目录放进 PATH 后执行
4
+ // `cron-up run`,命令与本文件都从 PATH 解析,npm 升级自动跟随。
5
5
 
6
6
  import { main } from '../src/cli.mjs';
7
7
  import { ExitError } from '../src/internals.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.6",
3
+ "version": "26.9.7",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -135,7 +135,7 @@ export function loadState() {
135
135
  pid = null;
136
136
  }
137
137
  ent.pid = pid && pid > 0 && pid <= PID_T_MAX ? pid : null;
138
- for (const k of ['startedAt', 'cooldownUntil', 'fails']) {
138
+ for (const k of ['startedAt', 'cooldownUntil', 'fails', 'deadSince']) {
139
139
  if (ent[k] !== null && ent[k] !== undefined) {
140
140
  try {
141
141
  ent[k] = lenientInt(ent[k]);
package/src/constants.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  // 全部调参常量集中一处,改动时便于通盘核对。
2
2
 
3
3
  export const DEFAULT_CONFIG = {
4
- // 默认扫家目录(两层深覆盖 ~/<root>/<workspace>);安装时可用 roots 收窄。
5
- roots: ['~'],
4
+ // 默认只扫本人的三个任务制容器(两层深覆盖 <容器>/<仓>及其一级子目录),
5
+ // 不扫整个家目录——他人可写仓库里的周期任务会被自动续期永久化,默认范围
6
+ // 必须是用户完全掌控的领地(见 README 安全说明)。其他布局用 --roots 覆
7
+ // 盖;目录不存在则跳过。
8
+ roots: ['~/space', '~/workspace', '~/tasks'],
6
9
  maxDepth: 2,
7
10
  intervalSeconds: 300,
8
11
  leadSeconds: 600,
@@ -17,6 +20,11 @@ export const DEFAULT_CONFIG = {
17
20
  // 而不是每 5 分钟无限重拉。
18
21
  export const FAIL_LIMIT = 3;
19
22
  export const COOLDOWN_SECONDS = 1800;
23
+ // 失败计数随死条目保留的时长。提前窗口通常只覆盖一两轮巡检(每小时任务
24
+ // 约 2 轮),计数若在窗口外的轮次被清理就永远数不满 FAIL_LIMIT——冷却只对
25
+ // 稠密任务生效。保留 48 小时让稀疏任务跨窗口累积;老化防止陈年计数误伤
26
+ // 已修好的环境。
27
+ export const FAIL_TTL_SECONDS = 48 * 3600;
20
28
  // pid 合法值域。state.json 与会话登记两个外部来源都在入口处按它校验:
21
29
  // 超大整数能穿过类型转换,直到 process.kill 才抛,所以必须入口卡范围。
22
30
  export const PID_T_MAX = 2 ** 31 - 1;
package/src/overview.mjs CHANGED
@@ -81,8 +81,13 @@ function overviewTasks(cfg, cols, consumers, state) {
81
81
  + `请运行 cron-up logs ${path.basename(ws)} 排查`);
82
82
  coolingWarned.add(ws);
83
83
  } else {
84
- alerts.push(`${ws} 有任务即将执行(或错过待补执行),但当前没有交互`
85
- + '会话,下轮巡检会自动启动');
84
+ // 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
85
+ // 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
86
+ // 告警解释,这里不重复。
87
+ const fails = (state[ws] ?? {}).fails ?? 0;
88
+ const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
89
+ alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
90
+ + '但当前没有交互会话,下轮巡检会自动启动');
86
91
  }
87
92
  }
88
93
  }
@@ -115,7 +120,7 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
115
120
  for (const [ws, ent] of Object.entries(state)) {
116
121
  if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, ent]);
117
122
  else if (deps.trackedAlive(ent)) live.push([ws, ent]);
118
- else dead.push(ws); // 记录还在、进程已没:下轮巡检即清理
123
+ else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
119
124
  }
120
125
  let tail = '';
121
126
  if (cooling.length) tail += `,冷却 ${cooling.length} 个`;
package/src/patrol.mjs CHANGED
@@ -9,6 +9,7 @@ import { deps } from './internals.mjs';
9
9
  import {
10
10
  FAIL_LIMIT,
11
11
  COOLDOWN_SECONDS,
12
+ FAIL_TTL_SECONDS,
12
13
  WARMUP_GRACE_SECONDS,
13
14
  SESSION_MAX_AGE_SECONDS,
14
15
  SESSION_IDLE_SECONDS,
@@ -184,13 +185,11 @@ export async function patrolWorkspace(
184
185
 
185
186
  if (consumers.has(ws)) {
186
187
  // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
187
- // (活消费者证明健康 → 计数清零),后者彻底放手。
188
- if (ent !== null) {
189
- if (mineAlive) {
190
- ent.fails = 0;
191
- } else if (!cooling) {
192
- delete state[ws];
193
- }
188
+ // (活消费者证明健康 → 计数清零),后者放手——但不在这里删死条目,失败
189
+ // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
190
+ // 由轮末 kept 过滤统一处理。
191
+ if (ent !== null && mineAlive) {
192
+ ent.fails = 0;
194
193
  }
195
194
  return;
196
195
  }
@@ -316,10 +315,20 @@ export async function cmdRun(args) {
316
315
  }
317
316
  }
318
317
 
319
- // 丢掉已死且不处冷却的条目;保留冷却与活会话。
318
+ // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
319
+ // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
320
+ // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
321
+ // 老化丢弃。
320
322
  const kept = Object.fromEntries(
321
- Object.entries(state).filter(([, ent]) =>
322
- deps.trackedAlive(ent) || (ent.cooldownUntil ?? 0) > cur),
323
+ Object.entries(state).filter(([, ent]) => {
324
+ if (deps.trackedAlive(ent)) return true;
325
+ if ((ent.cooldownUntil ?? 0) > cur) return true;
326
+ if ((ent.fails ?? 0) > 0) {
327
+ ent.deadSince ??= cur;
328
+ return cur - ent.deadSince < FAIL_TTL_SECONDS;
329
+ }
330
+ return false;
331
+ }),
323
332
  );
324
333
  saveState(kept);
325
334
  return true;
package/src/service.mjs CHANGED
@@ -328,9 +328,10 @@ export async function cmdInstall(args) {
328
328
  if (JSON.stringify(cfg.roots)
329
329
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
330
330
  // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
331
- deps.print('提示:未指定 --roots,扫描范围是整个用户目录,今后 clone 的'
332
- + '仓库也会进入巡检;自动续期会把其中的周期任务永久化。建议用 '
333
- + '--roots 显式限定范围(见 README 安全说明)');
331
+ deps.print('提示:未指定 --roots,默认扫描 ~/space、~/workspace、~/tasks'
332
+ + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
333
+ + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
334
+ + '--roots 指定(见 README 安全说明)');
334
335
  }
335
336
 
336
337
  // npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只