cron-up 26.9.21 → 26.9.22

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
@@ -130,10 +130,11 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
130
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
- - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,安装时直接报错中止。
134
- - **长驻会话自动换代**:被续期任务保活的会话会长期常驻,但常驻不等于长生不老——连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
135
- - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
136
- - **日志大小**:后台会话的界面输出是持续的全屏渲染流。单次运行产生的日志超过 10MB 时,会在下次启动该目录会话时轮转成 `.log.1`;而 `script(1)` 每次启动会清空旧日志,所以反复重启不会造成堆积。长驻会话的单代日志总量由自动换代(见上条)约束;唯一无法覆盖的是连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
133
+ - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
134
+ - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。效果:**错开够远(约十分钟、且含上一次任务的执行时长)的任务必然各自拿到全新会话**,稍近的可能共享(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
135
+ - **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
136
+ - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
137
+ - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
137
138
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
138
139
 
139
140
  ## 安全说明
@@ -151,10 +152,12 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
151
152
 
152
153
  - 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行;唤醒后 launchd 会立即跑一轮巡检,错过的**一次性**任务会在后台会话启动时由 Claude Code 补执行。
153
154
  - Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
154
- - 周期任务预热成功后,后台会话会一直保留到任务清空、进程退出或自动换代(连续运行超 7 天且静默超 1 小时,见「维护」),**不会在每次触发后回收**:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。autoRenew 默认开启使这成为常态;自动换代把累积周期约束在 7 天空闲以内,更细粒度的「触发后回收、下次执行前重新预热」仍是后续方向。
155
+ - 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 Claude Code 官方支持任务路由。
155
156
  - 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
156
157
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
157
158
  - 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话。
159
+ - 「已有会话在消费」按目录判断,分不清消费者是我们的预热会话还是你本人打开的会话:卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。
160
+ - 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
158
161
  - 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
159
162
  - 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
160
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.21",
3
+ "version": "26.9.22",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "license": "MIT",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+ssh://git@github.com/adaex/cron-up.git"
22
+ "url": "git+https://github.com/adaex/cron-up.git"
23
23
  },
24
24
  "keywords": [
25
25
  "cron",
package/src/cli.mjs CHANGED
@@ -21,6 +21,7 @@ const SPECS = {
21
21
  lead: 'int',
22
22
  force: 'bool',
23
23
  'auto-renew': 'tri',
24
+ 'session-retain': 'string',
24
25
  },
25
26
  },
26
27
  uninstall: {
@@ -159,7 +160,7 @@ export const USAGE = `cron-up —— 为定时任务提前备好交互会话
159
160
 
160
161
  用法:
161
162
  cron-up 查看总览(服务、配置、任务、会话)
162
- cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --force --no-auto-renew)
163
+ cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --force --no-auto-renew)
163
164
  cron-up uninstall 卸载(--purge 连配置日志一起删,--keep-sessions 保留后台会话)
164
165
  cron-up list 逐个列出定时任务及下次执行时间
165
166
  cron-up run 执行一轮巡检(launchd 入口)
package/src/config.mjs CHANGED
@@ -70,6 +70,10 @@ export function configFieldErrors(cfg) {
70
70
  errs.push(['autoRenew',
71
71
  `autoRenew 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoRenew)}`]);
72
72
  }
73
+ if (cfg.sessionRetain !== 'window' && cfg.sessionRetain !== 'always') {
74
+ errs.push(['sessionRetain',
75
+ `sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
76
+ }
73
77
  return errs;
74
78
  }
75
79
 
@@ -149,10 +153,29 @@ export function loadState() {
149
153
  return out;
150
154
  }
151
155
 
156
+ // 自产物按私密收紧(与会话日志、任务文件写入同一纪律):state 含工作区路
157
+ // 径与 pid,多用户机器上不给其他账号读面。chmod 顺带收回老版本留下的宽松
158
+ // 权限——state 每轮都写、config 每次 install 都写,存量会自动跟进;失败维
159
+ // 持现状。
152
160
  function atomicSave(file, doc) {
153
- fs.mkdirSync(path.dirname(file), { recursive: true });
161
+ const dir = path.dirname(file);
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ try {
164
+ fs.chmodSync(dir, 0o700);
165
+ } catch {
166
+ // 维持现状。
167
+ }
154
168
  const tmp = `${file}.tmp`;
155
- fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
169
+ // 以 0600 创建;残留的旧 tmp 更宽时也收回来,rename 保持该模式位。
170
+ fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, {
171
+ flag: 'w',
172
+ mode: 0o600,
173
+ });
174
+ try {
175
+ fs.chmodSync(tmp, 0o600);
176
+ } catch {
177
+ // 维持 0600。
178
+ }
156
179
  fs.renameSync(tmp, file);
