cron-up 26.9.5 → 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
@@ -8,7 +8,7 @@
8
8
  ## 平台要求
9
9
 
10
10
  - **macOS**(依赖 launchd、`script(1)`、zsh 登录配置;暂不支持其他系统)
11
- - **Node.js 22 以上**(零运行时依赖,巡检本身只调系统自带工具)。fnm、volta、nvm、Homebrew 与官网安装包都支持——安装时会自动解析版本无关的 node 稳定路径写进 launchd 配置,不依赖 shell 的 PATH
11
+ - **Node.js 22 以上**(零运行时依赖,巡检本身只调系统自带工具)。fnm、volta、Homebrew、官网安装包与其他版本管理器都支持——安装时把 node 与 cron-up 所在的 bin 目录写进启动脚本的 PATH,不依赖 shell 的 PATH
12
12
  - Claude Code 命令行(在交互登录 shell 里能直接运行 `claude`)
13
13
 
14
14
  ## 它解决什么问题
@@ -30,7 +30,7 @@ cron-up 是一个由 launchd 每 5 分钟运行一次的巡检进程,逻辑只
30
30
  |---|---|
31
31
  | 任务登记 | Claude Code 原生命令 `CronCreate(durable: true)`,写入工作目录下的 `.claude/scheduled_tasks.json` |
32
32
  | 定时执行 | Claude Code 交互会话自身(包括 cron 时间计算、随机延迟、一次性任务执行后自动删除) |
33
- | 保证有会话在场 | launchd 每 5 分钟调用一次 `cron-up run`(以「node 绝对路径 + 入口脚本绝对路径」启动,不依赖 shell PATH),运行完立即退出,不常驻 |
33
+ | 保证有会话在场 | launchd 每 5 分钟调用一次 `cron-up run`(经自有启动脚本拉起,PATH 指向安装时解析的 node bin 目录),运行完立即退出,不常驻 |
34
34
  | 周期任务续期 | 巡检时给周期任务补 `permanent: true`(默认开启、可关),使其不受 7 天过期限制;见下 |
35
35
  | 后台会话 | 通过 macOS 自带的 `script(1)` 启动无窗口交互界面,输出写入日志,可随时恢复 |
36
36
  | 安装与升级 | 一律走 npm:`npm i -g cron-up` / `npm i -g cron-up@latest` |
@@ -46,9 +46,9 @@ 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
- launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),npm 全局命令那个 `#!/usr/bin/env node` 壳在它下面找不到 node。因此安装时把**跟随默认版本的 bin 目录**(fnm 是 `~/.local/share/fnm/aliases/default/bin`——`fnm default` 切换时这个 symlink 自己跟着切;volta 是 `~/.volta/bin`;Homebrew/官网是系统 bin 目录)写进一个自有的启动脚本(`cron-up-service`)的 PATH,脚本直接 `exec cron-up run`——cron-up 与 node 每轮从 PATH 解析,**npm 升级、fnm/volta 切换默认版本都自动跟随,无需重跑 install**,且 node 与包永远同版本。切换默认版本后只需在新版本里装过一次 `npm i -g cron-up`,忘了装的话总览页会提示。例外:**nvm 的 default 只是个文本别名**(要解析到具体版本目录,没有跟随切换的 symlink),其启动脚本写死绝对路径,切换默认版本后需重跑 `cron-up install`,总览会检测路径失效与版本分叉。自建启动脚本的另一个原因是显示名:macOS 系统设置「允许在后台」按 launchd 拉起的第一个可执行文件给条目归组,直连 node 会显示在「Node.js Foundation」(node 的签名者)名下,指向自有脚本则显示 **cron-up-service**。
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
 
53
53
  ### 自动续期(默认开启)
54
54
 
@@ -68,7 +68,7 @@ cron-up install --force --no-auto-renew
68
68
  |---|---|
69
69
  | 命令行与代码 | npm 全局目录(位置因安装方式而异,如 fnm 是 `~/.local/share/fnm/aliases/default/lib/node_modules/cron-up/`,命令在同级 `bin/cron-up`) |
70
70
  | launchd 配置 | `~/Library/LaunchAgents/local.cron-up.plist` |
71
- | 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,内含 node 与包入口的绝对路径;后台列表显示名即来自它) |
71
+ | 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,PATH 里写有 node 与 cron-up 所在的 bin 目录;后台列表显示名即来自它) |
72
72
  | 配置文件 | `~/.local/share/cron-up/config.json` |
73
73
  | 运行状态 | 同目录下的 `state.json` |
74
74
  | 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
@@ -99,9 +99,9 @@ cron-up uninstall --purge # 结束后台会话并删除配置与日志(
99
99
  npm i -g cron-up@latest
