cron-up 26.9.7 → 26.9.21

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
@@ -126,8 +126,8 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
126
126
 
127
127
  - **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
128
128
  - **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
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」这类漂移。发布走 **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` 刷新后用显式版本号安装,装完以总览首行版本号为准。
129
+ - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;不带 `--force` 时配置原样保留,命令行显式给的参数会被忽略并在安装输出里点名提示;路径参数建议都加引号书写,例如 `--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` 才切到新版本。版本号与发布流程见下文「发布」一节。
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` 非法,安装时直接报错中止。
@@ -138,7 +138,7 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
138
138
 
139
139
  ## 安全说明
140
140
 
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,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
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,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
142
142
  - **续期会改变三条生命周期/边界**:
143
143
  1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
144
144
  2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
@@ -185,6 +185,14 @@ npm link # 本机把开发版链接成全局 cron-up,迭代
185
185
 
186
186
  用例名自说明覆盖面,不再这里另抄一份清单(抄来的清单只会腐化)。终端展示的东亚字符宽度用 `src/display.mjs` 里的一张 CJK 区间启发式判定,只覆盖任务摘要里真实出现的形态。
187
187
 
188
+ ## 发布
189
+
190
+ - **版本号**:按「年.月.序号」——两位年份、月份、累计发布序号。序号永不重置、不随月份归零,每次发布加一,始终等于含本次在内的历史发布总次数(0.x 时代的发布同样计入)。序号在 26.9.7 之后由月内计数改为累计计数,切换时历史共 20 次发布,故下一次是第 21 次:9 月内再发是 `26.9.21`,到 10 月发是 `26.10.21`,月份照常反映发布时间、序号只增不减。下一序号不用手数:发布前 `git fetch --prune --prune-tags --tags` 后 `git tag -l 'v*' | wc -l` 数出已有发布次数,加一即得(prune-tags 让删错的远端 tag 不至于把本地计数顶歪)。发布失败的 tag 重跑 release 工作流即可,序号不消耗。
191
+ - **发布方式**:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.21`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致(版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移),并复核 tag 序号等于含它在内的 `v*` tag 总数(序号数错当场失败),然后 publish。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
192
+ - **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 会被拒绝。
193
+ - **npm 传播延迟**:publish 成功后 npm 服务端约两分钟才完成处理,`latest` 标签与本机 npm 缓存各自再滞后一层——发布后立即 `npm i -g cron-up@latest` 可能装到旧版且毫无提示(`changed 1 package` 不含版本号);等 `npm view cron-up version` 刷新后用显式版本号安装,装完以总览首行版本号为准。
194
+ - **发版收尾**(每次发版必做):发版方本机按上述方式升级后,跑一次无参数 `cron-up` 确认三件事——首行是刚发的版本、服务已加载且上次退出码 0、「需要留意」为空——全部满足发版才算结束。
195
+
188
196
  ## License
189
197
 
190
198
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.7",
3
+ "version": "26.9.21",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/patrol.mjs CHANGED
@@ -213,7 +213,9 @@ export async function patrolWorkspace(
213
213
  // 冷却刚结束:给一次干净的缓刑拉起,而不是立刻再次顶满限制。
214
214
  fails = 0;
215
215
  } else {
216
- fails = (ent?.fails ?? 0) + (ent ? 1 : 0);
216
+ // 只有真的拉起过(pid 非空)的条目死亡才算一次新失败;pid 为空的条目
217
+ // 是当场就没拉起的尝试,那次失败已在写入时计过,不再重复累计。
218
+ fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
217
219
  }
218
220
 
219
221
  if (fails >= FAIL_LIMIT) {
@@ -251,15 +253,29 @@ export async function patrolWorkspace(
251
253
  }
252
254
  }
253
255
 
254
- const { pid, procStart } = deps.spawnSession(ws);
256
+ // 成败只认一个事实——有没有拿到 pid:null、undefined、无 pid 的对象都算
257
+ // 没拉起,计数与日志同源,不会各说各话。
258
+ const spawned = deps.spawnSession(ws);
259
+ if (spawned?.pid == null) {
260
+ // 本次尝试已当场失败,立即计入(成功拉起则本次尚待观察,沿用 fails)。
261
+ const newFails = fails + 1;
262
+ state[ws] = {
263
+ pid: null,
264
+ startedAt: cur,
265
+ fails: newFails,
266
+ log: sessionLogPath(ws),
267
+ };
268
+ deps.log(`启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
269
+ return;
270
+ }
255
271
  state[ws] = {
256
- pid,
272
+ pid: spawned.pid,
257
273
  startedAt: cur,
258
- procStart,
274
+ procStart: spawned.procStart,
259
275
  fails,
260
276
  log: sessionLogPath(ws),
261
277
  };