157
180
  }
158
181
 
package/src/constants.mjs CHANGED
@@ -14,6 +14,11 @@ export const DEFAULT_CONFIG = {
14
14
  // 全部补标。配置里 autoRenew:false 可关(下轮生效),或安装时
15
15
  // --no-auto-renew。
16
16
  autoRenew: true,
17
+ // 会话保留策略:'window'(默认)——会话生命周期对齐「一次执行」:任务
18
+ // 触发过、执行完毕且距下次需要还有余量即回收,下次窗口重拉,任务之间
19
+ // 上下文不互相累积;'always'——旧常驻行为,执行间隔里也保留会话,仅受
20
+ // 7 天超龄换代约束。
21
+ sessionRetain: 'window',
17
22
  };
18
23
 
19
24
  // 预热会话连续三次启动后死亡,大概率是环境坏了(缺信任、env 不对),退避
@@ -36,9 +41,14 @@ export const WARMUP_GRACE_SECONDS = 180;
36
41
  // 才退役——日志安静是没有任务在执行的证据,运行中的任务绝不会被切掉。
37
42
  export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
38
43
  export const SESSION_IDLE_SECONDS = 3600;
39
- // TUI typescript 是全屏渲染流,长驻时无限增长,拉起时轮转(script(1) 打开
40
- // 即截断,所以轮转粒度是一个预热会话的生命周期)。
41
- export const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
44
+ // window 模式回收判据一:界面静默超过此时长视为「本次执行已结束」。TUI
45
+ // 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;与
46
+ // WARMUP_GRACE 同值,对齐「3 分钟定生死」的既有心智。
47
+ export const SESSION_RECYCLE_IDLE_SECONDS = 180;
48
+ // window 模式回收判据二:距下次「需要会话在场」的时刻至少还有这么多秒。
49
+ // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
50
+ // 发直接复用现有会话(时间紧邻的任务共享)。
51
+ export const SESSION_FIRE_MARGIN_SECONDS = 120;
42
52
  // 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
43
53
  // 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
44
54
  export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
package/src/overview.mjs CHANGED
@@ -165,7 +165,9 @@ const COMMANDS = [
165
165
  export async function cmdOverview(args) {
166
166
  const cols = deps.terminalWidth();
167
167
  const v = packageVersion();
168
- deps.print(`cron-up ${v ?? ''} —— 为定时任务提前备好交互会话`);
168
+ deps.print(v === null
169
+ ? 'cron-up —— 为定时任务提前备好交互会话'
170
+ : `cron-up ${v} —— 为定时任务提前备好交互会话`);
169
171
 
170
172
  const cfgPath = args?.config ?? deps.paths.configPath;
171
173
  let cfg = null;
@@ -200,12 +202,14 @@ export async function cmdOverview(args) {
200
202
  } else {
201
203
  const actualIv = info ? info.interval : null;
202
204
  const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
205
+ const retainTag = cfg.sessionRetain === 'always'
206
+ ? ',会话常驻' : ',会话按执行回收';
203
207
  if (actualIv === null || actualIv === undefined) {
204
208
  deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
205
- + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}`);
209
+ + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${retainTag}`);
206
210
  } else {
207
211
  deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
208
- + `会话${renewTag}`);
212
+ + `会话${renewTag}${retainTag}`);
209
213
  if (actualIv !== cfg.intervalSeconds) {
210
214
  alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
211
215
  + '秒不一致,重新运行 cron-up install 后生效');
package/src/patrol.mjs CHANGED
@@ -13,11 +13,13 @@ import {
13
13
  WARMUP_GRACE_SECONDS,
14
14
  SESSION_MAX_AGE_SECONDS,
15
15
  SESSION_IDLE_SECONDS,
16
+ SESSION_RECYCLE_IDLE_SECONDS,
17
+ SESSION_FIRE_MARGIN_SECONDS,
16
18
  PATROL_LOG_ROTATE_BYTES,
17
19
  } from './constants.mjs';
18
20
  import { loadConfig, loadState, saveState } from './config.mjs';
19
21
  import { renewWorkspace, TaskFileChanged } from './tasks.mjs';
20
- import { taskWanted } from './cron.mjs';
22
+ import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
21
23
  import {
22
24
  sessionLogPath,
23
25
  sessionLogIdleSeconds,
@@ -124,9 +126,25 @@ export function rotatePatrolLogs() {
124
126
  }
125
127
  }
126
128
 