100
100
  ```
101
101
 
102
- launchd 每轮按 plist 里写死的路径重新拉起 node 与入口文件,npm 替换包文件后下一轮巡检自然跑新版,无需重载服务;配置与任务文件都不动(fnm/nvm 切换默认 Node 版本是例外,见「维护」)。
102
+ launchd 每轮经启动脚本从 PATH 重新解析 cron-up 与 node,npm 替换包文件后下一轮巡检自然跑新版,无需重载服务;配置与任务文件都不动。
103
103
 
104
- `cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「launchd 配置里的 node/入口路径已失效或分属不同 node 版本目录」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
104
+ `cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
105
105
 
106
106
  ## 使用定时任务时的三个约定
107
107
 
@@ -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 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install。**例外**:nvm 的启动脚本写死绝对路径,切换默认版本后重跑一次 `cron-up install`。从 26.9.4 及更早升级后建议重跑一次 `cron-up install --force` 把启动脚本切到 PATH 形态(26.9.4 及更早还会顺带把数据目录从 `~/Library/Application Support/cron-up` 迁到 `~/.local/share/cron-up`);不重跑的话旧形态照常巡检。版本号按「年.月.序号」:两位年份、月份、月内发布序号(序号每月重置),如 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
 
@@ -156,22 +156,21 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
156
156
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
157
157
  - 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话。
158
158
  - 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
159
- - 若升级 Node 时删掉了 plist 引用的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「Node 或入口路径已失效」,重跑一次 `cron-up install` 即可修复。fnm/nvm 切换默认版本但旧版本还在时更隐蔽:路径都有效、巡检照跑,只是跑的是旧版本目录里的旧包,总览页会报「入口路径属于另一个 node 版本目录」。
159
+ - 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
160
160
 
161
161
  ## 故障排查
162
162
 
163
163
  | 现象 | 排查方法 |
164
164
  |---|---|
165
165
  | 总览提示「冷却中」 | 运行 `cron-up logs <目录名>` 查看启动界面:要么停在信任确认或工具授权提问(日志里能看到提问界面),要么 shell 环境缺少命令(日志里是 `command not found`) |
166
- | 总览提示「Node 或入口路径已失效」 | 写死路径形态(nvm 或旧版脚本)下 Node 版本目录被删或移动,重跑 `cron-up install` 重新解析即可 |
167
166
  | 总览提示「启动脚本已失效 / 内容无法解读」 | 启动脚本(`~/.local/share/cron-up/cron-up-service`)被删或手改,重跑 `cron-up install` 覆盖重生成 |
168
- | 总览提示「PATH 目录里找不到 cron-up」 | fnm/volta 切换了默认 Node 版本,新版本里还没装包:在新版本里 `npm i -g cron-up` 即可 |
169
- | 总览提示「入口路径属于另一个 node 版本目录」 | 写死路径形态(nvm 或旧版脚本)下切换了默认 Node 版本,巡检在跑旧版本目录里的旧包;重跑 `cron-up install` 更新路径 |
167
+ | 总览提示「PATH 目录里找不到 cron-up」 | 切换了默认 Node 版本,新版本里还没装包:在新版本里 `npm i -g cron-up` 即可 |
168
+ | 总览提示「PATH 目录里找不到 node」 | 启动脚本 PATH 指向的版本目录被删或移动(多见于手动删除 nvm/fnm 版本),重跑 `cron-up install` 重新解析 |
170
169
  | 日志里出现「卡死」 | 会话进程起来了但没能变成可用会话,绝大多数是停在信任确认或授权提问。先手动 `cd <目录> && claude` 走完确认,再等下一轮巡检 |
171
170
  | 日志里出现「任务文件读不出 \<目录\>」 | 该目录的 `.claude/scheduled_tasks.json` 存在却解析不了(格式损坏),巡检跳过这一个目录,**其中的定时任务一个都不会执行**;其余目录照常。检查该文件 |
172
171
  | 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
173
172
  | 任务到点没有执行 | `cron-up list` 查看下次执行时间和是否有会话;`launchctl print gui/$(id -u)/local.cron-up` 查看 last exit code |
174
- | 手动 `cron-up run` 正常、定时执行不正常 | 基本都是 launchd 环境下 shell 初始化不一致(PATH、fnm、模型路由),会话日志里会有直接报错;注意 plist 用的是安装时解析的绝对 node 路径,换 Node 安装方式后要重跑 install |
173
+ | 手动 `cron-up run` 正常、定时执行不正常 | 基本都是 launchd 环境下 shell 初始化不一致(PATH、fnm、模型路由),会话日志里会有直接报错;换过 Node 安装方式的话重跑一次 `cron-up install` |
175
174
  | `last exit code=2` | config.json 缺失、不是合法 JSON、顶层不是对象,或字段类型不对(如 `leadSeconds` 写成字符串、`autoRenew` 写成字符串);无参数 `cron-up` 总览的配置行或「需要留意」会给出具体原因 |
176
175
 
177
176
  ## 开发与测试
@@ -184,7 +183,7 @@ npm link # 本机把开发版链接成全局 cron-up,迭代
184
183
 
185
184
  `npm link` 后运行 `cron-up install` 会把指向工作副本的 LaunchAgent 装进系统,适合端到端调试;日常使用直接装公开 npm 的版本即可。
186
185
 
187
- 用例名自说明覆盖面,不再这里另抄一份清单(抄来的清单只会腐化)。东亚显示宽度的码点表由 `scripts/gen-east-asian-width.mjs` 从 Unicode 官方数据生成(当前 Unicode 18.0.0,见文件头注释里的重生成方法)。
186
+ 用例名自说明覆盖面,不再这里另抄一份清单(抄来的清单只会腐化)。终端展示的东亚字符宽度用 `src/display.mjs` 里的一张 CJK 区间启发式判定,只覆盖任务摘要里真实出现的形态。
188
187
 
189
188
  ## License
190
189
 
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.5",
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/display.mjs CHANGED
@@ -1,10 +1,23 @@
1
1
  // 等宽终端展示原语与任务的展示视图。
2
2
 
3
- import { isWide } from './east-asian-width.mjs';
4
3
  import { parseCronOrNone, taskIsOneshot, wanted } from './cron.mjs';
5
4
  import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
6
5
 
7
- // 等宽终端里的显示宽度:东亚宽字符(中文、全角标点)占两列。
6
+ // 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
7
+ // 只覆盖任务摘要里真实出现的形态,区间外一律按 1 列——判错的代价仅是对
8
+ // 齐偏一列,不值得为此维护全量 Unicode 码点表。
9
+ const WIDE_RANGES = [
10
+ [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
11
+ [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
12
+ [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff],
13
+ [0x20000, 0x2fffd], [0x30000, 0x3fffd],
14
+ ];
15
+
16
+ function isWide(cp) {
17
+ return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
18
+ }
19
+
20
+ // 等宽终端里的显示宽度:宽字符(中文、全角标点)占两列。
8
21
  export function dispWidth(s) {
9
22
  let n = 0;
10
23
  for (const ch of String(s)) n += isWide(ch.codePointAt(0)) ? 2 : 1;
package/src/overview.mjs CHANGED
@@ -35,12 +35,7 @@ function overviewTasks(cfg, cols, consumers, state) {
35
35
  let soonest = null;
36
36
  for (const [ws] of deps.discover(cfg.roots, cfg.maxDepth)) {
37
37
  discovered.add(ws);
38
- let tasks;
39
- try {
40
- tasks = deps.readTasks(ws);
41
- } catch {
42
- continue;
43
- }
38
+ const tasks = deps.readTasks(ws);
44
39
  if (tasks === null) {
45
40
  // 与巡检同一条规则:文件还在却读不出,总览页要把它摆进「需要留
46
41
  // 意」,而不是装作这个工作区不存在。
@@ -86,8 +81,13 @@ function overviewTasks(cfg, cols, consumers, state) {
86
81
  + `请运行 cron-up logs ${path.basename(ws)} 排查`);
87
82
  coolingWarned.add(ws);
88
83
  } else {
89
- alerts.push(`${ws} 有任务即将执行(或错过待补执行),但当前没有交互`
90
- + '会话,下轮巡检会自动启动');
84
+ // 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
85
+ // 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
86
+ // 告警解释,这里不重复。
87
+ const fails = (state[ws] ?? {}).fails ?? 0;
88
+ const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
89
+ alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
90
+ + '但当前没有交互会话,下轮巡检会自动启动');
91
91
  }
92
92
  }
93
93
  }
@@ -120,7 +120,7 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
120
120
  for (const [ws, ent] of Object.entries(state)) {
121
121
  if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, ent]);
122
122
  else if (deps.trackedAlive(ent)) live.push([ws, ent]);
123
- else dead.push(ws); // 记录还在、进程已没:下轮巡检即清理
123
+ else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
124
124
  }
125
125
  let tail = '';
126
126
  if (cooling.length) tail += `,冷却 ${cooling.length} 个`;
package/src/paths.mjs CHANGED
@@ -17,11 +17,8 @@ export const TASK_REL = path.join('.claude', 'scheduled_tasks.json');
17
17
  export const paths = {
18
18
  home: HOME,
19
19
  // 数据目录用 XDG 风格的 ~/.local/share/cron-up:config、state 与启动脚本
20
- // 集中一处,purge 一删全清。26.9.3 及更早放在
21
- // ~/Library/Application Support/cron-up,install 时自动搬家(见
22
- // service.mjs 的 migrateLegacyDir)。
20
+ // 集中一处,purge 一删全清。
23
21
  dataDir: path.join(HOME, '.local', 'share', 'cron-up'),
24
- legacyAppSupport: path.join(HOME, 'Library', 'Application Support', 'cron-up'),
25
22
  logDir: path.join(HOME, 'Library', 'Logs', 'cron-up'),
26
23
  plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
27
24
  sessionDir: path.join(HOME, '.claude', 'sessions'),
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,
@@ -20,6 +21,7 @@ import { taskWanted } from './cron.mjs';
20
21
  import {
21
22
  sessionLogPath,
22
23
  sessionLogIdleSeconds,
24
+ scriptProcLogPath,
23
25
  } from './sessions.mjs';
24
26
  import { TASK_REL } from './paths.mjs';
25
27
 
@@ -183,13 +185,11 @@ export async function patrolWorkspace(
183
185
 
184
186
  if (consumers.has(ws)) {
185
187
  // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
186
- // (活消费者证明健康 → 计数清零),后者彻底放手。
187
- if (ent !== null) {
188
- if (mineAlive) {
189
- ent.fails = 0;
190
- } else if (!cooling) {
191
- delete state[ws];
192
- }
188
+ // (活消费者证明健康 → 计数清零),后者放手——但不在这里删死条目,失败
189
+ // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
190
+ // 由轮末 kept 过滤统一处理。
191
+ if (ent !== null && mineAlive) {
192
+ ent.fails = 0;
193
193
  }
194
194
  return;
195
195
  }
@@ -236,7 +236,7 @@ export async function patrolWorkspace(
236
236
  for (const [procPid, procArgs] of scriptProcs) {
237
237
  // alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
238
238
  // 把死人认回来会白记一次失败。
239
- if (procArgs.includes(`-q ${logPath} `) && deps.alive(procPid)) {
239
+ if (scriptProcLogPath(procArgs) === logPath && deps.alive(procPid)) {
240
240
  state[ws] = {
241
241
  pid: procPid,
242
242
  startedAt: cur,
@@ -315,10 +315,20 @@ export async function cmdRun(args) {
315
315
  }
316
316
  }
317
317
 
318
- // 丢掉已死且不处冷却的条目;保留冷却与活会话。
318
+ // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
319
+ // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
320
+ // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
321
+ // 老化丢弃。
319
322
  const kept = Object.fromEntries(
320
- Object.entries(state).filter(([, ent]) =>
321
- 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
+ }),
322
332
  );
323
333
  saveState(kept);
324
334
  return true;
package/src/registry.mjs CHANGED
@@ -50,7 +50,7 @@ export async function readSessionFile(file) {
50
50
  return null; // 文件消失不是竞态,不重试
51
51
  }
52
52
  try {
53
- return deps.parseJson(text);
53
+ return JSON.parse(text);
54
54
  } catch {
55
55
  if (attempt === 1) return null;
56
56
  }
@@ -149,7 +149,6 @@ export async function scanSessions() {
149
149
  }
150
150
 
151
151
  Object.assign(deps, {
152
- parseJson: (text) => JSON.parse(text),
153
152
  pidIsClaude,
154
153
  scanSessions,
155
154
  });
package/src/service.mjs CHANGED
@@ -1,14 +1,14 @@
1
- // launchd 服务面:launchctl 封装、服务状态查询、node 启动路径的通用解析、
2
- // plist 渲染、install/uninstall,以及「Node 路径是否失效」的健康检查。
1
+ // launchd 服务面:launchctl 封装、服务状态查询、node bin 目录解析、plist
2
+ // 渲染、install/uninstall,以及启动链的健康检查。
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
6
 
8
7
  import { deps, ExitError } from './internals.mjs';
9
8
  import { paths, LABEL } from './paths.mjs';
10
9
  // paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
11
10
  // deps.paths(测试要整体重定向)。
11
+ import { scriptProcLogPath } from './sessions.mjs';
12
12
  import {
13
13
  DEFAULT_CONFIG,
14
14
  } from './constants.mjs';
@@ -71,9 +71,9 @@ export function serviceLine(info, plistExists) {
71
71
  // ---- node 启动路径解析 ----
72
72
  //
73
73
  // npm 全局 bin 的 shebang 是 #!/usr/bin/env node,而 launchd 的最小 PATH
74
- // (/usr/bin:/bin:/usr/sbin:/sbin)里没有 node,所以 plist 必须写死
75
- // 「node 绝对路径 + 入口脚本绝对路径」。不同 node 安装方式的稳定路径各
76
- // 不相同,按固定布局逐个探测;都不认识时退回本次安装进程的 execPath。
74
+ // (/usr/bin:/bin:/usr/sbin:/sbin)里没有 node。安装时把「node 与 cron-up
75
+ // 所在的 bin 目录」写进启动脚本的 PATH,之后每轮由脚本自己解析:npm 升
76
+ // 级、切换默认版本都自动跟随,且 node 与包永远同版本。
77
77
 
78
78
  function isExecutable(p) {
79
79
  try {
@@ -84,134 +84,40 @@ function isExecutable(p) {
84
84
  }
85
85
  }
86
86
 
87
- function semverCompare(a, b) {
88
- const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
89
- const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
90
- for (let i = 0; i < 3; i++) {
91
- if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
92
- }
93
- return 0;
94
- }
95
-
96
- // nvm 的 default 别名是文本文件('node'、'lts/*' 或 'v22.2.0'),不是
97
- // 符号链接;解析不出具体版本时取已安装的最高版本目录(lts/* 的近似)。
98
- function nvmNodePath(nvmDir) {
99
- const versionsDir = path.join(nvmDir, 'versions', 'node');
100
- const pick = (v) => {
101
- const p = path.join(versionsDir, v, 'bin', 'node');
102
- return isExecutable(p) ? p : null;
103
- };
104
- let pref = null;
105
- try {
106
- pref = fs.readFileSync(path.join(nvmDir, 'alias', 'default'), 'utf-8').trim();
107
- } catch {
108
- pref = null;
109
- }
110
- if (/^v/.test(pref ?? '')) {
111
- const p = pick(pref);
112
- if (p) return p;
113
- }
114
- if (pref === 'lts/*') {
115
- try {
116
- const v = fs.readFileSync(path.join(nvmDir, 'alias', 'lts', '*'), 'utf-8').trim();
117
- const p = pick(v);
118
- if (p) return p;
119
- } catch {
120
- // 落到最高版本兜底。
121
- }
122
- }
123
- let all = [];
124
- try {
125
- all = fs.readdirSync(versionsDir);
126
- } catch {
127
- return null;
128
- }
129
- const versions = all.filter((d) => /^v/.test(d)).sort(semverCompare);
130
- for (let i = versions.length - 1; i >= 0; i--) {
131
- const p = pick(versions[i]);
132
- if (p) return p;
133
- }
134
- return null;
135
- }
136
-
137
- // 返回 {nodePath, entryPath, manager}。注入 home/execPath/entryPath 仅为
138
- // 测试在临时 HOME 里伪造各安装形态。pathDirs 是该安装形态下「跟随默认版
139
- // 本的 bin 目录」(fnm 的 aliases/default/bin——fnm default 切换时 symlink
140
- // 跟着切;volta 的 ~/.volta/bin;brew/pkg 的系统 bin):启动脚本把它放进
141
- // PATH 后 cron-up 与 node 都从 PATH 解析,npm 升级与切换默认版本自动跟
142
- // 随。nvm 与 exec-path 兜底没有这种目录(nvm 的 default 是文本别名,要解
143
- // 析到具体版本),pathDirs 为 null,启动脚本退回写死绝对路径。
87
+ // 返回 {pathDirs}:启动脚本要写进 PATH 的 bin 目录。注入 home/execPath/
88
+ // systemPaths 仅为测试在临时 HOME 里伪造各安装形态。fnm 的
89
+ // aliases/default/bin 是跟随 `fnm default` 切换的稳定 symlink(切勿
90
+ // realpath 到版本化目录,fnm 升级后那里会失效);volta 的 ~/.volta/bin、
91
+ // Homebrew 与官网 pkg 的系统 bin 同理是版本无关的稳定入口。都不认识时退
92
+ // 回当前安装进程自己的 bin 目录:nvm 等按版本目录安装的形态由此覆盖(node
93
+ // 与 cron-up 同目录,npm 升级自动跟随),代价是绑定装包时的版本目录——切
94
+ // 换默认版本后要重跑 install 才切过去。
144
95
  export function resolveLauncher(opts = {}) {
145
96
  const home = opts.home ?? paths.home;
146
97
  const execPath = opts.execPath ?? process.execPath;
147
- const entryPath = opts.entryPath
148
- ?? fileURLToPath(new URL('../bin/cron-up.mjs', import.meta.url));
98
+ const systemPaths = opts.systemPaths
99
+ ?? ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
149
100
 
150
- // 1. fnm:aliases/default 是跟随 `fnm default` 切换的稳定 symlink,切勿
151
- // realpath 到版本化目录(fnm 升级后那里会失效)。包含 FNM_DIR 与新
152
- // 旧两个默认位置。
153
101
  const fnmDirs = [
154
102
  process.env.FNM_DIR,
155
103
  path.join(home, '.local', 'share', 'fnm'),
156
104
  path.join(home, '.fnm'),
157
105
  ].filter(Boolean);
158
- for (const d of fnmDirs) {
159
- const nodePath = path.join(d, 'aliases', 'default', 'bin', 'node');
160
- if (isExecutable(nodePath)) {
161
- return {
162
- nodePath, entryPath, manager: 'fnm',
163
- pathDirs: [path.dirname(nodePath)],
164
- };
165
- }
166
- }
167
-
168
- // 2. volta:~/.volta/bin/node 是稳定的版本无关 shim。
169
- const voltaNode = path.join(home, '.volta', 'bin', 'node');
170
- if (isExecutable(voltaNode)) {
171
- return {
172
- nodePath: voltaNode, entryPath, manager: 'volta',
173
- pathDirs: [path.dirname(voltaNode)],
174
- };
175
- }
176
-
177
- // 3. nvm。
178
- const nvmPath = nvmNodePath(path.join(home, '.nvm'));
179
- if (nvmPath) return { nodePath: nvmPath, entryPath, manager: 'nvm' };
180
-
181
- // 4. Homebrew(Apple silicon 与 Intel)与官网 pkg 共用的系统路径。这些
182
- // 本身就是版本无关的稳定 symlink。systemPaths 仅供测试注入。
183
- const systemPaths = opts.systemPaths
184
- ?? ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
185
- for (const cand of systemPaths) {
186
- if (isExecutable(cand)) {
187
- return {
188
- nodePath: cand,
189
- entryPath,
190
- manager: cand.includes('homebrew') || isBrewCellar(cand) ? 'brew' : 'pkg',
191
- pathDirs: [path.dirname(cand)],
192
- };
193
- }
106
+ const candidates = [
107
+ ...fnmDirs.map((d) => path.join(d, 'aliases', 'default', 'bin')),
108
+ path.join(home, '.volta', 'bin'),
109
+ ...systemPaths.map((p) => path.dirname(p)),
110
+ ];
111
+ for (const dir of candidates) {
112
+ if (isExecutable(path.join(dir, 'node'))) return { pathDirs: [dir] };
194
113
  }
195
-
196
- // 5. 兜底:当前安装进程的 node 至少现在能跑;可能是 fnm 多 shell 的易变
197
- // 路径,标记 note 提示日后核对。
198
114
  return {
199
- nodePath: execPath,
200
- entryPath,
201
- manager: 'exec-path',
202
- note: '未能定位版本无关的 node 路径,已使用当前 node 的绝对路径;'
203
- + '切换 Node 版本后请重新运行 cron-up install',
115
+ pathDirs: [path.dirname(execPath)],
116
+ note: '未能定位版本无关的 node bin 目录,已使用当前 node 所在目录;'
117
+ + '该目录失效(如版本目录被删)时请重新运行 cron-up install',
204
118
  };
205
119
  }
206
120
 
207
- function isBrewCellar(p) {
208
- try {
209
- return fs.realpathSync(p).includes(`${path.sep}Cellar${path.sep}`);
210
- } catch {
211
- return false;
212
- }
213
- }
214
-
215
121
  // ---- plist ----
216
122
 
217
123
  function xmlEscape(s) {
@@ -228,35 +134,27 @@ function xmlUnescape(s) {
228
134
  .replace(/&amp;/g, '&');
229
135
  }
230
136
 
231
- // key 按字母序排列。ProgramArguments 是
232
137
  // 生成 launchd 拉起的启动脚本:ProgramArguments[0] 指向它而非 node。
233
138
  // macOS 的后台项目列表(系统设置 → 登录项与扩展 → 允许在后台)按
234
139
  // ProgramArguments[0] 的文件名归组条目:直连 node 会与所有 node 系后台服务
235
140
  // 合并显示在「Node.js Foundation」(node 的签名者)名下;指向自有脚本则
236
- // 显示 cron-up-service。
237
- // 有跟随默认版本的 bin 目录的安装形态(fnm/volta/brew/pkg)把目录放进
238
- // PATH 后直接 exec cron-up run:npm 升级、切换默认版本都自动跟随,无需
239
- // 重跑 install(node 与 cron-up 同目录,天然同版本)。nvm 与 exec-path
240
- // 兜底没有这种目录,写死绝对路径,失效与版本分叉由总览检测兜底。
141
+ // 显示 cron-up-service。脚本把 node 所在 bin 目录放进 PATH 后直接
142
+ // exec cron-up run:npm 升级自动跟随,无需重跑 install(node 与 cron-up
143
+ // 同目录,天然同版本)。
241
144
  export function writeServiceScript(launcher) {
242
145
  const script = deps.paths.serviceScriptPath;
243
146
  fs.mkdirSync(path.dirname(script), { recursive: true });
244
- let body;
245
- if (launcher.pathDirs) {
246
- const dirs = launcher.pathDirs.map((d) => `"${d}"`).join(':');
247
- body = `export PATH=${dirs}:"$PATH"\nexec cron-up run\n`;
248
- } else {
249
- body = `exec "${launcher.nodePath}" "${launcher.entryPath}" run\n`;
250
- }
147
+ const dirs = launcher.pathDirs.map((d) => `"${d}"`).join(':');
251
148
  fs.writeFileSync(script,
252
149
  '#!/bin/bash\n'
253
150
  + '# 由 cron-up install 生成;重跑 cron-up install --force 覆盖重生成\n'
254
- + body);
151
+ + `export PATH=${dirs}:"$PATH"\n`
152
+ + 'exec cron-up run\n');
255
153
  fs.chmodSync(script, 0o755);
256
154
  return script;
257
155
  }
258
156
 
259
- // ProgramArguments 单参数(启动脚本路径)——node 与入口路径在脚本里。
157
+ // ProgramArguments 单参数(启动脚本路径)。
260
158
  export function renderPlist(intervalSeconds, scriptPath) {
261
159
  return `<?xml version="1.0" encoding="UTF-8"?>