262
- deps.log(`已启动 ${ws} pid=${pid}(连续失败计数 ${fails})`);
278
+ deps.log(`已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
263
279
  }
264
280
 
265
281
  // 跑一轮巡检;抢不到锁返回 false。
package/src/service.mjs CHANGED
@@ -26,9 +26,10 @@ export function guiTarget() {
26
26
  return `gui/${process.getuid()}`;
27
27
  }
28
28
 
29
- // launchctl 调用统一走不抛的 execFile,判成败看 status。
29
+ // launchctl 调用统一走不抛的 execFile,判成败看 status。与 ps 同款防御:
30
+ // launchctl 卡住时命令挂死不如超时报「未加载」——总览页至少还能出来。
30
31
  export function launchctl(...args) {
31
- return deps.execFile('/bin/launchctl', args);
32
+ return deps.execFile('/bin/launchctl', args, { timeout: 10000 });
32
33
  }
33
34
 
34
35
  export function launchctlInfo() {
@@ -265,6 +266,17 @@ export async function cmdInstall(args) {
265
266
  throw e;
266
267
  }
267
268
  deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
269
+ // 不带 --force 时配置原样保留,命令行显式给的参数一律不生效:点名说清
270
+ // 被忽略的是哪些,而不是让用户以为已经改了。
271
+ const ignored = [];
272
+ if (args.roots !== undefined) ignored.push('--roots');
273
+ if (args.interval !== undefined) ignored.push('--interval');
274
+ if (args.lead !== undefined) ignored.push('--lead');
275
+ if (args.autoRenew !== undefined) ignored.push('--auto-renew');
276
+ if (ignored.length > 0) {
277
+ deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
278
+ + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
279
+ }
268
280
  } else {
269
281
  // 即使 --force 也从现有配置(全新安装则默认值)起步,只覆盖命令行给
270
282
  // 的字段——否则 --force --interval 600 会悄悄把 roots 重置成默认。
package/src/sessions.mjs CHANGED
@@ -128,6 +128,9 @@ export function spawnSession(ws) {
128
128
  // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出)。
129
129
  child.on('error', () => {});
130
130
  child.unref();
131
+ // 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
132
+ // 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。
133
+ if (child.pid === undefined) return null;
131
134
  return { pid: child.pid, procStart: deps.procStartedAt(child.pid) };
132
135
  }
133
136
 
package/src/tasks.mjs CHANGED
@@ -146,6 +146,13 @@ export function loadTaskDoc(file) {
146
146
  return doc;
147
147
  }
148
148
 
149
+ // 临时文件用专属后缀而非泛用的 .tmp:清扫时只认自己的名字,不会误删恰好
150
+ // 用同一约定的其他写者(巡检锁只隔离 cron-up 自己,管不到 Claude Code)
151
+ // 刚建好的临时文件。
152
+ function tmpSibling(file) {
153
+ return `${file}.cron-up-tmp`;
154
+ }
155
+
149
156
  // 旁边写再 rename 的原子替换,保留原文件权限位与中文字面量。
150
157
  // tmp 以 0600 创建(绝不更宽),写完再放宽到原 mode。expectedMtimeNs 是
151
158
  // 乐观锁:路径 mtime 自调用方读后变了说明别的写者抢先,抛
@@ -157,7 +164,7 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
157
164
  } catch {
158
165
  // 新文件用 0600。
159
166
  }
160
- const tmp = `${file}.tmp`;
167
+ const tmp = tmpSibling(file);
161
168
  // JSON.stringify 两空格缩进:中文按字面写出(不转 \u),无尾换行。
162
169
  fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), {
163
170
  flag: 'w',
@@ -197,6 +204,14 @@ export function renewWorkspace(taskfile) {
197
204
  } catch {
198
205
  return null;
199
206
  }
207
+ // 上次写一半被杀(SIGKILL/断电)留下的临时文件会永远躺在用户的仓库里
208
+ // ——已全部标好的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
209
+ // 且不碰别人。
210
+ try {
211
+ fs.unlinkSync(tmpSibling(taskfile));
212
+ } catch {
213
+ // 不存在即无事。
214
+ }
200
215
  const doc = deps.loadTaskDoc(taskfile);
201
216
  if (doc === null) return null;
202
217
  let tagged = 0;