127
- // 保证一个工作区里有一个健康的消费者会话。原地修改 state。
129
+ // 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
130
+ // 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。
131
+ function needHorizonMs(tasks, now) {
132
+ let horizon = Infinity;
133
+ for (const t of tasks) {
134
+ const cron = parseCronOrNone(t?.cron);
135
+ if (cron === null) continue;
136
+ if (wanted(cron, t, now, 0)) return now.getTime();
137
+ const nxt = cron.nextAfter(now)?.getTime();
138
+ if (nxt !== undefined && nxt < horizon) horizon = nxt;
139
+ }
140
+ return horizon;
141
+ }
142
+
143
+ // 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
144
+ // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
128
145
  export async function patrolWorkspace(
129
146
  ws, state, now, leadMs, cur, consumers, scriptProcs = [],
147
+ retain = 'always',
130
148
  ) {
131
149
  const tasks = deps.readTasks(ws);
132
150
  let ent = state[ws] ?? null;
@@ -169,9 +187,42 @@ export async function patrolWorkspace(
169
187
  delete state[ws];
170
188
  ent = null;
171
189
  mineAlive = false;
190
+ // 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
191
+ // 次,别让陈旧登记挡住下面的重拉。
192
+ const [fresh] = await deps.scanSessions();
193
+ consumers = fresh;
172
194
  }
173
195
  }
174
196
 