262
160
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -282,9 +180,7 @@ export function renderPlist(intervalSeconds, scriptPath) {
282
180
  }
283
181
 
284
182
  // 从已安装 plist 的 ProgramArguments 里抠出参数(plist 是我们自己写的,
285
- // 正则足够,不引 plist 解析器)。两种形态:单参数(当前,启动脚本路径)
286
- // 与三参数 [node, 入口, 'run'](旧版 plist,升级后重跑 install 前的过渡
287
- // 形态)。
183
+ // 正则足够,不引 plist 解析器)。
288
184
  export function readPlistProgramArgs() {
289
185
  let text;
290
186
  try {
@@ -300,33 +196,37 @@ export function readPlistProgramArgs() {
300
196
  return parts.length > 0 ? parts : null;
301
197
  }
302
198
 
303
- // 从启动脚本内容里解出 [node 路径, 入口路径];脚本是我们生成的固定格
304
- // 式,解不出(手改坏)返回 null。PATH 形态的脚本没有写死的路径,返回
305
- // 'path' 标记。
306
- function readServiceScriptShape(scriptPath) {
199
+ // 从启动脚本内容里解出 PATH 目录;脚本是我们生成的固定格式,解不出(手改
200
+ // 坏)返回 null。
201
+ function scriptPathDirs(scriptPath) {
307
202
  let text;
308
203
  try {
309
204
  text = fs.readFileSync(scriptPath, 'utf-8');
310
205
  } catch {
311
206
  return null;
312
207
  }
313
- if (/^export PATH=.+"\$PATH"$/m.test(text)) return 'path';
314
- const m = text.match(/^exec "(.+)" "(.+)" run$/m);
315
- return m ? [m[1], m[2]] : null;
208
+ const m = text.match(/^export PATH=(.+):"\$PATH"$/m);
209
+ return m ? m[1].split('":"').map((s) => s.replace(/"/g, '')) : null;
316
210
  }
317
211
 
318
- // PATH 形态健康检查:跟随默认版本的 bin 目录里要能找到 cron-up 与
319
- // node。典型断点是 fnm/volta 切换默认版本后忘了在新版本里装包。
320
- function pathResolveHealth(scriptPath) {
321
- let text;
322
- try {
323
- text = fs.readFileSync(scriptPath, 'utf-8');
324
- } catch {
325
- return null;
212
+ // 总览页健康检查:launchd 每轮按启动脚本拉起巡检,脚本丢失、被手改或
213
+ // PATH 目录里解析不到 cron-up/node(典型断点:切换默认 Node 版本后忘了
214
+ // 在新版本里装包、版本目录被删)时都会静默拉不起来,这是总览唯一能发现
215
+ // 它的地方。
216
+ export function launcherHealth() {
217
+ if (!fs.existsSync(deps.paths.plistPath)) return null;
218
+ const args = readPlistProgramArgs();
219
+ if (!args || args.length !== 1) return null;
220
+ const [scriptPath] = args;
221
+ if (!fs.existsSync(scriptPath)) {
222
+ return `launchd 配置中的启动脚本已失效(${path.basename(scriptPath)} `
223
+ + '不存在),巡检当前拉不起来;请重新运行 cron-up install';
224
+ }
225
+ const dirs = scriptPathDirs(scriptPath);
226
+ if (dirs === null) {
227
+ return `启动脚本 ${scriptPath} 内容无法解读(可能被手改),`
228
+ + '重跑 cron-up install 覆盖重生成';
326
229
  }
327
- const m = text.match(/^export PATH=(.+):"\$PATH"$/m);
328
- if (!m) return null;
329
- const dirs = m[1].split('":"').map((s) => s.replace(/"/g, ''));
330
230
  const has = (name) => dirs.some((d) => isExecutable(path.join(d, name)));
331
231
  if (!has('cron-up')) {
332
232
  return `启动脚本的 PATH 目录(${dirs.join('、')})里找不到 cron-up:切换`
@@ -340,99 +240,9 @@ function pathResolveHealth(scriptPath) {
340
240
  return null;
341
241
  }
342
242
 
343
- // fnm 与 nvm 的全局包装在版本化目录里(…/node-versions/v<X>/… 与
344
- // …/versions/node/v<X>/…)。default 别名切换后 node 路径依然有效,但入口
345
- // 路径仍指向装包时的旧版本目录——巡检会静默继续跑旧版 cron-up,直到旧版
346
- // 本被卸载才表现为「路径失效」。这里提前一步提示。版本段固定以 v 开头
347
- // (fnm/nvm 的目录命名),避免路径里恰好出现 node-versions 字样的普通目
348
- // 录误报;volta/brew/pkg 的包路径不含版本化目录,返回 null 跳过检查
349
- // (npm link 的工作副本同理)。
350
- function nodeVersionRoot(p) {
351
- const m = p.match(/(?:node-versions|versions\/node)\/v[^/]+/);
352
- return m ? m[0] : null;
353
- }
354
-
355
- // node/入口路径的存在性与版本分叉检查,新(启动脚本内)旧(plist 三参
356
- // 数)两种形态共用。
357
- function pathHealthAlert(nodePath, entryPath) {
358
- const missing = [nodePath, entryPath].filter((p) => !fs.existsSync(p));
359
- if (missing.length > 0) {
360
- return 'launchd 配置中的 Node 或入口路径已失效('
361
- + `${missing.map((p) => path.basename(p)).join('、')} 不存在),巡检当前拉不`
362
- + '起来;请重新运行 cron-up install';
363
- }
364
- let nodeReal = nodePath;
365
- try {
366
- nodeReal = fs.realpathSync(nodePath);
367
- } catch {
368
- // 存在性已确认,保留原路径。
369
- }
370
- const nodeRoot = nodeVersionRoot(nodeReal);
371
- const entryRoot = nodeVersionRoot(entryPath);
372
- if (nodeRoot !== null && entryRoot !== null && nodeRoot !== entryRoot) {
373
- return 'launchd 配置里的入口路径属于另一个 node 版本目录'
374
- + `(${entryRoot}),而当前 node 是 ${nodeReal};巡检可能在运行旧版`
375
- + ' cron-up,重跑 cron-up install 更新路径';
376
- }
377
- return null;
378
- }
379
-
380
- // 总览页健康检查:plist 指定的启动链若已断(脚本丢失、PATH 里解析不到
381
- // cron-up/node、写死路径失效如 nvm 版本目录被清),launchd 每轮都会静默
382
- // 拉不起来;写死路径的形态还检查 fnm/nvm 版本分叉。
383
- export function launcherHealth() {
384
- if (!fs.existsSync(deps.paths.plistPath)) return null;
385
- const args = readPlistProgramArgs();
386
- if (!args) return null;
387
- if (args.length === 1) {
388
- const [scriptPath] = args;
389
- if (!fs.existsSync(scriptPath)) {
390
- return `launchd 配置中的启动脚本已失效(${path.basename(scriptPath)} `
391
- + '不存在),巡检当前拉不起来;请重新运行 cron-up install';
392
- }
393
- const shape = readServiceScriptShape(scriptPath);
394
- if (shape === 'path') return pathResolveHealth(scriptPath);
395
- if (Array.isArray(shape)) return pathHealthAlert(...shape);
396
- return `启动脚本 ${scriptPath} 内容无法解读(可能被手改),`
397
- + '重跑 cron-up install 覆盖重生成';
398
- }
399
- if (args.length === 3) {
400
- // 旧版 plist(ProgramArguments 直写 node 与入口):升级后重跑
401
- // install 前的过渡形态,照常检查。
402
- return pathHealthAlert(args[0], args[1]);
403
- }
404
- return null;
405
- }
406
-
407
243
  // ---- install / uninstall ----
408
244
 
409
- // 26.9.3 及更早把数据放在 ~/Library/Application Support/cron-up;新版统一
410
- // 到 ~/.local/share/cron-up。整体搬家(config、state 随行),随后 install
411
- // 照常重写脚本与 plist 并重启服务,用户无感。搬家只在 install 里发生:
412
- // 只升级 npm 包而不跑 install 的话,旧目录与旧 plist 依旧配套、照常巡检。
413
- function migrateLegacyDir() {
414
- const legacy = deps.paths.legacyAppSupport;
415
- if (!fs.existsSync(legacy) || fs.existsSync(deps.paths.dataDir)) return;
416
- try {
417
- fs.mkdirSync(path.dirname(deps.paths.dataDir), { recursive: true });
418
- try {
419
- fs.renameSync(legacy, deps.paths.dataDir);
420
- } catch {
421
- // rename 搬不动(目标异常等):退化为复制,旧目录留着无害。
422
- fs.cpSync(legacy, deps.paths.dataDir, { recursive: true });
423
- }
424
- deps.print(`已迁移数据目录:${legacy} → ${deps.paths.dataDir}`);
425
- } catch {
426
- // 迁移彻底失败必须当面说:不说的话 install 会按「全新安装」落默认
427
- // 配置,用户收窄过的 roots 等手改就静默丢了。
428
- deps.print(`提示:旧数据目录迁移失败(${legacy}),本次按新目录继续;`
429
- + '如旧目录里还有你改过的配置,请手动复制到 '
430
- + `${deps.paths.dataDir} 后重跑 cron-up install`);
431
- }
432
- }
433
-
434
245
  export async function cmdInstall(args) {
435
- migrateLegacyDir();
436
246
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
437
247
  fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
438
248
  try {
@@ -518,13 +328,14 @@ export async function cmdInstall(args) {
518
328
  if (JSON.stringify(cfg.roots)
519
329
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
520
330
  // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
521
- deps.print('提示:未指定 --roots,扫描范围是整个用户目录,今后 clone 的'
522
- + '仓库也会进入巡检;自动续期会把其中的周期任务永久化。建议用 '
523
- + '--roots 显式限定范围(见 README 安全说明)');
331
+ deps.print('提示:未指定 --roots,默认扫描 ~/space、~/workspace、~/tasks'
332
+ + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
333
+ + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
334
+ + '--roots 指定(见 README 安全说明)');
524
335
  }
525
336
 
526
337
  // npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
527
- // 解析它们的稳定绝对路径,写进启动脚本(plist 只指向脚本,让后台项目
338
+ // 解析它们所在的 bin 目录,写进启动脚本(plist 只指向脚本,让后台项目
528
339
  // 列表显示 cron-up-service 而非 node 的签名者)。
529
340
  const launcher = resolveLauncher();
530
341
  if (launcher.note) deps.print(`提示:${launcher.note}`);
@@ -565,6 +376,17 @@ export async function cmdUninstall(args) {
565
376
  await deps.stopSession(ent);
566
377
  }
567
378
  }
379
+ // state 丢失时的兜底:没进登记的孤儿会话(卡在提问界面就不会出现在
380
+ // state 里)从进程表按日志目录精确认领。
381
+ for (const [pid, procArgs] of deps.listScriptProcesses()) {
382
+ const log = scriptProcLogPath(procArgs);
383
+ if (log === null
384
+ || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
385
+ const ent = { pid, procStart: deps.procStartedAt(pid) };
386
+ if (!deps.trackedAlive(ent)) continue;
387
+ deps.print(`正在结束保活会话(state 已丢失) pid=${pid}`);
388
+ await deps.stopSession(ent);
389
+ }
568
390
  }
569
391
 
570
392
  if (fs.existsSync(deps.paths.plistPath)) {
@@ -573,8 +395,7 @@ export async function cmdUninstall(args) {
573
395
  }
574
396
 
575
397
  if (args.purge) {
576
- for (const p of [deps.paths.dataDir, deps.paths.legacyAppSupport,
577
- deps.paths.logDir]) {
398
+ for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
578
399
  fs.rmSync(p, { recursive: true, force: true });
579
400
  deps.print(`已删除 ${p}`);
580
401
  }
package/src/sessions.mjs CHANGED
@@ -177,6 +177,14 @@ export function listScriptProcesses() {
177
177
  return out;
178
178
  }
179
179
 
180
+ // 从 ps 的 args 输出里解出 script(1) 的 typescript 路径(-q 与固定后缀
181
+ // /bin/zsh 之间的参数,非贪婪,路径含空格也成立)。不是本工具拉起形态的
182
+ // script 进程返回 null。
183
+ export function scriptProcLogPath(procArgs) {
184
+ const m = procArgs.match(/ -q (.+?) \/bin\/zsh /);
185
+ return m ? m[1] : null;
186
+ }
187
+
180
188
  Object.assign(deps, {
181
189
  alive,
182
190
  procStartedAt,
@@ -1,40 +0,0 @@
1
- // 本文件由 scripts/gen-east-asian-width.mjs 生成,请勿手改。
2
- // 数据:Unicode East_Asian_Width 的 W/F 码点区间(Unicode 18.0.0,2026-06-29, 15:25:05 GMT)。
3
- // Ambiguous 字符(如 U+2026 省略号)不在表中,按 1 列计。
4
- // 共 126 个连续区间,二分查找。
5
-
6
- const WIDE_RANGES = [
7
- [4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200],[9203],
8
- [9725,9726],[9748,9749],[9776,9783],[9800,9811],[9855],[9866,9871],
9
- [9875],[9889],[9898,9899],[9917,9918],[9924,9925],[9934],
10
- [9940],[9962],[9970,9971],[9973],[9978],[9981],
11
- [9989],[9994,9995],[10024],[10060],[10062],[10067,10069],
12
- [10071],[10133,10135],[10160],[10175],[11035,11036],[11088],
13
- [11093],[11904,11929],[11931,12019],[12032,12245],[12272,12350],[12353,12438],
14
- [12441,12543],[12549,12591],[12593,12686],[12688,12773],[12783,12830],[12832,12871],
15
- [12880,42124],[42128,42182],[43360,43388],[44032,55203],[63744,64255],[65040,65049],
16
- [65072,65106],[65108,65126],[65128,65131],[65281,65376],[65504,65510],[94176,94180],
17
- [94192,94198],[94208,101594],[101631,101664],[101760,101874],[101888,102801],[102816,102866],
18
- [110576,110579],[110581,110587],[110589,110590],[110592,110888],[110898],[110928,110930],
19
- [110933],[110948,110952],[110960,111355],[119552,119638],[119648,119670],[126980],
20
- [127183],[127374],[127377,127386],[127406],[127488,127490],[127504,127547],
21
- [127552,127560],[127568,127569],[127584,127589],[127744,127776],[127789,127797],[127799,127868],
22
- [127870,127891],[127904,127946],[127951,127955],[127968,127984],[127988],[127992,128062],
23
- [128064],[128066,128252],[128255,128317],[128331,128334],[128336,128359],[128378],
24
- [128405,128406],[128420],[128507,128591],[128640,128709],[128716],[128720,128722],
25
- [128725,128729],[128732,128735],[128747,128748],[128756,128764],[128986],[128992,129003],
26
- [129008],[129292,129338],[129340,129349],[129351,129535],[129648,129660],[129664,129734],
27
- [129736],[129740,129757],[129759,129771],[129775,129786],[131072,196605],[196608,262141]
28
- ];
29
-
30
- export function isWide(cp) {
31
- let lo = 0, hi = WIDE_RANGES.length - 1;
32
- while (lo <= hi) {
33
- const mid = (lo + hi) >> 1;
34
- const [a, b] = WIDE_RANGES[mid];
35
- if (cp < a) hi = mid - 1;
36
- else if (cp > (b ?? a)) lo = mid + 1;
37
- else return true;
38
- }
39
- return false;
40
- }