cron-up 26.9.5 → 26.9.6

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` |
@@ -48,7 +48,7 @@ cron-up install --roots ~/code,~/work --interval 300 --lead 600
48
48
 
49
49
  `--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描家目录下两层以内(`~/` 自身、`~/*` 与 `~/*/*`),并自动跳过 `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,7 +127,7 @@ 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」这类漂移。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
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` 非法,安装时直接报错中止。
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.5",
3
+ "version": "26.9.6",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
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
  // 意」,而不是装作这个工作区不存在。
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
@@ -20,6 +20,7 @@ import { taskWanted } from './cron.mjs';
20
20
  import {
21
21
  sessionLogPath,
22
22
  sessionLogIdleSeconds,
23
+ scriptProcLogPath,
23
24
  } from './sessions.mjs';
24
25
  import { TASK_REL } from './paths.mjs';
25
26
 
@@ -236,7 +237,7 @@ export async function patrolWorkspace(
236
237
  for (const [procPid, procArgs] of scriptProcs) {
237
238
  // alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
238
239
  // 把死人认回来会白记一次失败。
239
- if (procArgs.includes(`-q ${logPath} `) && deps.alive(procPid)) {
240
+ if (scriptProcLogPath(procArgs) === logPath && deps.alive(procPid)) {
240
241
  state[ws] = {
241
242
  pid: procPid,
242
243
  startedAt: cur,
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 {
@@ -524,7 +334,7 @@ export async function cmdInstall(args) {
524
334
  }
525
335
 
526
336
  // npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
527
- // 解析它们的稳定绝对路径,写进启动脚本(plist 只指向脚本,让后台项目
337
+ // 解析它们所在的 bin 目录,写进启动脚本(plist 只指向脚本,让后台项目
528
338
  // 列表显示 cron-up-service 而非 node 的签名者)。
529
339
  const launcher = resolveLauncher();
530
340
  if (launcher.note) deps.print(`提示:${launcher.note}`);
@@ -565,6 +375,17 @@ export async function cmdUninstall(args) {
565
375
  await deps.stopSession(ent);
566
376
  }
567
377
  }
378
+ // state 丢失时的兜底:没进登记的孤儿会话(卡在提问界面就不会出现在
379
+ // state 里)从进程表按日志目录精确认领。
380
+ for (const [pid, procArgs] of deps.listScriptProcesses()) {
381
+ const log = scriptProcLogPath(procArgs);
382
+ if (log === null
383
+ || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
384
+ const ent = { pid, procStart: deps.procStartedAt(pid) };
385
+ if (!deps.trackedAlive(ent)) continue;
386
+ deps.print(`正在结束保活会话(state 已丢失) pid=${pid}`);
387
+ await deps.stopSession(ent);
388
+ }
568
389
  }
569
390
 
570
391
  if (fs.existsSync(deps.paths.plistPath)) {
@@ -573,8 +394,7 @@ export async function cmdUninstall(args) {
573
394
  }
574
395
 
575
396
  if (args.purge) {
576
- for (const p of [deps.paths.dataDir, deps.paths.legacyAppSupport,
577
- deps.paths.logDir]) {
397
+ for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
578
398
  fs.rmSync(p, { recursive: true, force: true });
579
399
  deps.print(`已删除 ${p}`);
580
400
  }
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
- }