197
+ // window 模式回收:会话生命周期对齐「一次执行」而非「常驻」。三个条件
198
+ // 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
199
+ // ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
200
+ // 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
201
+ // 即不在干活);距下次需要会话还有余量(回收后同轮重拉,新会话来得及
202
+ // 登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任务路
203
+ // 由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复执行的
204
+ // 风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计数一并
205
+ // 清零。
206
+ if (ent !== null && mineAlive && retain === 'window'
207
+ && tasks.some((t) => typeof t?.lastFiredAt === 'number'
208
+ && t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
209
+ && sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
210
+ > SESSION_RECYCLE_IDLE_SECONDS
211
+ && needHorizonMs(tasks, now) - now.getTime()
212
+ > SESSION_FIRE_MARGIN_SECONDS * 1000) {
213
+ const pid = ent.pid;
214
+ await deps.stopSession(ent);
215
+ deps.log(`回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
216
+ + '结束会话,下次需要时重拉');
217
+ delete state[ws];
218
+ ent = null;
219
+ mineAlive = false;
220
+ // 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
221
+ // 重扫依旧看见,不会在同目录双开。
222
+ const [fresh] = await deps.scanSessions();
223
+ consumers = fresh;
224
+ }
225
+
175
226
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
176
227
  // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
177
228
  // 却,连一次新尝试都不给。
@@ -184,13 +235,10 @@ export async function patrolWorkspace(
184
235
  const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
185
236
 
186
237
  if (consumers.has(ws)) {
187
- // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
188
- // (活消费者证明健康 → 计数清零),后者放手——但不在这里删死条目,失败
189
- // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
190
- // 由轮末 kept 过滤统一处理。
191
- if (ent !== null && mineAlive) {
192
- ent.fails = 0;
193
- }
238
+ // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
239
+ // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
240
+ // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
241
+ // 末 kept 过滤统一处理。
194
242
  return;
195
243
  }
196
244
 
@@ -325,7 +373,8 @@ export async function cmdRun(args) {
325
373
  }
326
374
  try {
327
375
  await patrolWorkspace(
328
- ws, state, now, leadMs, cur, consumers, scriptProcs);
376
+ ws, state, now, leadMs, cur, consumers, scriptProcs,
377
+ cfg.sessionRetain);
329
378
  } catch (e) {
330
379
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
331
380
  }
package/src/service.mjs CHANGED
@@ -244,6 +244,13 @@ export function launcherHealth() {
244
244
  // ---- install / uninstall ----
245
245
 
246
246
  export async function cmdInstall(args) {
247
+ // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
248
+ // 路径的配置,指定别的路径不改变任何行为——静默忽略等于让人以为装到了
249
+ // 别处。查询与巡检命令(run/renew/list/总览)才真正支持 --config。
250
+ if (args.config !== undefined) {
251
+ throw new ExitError(2, 'install 不支持 --config:配置与 LaunchAgent 固定'
252
+ + `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
253
+ }
247
254
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
248
255
  fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
249
256
  try {
@@ -273,6 +280,7 @@ export async function cmdInstall(args) {
273
280
  if (args.interval !== undefined) ignored.push('--interval');
274
281
  if (args.lead !== undefined) ignored.push('--lead');
275
282
  if (args.autoRenew !== undefined) ignored.push('--auto-renew');
283
+ if (args.sessionRetain !== undefined) ignored.push('--session-retain');
276
284
  if (ignored.length > 0) {
277
285
  deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
278
286
  + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
@@ -312,10 +320,31 @@ export async function cmdInstall(args) {
312
320
  overrides.roots = roots;
313
321
  }
314
322
  // is not undefined,而非 truthiness:--lead 0(「不要提前量」)是合法
315
- // 选择,不能被悄悄丢掉;--interval 0 也要留到下面的校验被响亮拒绝。
316
- if (args.interval !== undefined) overrides.intervalSeconds = args.interval;
317
- if (args.lead !== undefined) overrides.leadSeconds = args.lead;
323
+ // 选择,不能被悄悄丢掉。值域就地拦且必须在 saveConfig 之前:文件继承
324
+ // 的值已由 loadConfig / configFieldErrors 把关,命令行值只经过整数解
325
+ // 析——被拒的安装若把非法值写进配置,此后每轮巡检都会以退出码 2 死掉。
326
+ if (args.interval !== undefined) {
327
+ if (!isInt(args.interval) || args.interval <= 0) {
328
+ deps.printErr(`--interval 必须是正整数秒,当前为 ${JSON.stringify(args.interval)}`);
329
+ throw new ExitError(1);
330
+ }
331
+ overrides.intervalSeconds = args.interval;
332
+ }
333
+ if (args.lead !== undefined) {
334
+ if (!isInt(args.lead) || args.lead < 0) {
335
+ deps.printErr(`--lead 必须是非负整数秒,当前为 ${JSON.stringify(args.lead)}`);
336
+ throw new ExitError(1);
337
+ }
338
+ overrides.leadSeconds = args.lead;
339
+ }
318
340
  if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
341
+ if (args.sessionRetain !== undefined) {
342
+ if (args.sessionRetain !== 'window' && args.sessionRetain !== 'always') {
343
+ deps.printErr(`--session-retain 只接受 window 或 always,当前为 ${JSON.stringify(args.sessionRetain)}`);
344
+ throw new ExitError(1);
345
+ }
346
+ overrides.sessionRetain = args.sessionRetain;
347
+ }
319
348
  cfg = { ...cfg, ...overrides };
320
349
  cfg.roots = normalizeRoots(cfg.roots ?? []);
321
350
  saveConfig(cfg);
@@ -324,18 +353,7 @@ export async function cmdInstall(args) {
324
353
  deps.print(`已写入配置:${deps.paths.configPath}(更新字段:${updated},${rest})`);
325
354
  }
326
355
 
327
- // 动系统前先校验生效配置:文件继承的值由 loadConfig / configFieldErrors
328
- // 保证类型,但命令行给的 --interval/--lead 只经过整数解析,取值范围要在
329
- // 这里拦(--interval 0 非法、--lead 0 是合法选择);软问题(提前量过
330
- // 小、目录缺失)只警告。
331
- if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
332
- deps.printErr(`intervalSeconds 必须是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`);
333
- throw new ExitError(1);
334
- }
335
- if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
336
- deps.printErr(`leadSeconds 必须是非负整数秒,当前为 ${JSON.stringify(cfg.leadSeconds)}`);
337
- throw new ExitError(1);
338
- }
356
+ // 命令行值的硬校验已在落盘前完成;软问题(提前量过小、目录缺失)只警告。
339
357
  validateConfig(cfg, (m) => deps.print(m));
340
358
  if (JSON.stringify(cfg.roots)
341
359
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
@@ -376,6 +394,12 @@ export async function cmdInstall(args) {
376
394
  }
377
395
 
378
396
  export async function cmdUninstall(args) {
397
+ // 同 install:uninstall 动的是本机固定的安装产物,--config 只会让人以为
398
+ // 卸载的是另一套安装(--purge 删的也是真实目录)。
399
+ if (args.config !== undefined) {
400
+ throw new ExitError(2, 'uninstall 不支持 --config:卸载操作的是本机固定'
401
+ + `的安装产物(${deps.paths.plistPath} 等)`);
402
+ }
379
403
  const r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
380
404
  if (r.status === 0) deps.print('已从 launchd 卸载');
381
405
 
package/src/sessions.mjs CHANGED
@@ -7,7 +7,6 @@ import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
9
  import {
10
- LOG_ROTATE_BYTES,
11
10
  SESSION_IDLE_SECONDS,
12
11
  } from './constants.mjs';
13
12
 
@@ -98,10 +97,12 @@ export function spawnSession(ws) {
98
97
  } catch {
99
98
  // 目录权限维持现状。
100
99
  }
101
- // script(1) 不带 O_APPEND 打开 typescript,下次 spawn 会截断,所以只轮
102
- // 转仍在增长的会话留下的超大日志。
100
+ // window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
101
+ // 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
102
+ // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
103
+ // 轮转放大。
103
104
  try {
104
- if (fs.existsSync(logPath) && fs.statSync(logPath).size > LOG_ROTATE_BYTES) {
105
+ if (fs.existsSync(logPath)) {
105
106
  const old = `${logPath}.1`;
106
107
  fs.rmSync(old, { force: true });
107
108
  fs.renameSync(logPath, old);