cron-up 26.10.26 → 26.10.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.26",
3
+ "version": "26.10.27",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // 子命令选项规格与选项名转换。独立于 cli:install 的「未生效参数」名单
2
+ // (service.mjs)从同一张选项表派生,新增可持久化选项时两处自动同步。
3
+
4
+ // 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
5
+ // 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
6
+ export const SPECS = {
7
+ install: {
8
+ options: {
9
+ roots: 'string',
10
+ interval: 'int',
11
+ lead: 'int',
12
+ force: 'bool',
13
+ 'auto-renew': 'tri',
14
+ 'auto-min-id': 'tri',
15
+ 'session-retain': 'string',
16
+ },
17
+ },
18
+ uninstall: {
19
+ options: {
20
+ purge: 'bool',
21
+ 'keep-sessions': 'bool',
22
+ },
23
+ },
24
+ list: { options: {} },
25
+ run: { options: {} },
26
+ renew: { options: {} },
27
+ logs: {
28
+ options: { follow: 'bool', f: 'bool' },
29
+ positionalMax: 1,
30
+ },
31
+ };
32
+
33
+ // 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
34
+ export function thisCamel(name) {
35
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
36
+ }
package/src/cli.mjs CHANGED
@@ -10,35 +10,7 @@ import { cmdInstall, cmdUninstall } from './service.mjs';
10
10
  import { cmdList } from './list.mjs';
11
11
  import { cmdOverview } from './overview.mjs';
12
12
  import { cmdLogs } from './logs.mjs';
13
-
14
- // 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
15
- // 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
16
- const SPECS = {
17
- install: {
18
- options: {
19
- roots: 'string',
20
- interval: 'int',
21
- lead: 'int',
22
- force: 'bool',
23
- 'auto-renew': 'tri',
24
- 'auto-min-id': 'tri',
25
- 'session-retain': 'string',
26
- },
27
- },
28
- uninstall: {
29
- options: {
30
- purge: 'bool',
31
- 'keep-sessions': 'bool',
32
- },
33
- },
34
- list: { options: {} },
35
- run: { options: {} },
36
- renew: { options: {} },
37
- logs: {
38
- options: { follow: 'bool', f: 'bool' },
39
- positionalMax: 1,
40
- },
41
- };
13
+ import { SPECS, thisCamel } from './args.mjs';
42
14
 
43
15
  // 严格十进制整数命令行值(谓词 isDecInt 见 internals):
44
16
  // Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
@@ -102,11 +74,6 @@ function consumeOption(tok, tokens, i, spec, opts) {
102
74
  return inlineValue !== undefined ? i + 1 : i + 2;
103
75
  }
104
76
 
105
- // 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
106
- function thisCamel(name) {
107
- return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
108
- }
109
-
110
77
  export function parseCli(argv) {
111
78
  let command = null;
112
79
  const opts = {};
package/src/config.mjs CHANGED
@@ -4,7 +4,13 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- import { deps, ExitError, isPlainObject, isDecInt } from './internals.mjs';
7
+ import {
8
+ deps,
9
+ ExitError,
10
+ isPlainObject,
11
+ isDecInt,
12
+ ensurePrivateDir,
13
+ } from './internals.mjs';
8
14
  import { DEFAULT_CONFIG, PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
9
15
  import { expandHome, isDir } from './tasks.mjs';
10
16
 
@@ -31,7 +37,7 @@ export function isInt(x) {
31
37
  // 宽容整型化(手改的 state/登记文件里字段可能是字符串或浮点):纯十进制
32
38
  // 字符串接受,浮点截断,布尔拒绝;非法输入抛错由调用方按「字段不存在」
33
39
  // 处理。
34
- export function lenientInt(v) {
40
+ function lenientInt(v) {
35
41
  if (typeof v === 'boolean') throw new Error('布尔不是整数字段');
36
42
  if (typeof v === 'number') {
37
43
  if (!Number.isFinite(v)) throw new Error('数值越界');
@@ -160,18 +166,11 @@ export function loadState() {
160
166
  return out;
161
167
  }
162
168
 
163
- // 自产物按私密收紧(与会话日志、任务文件写入同一纪律):state 含工作区路
164
- // 径与 pid,多用户机器上不给其他账号读面。chmod 顺带收回老版本留下的宽松
165
- // 权限——state 每轮都写、config 每次 install 都写,存量会自动跟进;失败维
166
- // 持现状。
169
+ // state 含工作区路径与 pid,目录按 ensurePrivateDir 收紧;文件以 0600 原
170
+ // 子替换——state 每轮都写、config 每次 install 都写,老版本留下的宽松权
171
+ // 限会自动跟进。
167
172
  function atomicSave(file, doc) {
168
- const dir = path.dirname(file);
169
- fs.mkdirSync(dir, { recursive: true });
170
- try {
171
- fs.chmodSync(dir, 0o700);
172
- } catch {
173
- // 维持现状。
174
- }
173
+ ensurePrivateDir(path.dirname(file));
175
174
  const tmp = `${file}.tmp`;
176
175
  // 以 0600 创建;残留的旧 tmp 更宽时也收回来,rename 保持该模式位。
177
176
  fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, {
@@ -214,6 +213,17 @@ export function heartbeatStale(hb, intervalSeconds, nowSec) {
214
213
  return hb !== null && nowSec - hb.ranAt > intervalSeconds * HEARTBEAT_STALE_FACTOR;
215
214
  }
216
215
 
216
+ // 失败冷却的唯一判据:cooldownUntil 是秒级时间戳,0/缺省表示无冷却。巡检
217
+ // 决策、kept 过滤与总览展示共用,冷却表示法只渗到这两个函数。
218
+ export function isCooling(ent, cur) {
219
+ return (ent?.cooldownUntil ?? 0) > cur;
220
+ }
221
+
222
+ // 距冷却结束还有多少整分钟(已结束为 0),只用于总览文案。
223
+ export function cooldownEtaMinutes(ent, cur) {
224
+ return Math.max(0, Math.floor(((ent?.cooldownUntil ?? 0) - cur) / 60));
225
+ }
226
+
217
227
  export function saveConfig(cfg) {
218
228
  atomicSave(deps.paths.configPath, cfg);
219
229
  }
package/src/constants.mjs CHANGED
@@ -100,7 +100,7 @@ export const CC_JITTER = {
100
100
  // maxN,常量只保留「目标延迟」这一个可调项。
101
101
  export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
102
102
 
103
- // 投递尾窗判定的回看/前探窗口(wanted 与 predictedFire 共用,见
103
+ // 投递尾窗判定的回看/前探窗口(wanted 与 nextDelivery 共用,见
104
104
  // src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
105
105
  // 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
106
106
  // 点隔 4 年,世纪非闰年(2100)隔 8 年;窗口小了尾窗会塌缩成零:落点已
package/src/cron.mjs CHANGED
@@ -249,9 +249,9 @@ export function taskIdHash(taskId) {
249
249
  // 不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明天),
250
250
  // 否则是下一落点 + 按 id 的抖动。先于窗口检查尾窗:2 月 29 日这类长周期
251
251
  // 任务的下一落点可能在展示窗口之外,正在等待的投递却依然成立。taskView
252
- // 的 nxt/fire 与 predictedFire 共用这一个口径,落点与投递同源,两边不会
253
- // 各说各话。返回 {slot, fire},均为 Date 或 null(窗口内没有落点时双双
254
- // 为 null)。cron 须已解析且 satisfiable。
252
+ // 的 nxt/fire 与 wanted 都以这里的落点为单一来源,两边不会各说各话。返回
253
+ // {slot, fire},均为 Date 或 null(窗口内没有落点时双双为 null)。cron 须
254
+ // 已解析且 satisfiable。
255
255
  export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS) {
256
256
  if (taskIsOneshot(task)) {
257
257
  const slot = cron.nextAfter(now, withinDays);
@@ -285,16 +285,6 @@ export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS)
285
285
  // 经过该分支,故不予复刻。
286
286
  }
287
287
 
288
- // 任务按 Claude Code 的抖动规则预计的实际触发时刻(nextDelivery 的
289
- // fire);cron 无效、不在尾窗内、窗口内没有落点时返回 null。基准为 now
290
- // (上游调度器以上次触发时刻为基准,展示只关心从现在起的下一次,两者算
291
- // 出的落点一致)。
292
- export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
293
- const cron = parseCronOrNone(task?.cron);
294
- if (cron === null || !cron.satisfiable) return null;
295
- return nextDelivery(cron, task, now, withinDays).fire;
296
- }
297
-
298
288
  // 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
299
289
  // slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
300
290
  function recurringDelayMs(cron, slot, taskId, withinDays) {
package/src/display.mjs CHANGED
@@ -122,7 +122,8 @@ function sameDay(a, b) {
122
122
  && a.getMonth() === b.getMonth()
123
123
  && a.getDate() === b.getDate();
124
124
  }
125
- function sameMinute(a, b) {
125
+ // 「同一分钟」的唯一判据,fmtFireRange 的箭头省略与总览「设定」附注共用。
126
+ export function sameMinute(a, b) {
126
127
  return sameDay(a, b) && a.getHours() === b.getHours()
127
128
  && a.getMinutes() === b.getMinutes();
128
129
  }
@@ -142,10 +143,10 @@ export function fmtFireRange(nxt, fire) {
142
143
  }
143
144
  const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
144
145
  if (sameMinute(nxt, r)) return fmtMDHM(nxt);
146
+ const secs = r.getSeconds() ? `:${pad2(r.getSeconds())}` : '';
145
147
  const tail = sameDay(nxt, r)
146
- ? `${pad2(r.getHours())}:${pad2(r.getMinutes())}`
147
- + (r.getSeconds() ? `:${pad2(r.getSeconds())}` : '')
148
- : `${fmtMDHM(r)}${r.getSeconds() ? `:${pad2(r.getSeconds())}` : ''}`;
148
+ ? `${pad2(r.getHours())}:${pad2(r.getMinutes())}${secs}`
149
+ : `${fmtMDHM(r)}${secs}`;
149
150
  return `${fmtMDHM(nxt)} → ${tail}`;
150
151
  }
151
152
 
package/src/internals.mjs CHANGED
@@ -6,7 +6,9 @@
6
6
  // 纯系统叶子的默认实现放这里;业务函数(discover、spawnSession…)由各自
7
7
  // 模块注册。
8
8
 
9
+ import fs from 'node:fs';
9
10
  import { spawn, spawnSync, execFileSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
10
12
  import { paths } from './paths.mjs';
11
13
 
12
14
  export class ExitError extends Error {
@@ -64,6 +66,29 @@ function terminalWidth() {
64
66
  return Math.max(60, process.stdout.columns ?? 100);
65
67
  }
66
68
 
69
+ // 自产物目录(state/config、会话日志)含工作区路径与会话记录,按私密收紧:
70
+ // 建好后 chmod 0700,顺带收回老版本留下的宽松权限;失败维持现状。三处写入
71
+ // 点共用这一条纪律。
72
+ export function ensurePrivateDir(dir) {
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ try {
75
+ fs.chmodSync(dir, 0o700);
76
+ } catch {
77
+ // 维持现状。
78
+ }
79
+ }
80
+
81
+ // 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
82
+ // 版本实验特性的依赖);读不到返回 null,总览页降级显示。
83
+ export function packageVersion() {
84
+ try {
85
+ const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
86
+ return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
67
92
  export const deps = {
68
93
  paths,
69
94
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
package/src/logs.mjs CHANGED
@@ -42,8 +42,9 @@ export function resolveLogPath(workspace, announce) {
42
42
  const err = path.join(deps.paths.logDir, 'launchd.err.log');
43
43
  if (!fs.existsSync(out)) return err;
44
44
  try {
45
- if (fs.statSync(err).size > 0) {
46
- say(`注:${err} 有内容(最后写入 ${fmtMDHM(fs.statSync(err).mtime)}),`
45
+ const st = fs.statSync(err);
46
+ if (st.size > 0) {
47
+ say(`注:${err} 有内容(最后写入 ${fmtMDHM(st.mtime)}),`
47
48
  + '巡检若异常先看它');
48
49
  }
49
50
  } catch {
package/src/overview.mjs CHANGED
@@ -5,16 +5,17 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps } from './internals.mjs';
8
+ import { deps, packageVersion } from './internals.mjs';
9
9
  import {
10
10
  loadConfig,
11
11
  loadState,
12
12
  validateConfig,
13
13
  loadHeartbeat,
14
14
  heartbeatStale,
15
+ isCooling,
16
+ cooldownEtaMinutes,
15
17
  } from './config.mjs';
16
18
  import { taskFileExists } from './tasks.mjs';
17
- import { packageVersion } from './paths.mjs';
18
19
  import { serviceLine, launcherHealth } from './service.mjs';
19
20
  import {
20
21
  humanDelta,
@@ -26,6 +27,7 @@ import {
26
27
  elapsedZh,
27
28
  fmtMDHM,
28
29
  fmtHM,
30
+ sameMinute,
29
31
  } from './display.mjs';
30
32
 
31
33
  // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
@@ -81,11 +83,10 @@ function overviewTasks(cfg, cols, consumers, state) {
81
83
  }
82
84
  if (wanted && !consumers.has(ws)) {
83
85
  const ent = state[ws] ?? {};
84
- const cooldown = ent.cooldownUntil ?? 0;
85
- if (cooldown > cur) {
86
+ if (isCooling(ent, cur)) {
86
87
  // 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
87
88
  // 矛盾,这里合并成一条。
88
- const eta = Math.max(0, Math.floor((cooldown - cur) / 60));
89
+ const eta = cooldownEtaMinutes(ent, cur);
89
90
  alerts.push(
90
91
  `${ws} 有任务即将执行(或错过待补执行),但保活会话连续失败 `
91
92
  + `${ent.fails ?? '?'} 次正处冷却中,约 ${eta} 分钟后才会重试;`
@@ -109,8 +110,7 @@ function overviewTasks(cfg, cols, consumers, state) {
109
110
  if (soonest) {
110
111
  const [fire, ws, summary, nxt] = soonest;
111
112
  // 预计与设定不在同一分钟时,附注 cron 落点,解释两个时间的差。
112
- const setTag = (nxt && (fmtHM(fire) !== fmtHM(nxt)
113
- || fire.getDate() !== nxt.getDate()))
113
+ const setTag = (nxt && !sameMinute(fire, nxt))
114
114
  ? `,设定 ${fmtHM(nxt)}` : '';
115
115
  const line = `最近:${fmtMDHM(fire)}(${humanDelta(fire.getTime() - now.getTime())}`
116
116
  + `${setTag}) ${path.basename(ws)} · ${summary}`;
@@ -132,7 +132,7 @@ function overviewSessions(cols, state, suppressCooling, discovered) {
132
132
  const cooling = [];
133
133
  const dead = [];
134
134
  for (const [ws, ent] of Object.entries(state)) {
135
- if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, ent]);
135
+ if (isCooling(ent, cur)) cooling.push([ws, ent]);
136
136
  else if (deps.trackedAlive(ent)) live.push([ws, ent]);
137
137
  else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
138
138
  }
@@ -151,7 +151,7 @@ function overviewSessions(cols, state, suppressCooling, discovered) {
151
151
  }
152
152
  for (const [ws, ent] of cooling) {
153
153
  if (suppressCooling.has(ws)) continue; // 任务区已给过带上下文的合并告警
154
- const eta = Math.max(0, Math.floor(((ent.cooldownUntil ?? 0) - cur) / 60));
154
+ const eta = cooldownEtaMinutes(ent, cur);
155
155
  alerts.push(`${ws} 保活连续失败 ${ent.fails ?? '?'} 次,冷却中,约 ${eta} `
156
156
  + '分钟后重试');
157
157
  }
@@ -205,7 +205,7 @@ export async function cmdOverview(args) {
205
205
  const state = loadState();
206
206
  const curOv = Math.floor(Date.now() / 1000);
207
207
  const heartbeat = loadHeartbeat();
208
- const coolingWarned = new Set();
208
+ let coolingWarned = new Set();
209
209
  let discovered = null;
210
210
 
211
211
  if (cfgError !== null) {
@@ -221,7 +221,7 @@ export async function cmdOverview(args) {
221
221
  const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
222
222
  const retainTag = cfg.sessionRetain === 'always'
223
223
  ? ',会话常驻' : ',会话按执行回收';
224
- if (actualIv === null || actualIv === undefined) {
224
+ if (actualIv == null) {
225
225
  deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
226
226
  + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}`);
227
227
  } else {
@@ -252,9 +252,10 @@ export async function cmdOverview(args) {
252
252
  }
253
253
  alerts.push(...validateConfig(cfg));
254
254
  deps.print('');
255
- const [taskAlerts, cooled, found] = overviewTasks(cfg, cols, consumers, state);
255
+ const [taskAlerts, coolingWarnedNow, found] = overviewTasks(
256
+ cfg, cols, consumers, state);
256
257
  alerts.push(...taskAlerts);
257
- for (const w of cooled) coolingWarned.add(w);
258
+ coolingWarned = coolingWarnedNow;
258
259
  discovered = found;
259
260
  }
260
261
 
package/src/paths.mjs CHANGED
@@ -3,8 +3,6 @@
3
3
 
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import fs from 'node:fs';
7
- import { fileURLToPath } from 'node:url';
8
6
 
9
7
  export const LABEL = 'local.cron-up';
10
8
 
@@ -56,14 +54,3 @@ export const paths = makePaths({
56
54
  plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
57
55
  sessionDir: path.join(HOME, '.claude', 'sessions'),
58
56
  });
59
-
60
- // 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
61
- // 版本实验特性的依赖);读不到返回 null,总览页降级显示。
62
- export function packageVersion() {
63
- try {
64
- const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
65
- return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
66
- } catch {
67
- return null;
68
- }
69
- }
package/src/patrol.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps } from './internals.mjs';
8
+ import { deps, packageVersion } from './internals.mjs';
9
9
  import {
10
10
  FAIL_LIMIT,
11
11
  COOLDOWN_SECONDS,
@@ -25,6 +25,7 @@ import {
25
25
  loadHeartbeat,
26
26
  saveHeartbeat,
27
27
  heartbeatStale,
28
+ isCooling,
28
29
  } from './config.mjs';
29
30
  import {
30
31
  maintainWorkspace,
@@ -38,7 +39,7 @@ import {
38
39
  scriptProcLogPath,
39
40
  rotateOnce,
40
41
  } from './sessions.mjs';
41
- import { TASK_REL, packageVersion } from './paths.mjs';
42
+ import { TASK_REL } from './paths.mjs';
42
43
  import { elapsedZh, pad2 } from './display.mjs';
43
44
 
44
45
  // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
@@ -129,7 +130,7 @@ export function acquireRunLock() {
129
130
  return null;
130
131
  }
131
132
 
132
- export function releaseLock(handle) {
133
+ function releaseLock(handle) {
133
134
  if (!handle?.lockPath) return;
134
135
  try {
135
136
  fs.unlinkSync(handle.lockPath);
@@ -158,7 +159,11 @@ export function rotatePatrolLogs() {
158
159
  }
159
160
 
160
161
  // 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
161
- // 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。
162
+ // 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。锚点刻意取
163
+ // cron 裸落点而非 nextDelivery 的抖动后投递时刻:落点总不晚于投递时刻,
164
+ // 按落点算余量更小、回收更保守,是安全方向;真正的重拉闸门是下面的
165
+ // wanted(leadMs),margin 只需保住新会话登记。若改用 fire,落点后、投递
166
+ // 前的整段延迟窗内都不会回收,回收频率被悄悄改变。
162
167
  function needHorizonMs(tasks, now) {
163
168
  let horizon = Infinity;
164
169
  for (const t of tasks) {
@@ -212,23 +217,28 @@ export async function patrolWorkspace(
212
217
  return;
213
218
  }
214
219
 
220
+ // 结束本代会话的完整收尾:停掉 → 摘 state → 重扫消费者。换代与 window
221
+ // 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
222
+ // 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
223
+ let mineAlive = deps.trackedAlive(ent);
224
+ const retire = async (kind, msg) => {
225
+ await deps.stopSession(ent);
226
+ note(kind, msg);
227
+ delete state[ws];
228
+ ent = null;
229
+ mineAlive = false;
230
+ consumers = (await deps.scanSessions())[0];
231
+ };
232
+
215
233
  // 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
216
234
  // 文自然重拉。换代是维护,不计失败。
217
- let mineAlive = deps.trackedAlive(ent);
218
235
  if (ent !== null && mineAlive) {
219
236
  const age = cur - (ent.startedAt ?? cur);
220
237
  if (age > SESSION_MAX_AGE_SECONDS
221
238
  && sessionLogIdleSeconds(
222
239
  ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
223
- await deps.stopSession(ent);
224
- note('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
240
+ await retire('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
225
241
  + `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
226
- delete state[ws];
227
- ent = null;
228
- mineAlive = false;
229
- // 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
230
- // 次,别让陈旧登记挡住下面的重拉。
231
- consumers = (await deps.scanSessions())[0];
232
242
  }
233
243
  }
234
244
 
@@ -248,16 +258,9 @@ export async function patrolWorkspace(
248
258
  > SESSION_RECYCLE_IDLE_SECONDS
249
259
  && needHorizonMs(tasks, now) - now.getTime()
250
260
  > SESSION_FIRE_MARGIN_SECONDS * 1000) {
251
- const pid = ent.pid;
252
- await deps.stopSession(ent);
253
- note('recycle', `回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
254
- + '结束会话,下次需要时重拉');
255
- delete state[ws];
256
- ent = null;
257
- mineAlive = false;
258
- // 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
259
- // 重扫依旧看见,不会在同目录双开。
260
- consumers = (await deps.scanSessions())[0];
261
+ // 用户自己的会话若在场,retire 的重扫依旧看见,不会在同目录双开。
262
+ await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
263
+ + '界面静默,结束会话,下次需要时重拉');
261
264
  }
262
265
 
263
266
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
@@ -276,7 +279,7 @@ export async function patrolWorkspace(
276
279
 
277
280
  if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
278
281
 
279
- const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
282
+ const cooling = isCooling(ent, cur);
280
283
 
281
284
  // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
282
285
  // 应答的提问界面,永不登记——退休它并推进失败计数。
@@ -507,7 +510,7 @@ export async function cmdRun(args) {
507
510
  const kept = Object.fromEntries(
508
511
  Object.entries(state).filter(([, ent]) => {
509
512
  if (deps.trackedAlive(ent)) return true;
510
- if ((ent.cooldownUntil ?? 0) > cur) return true;
513
+ if (isCooling(ent, cur)) return true;
511
514
  if ((ent.fails ?? 0) > 0) {
512
515
  ent.deadSince ??= cur;
513
516
  return cur - ent.deadSince < FAIL_TTL_SECONDS;
package/src/registry.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
 
9
- import { deps } from './internals.mjs';
9
+ import { deps, isPlainObject } from './internals.mjs';
10
10
  import { parsePid } from './config.mjs';
11
11
  import { lexReal } from './tasks.mjs';
12
12
 
@@ -73,7 +73,7 @@ export async function scanSessions() {
73
73
  const interactive = [];
74
74
  for (const f of files) {
75
75
  const s = await readSessionFile(f);
76
- if (s && typeof s === 'object' && s.kind === 'interactive') {
76
+ if (isPlainObject(s) && s.kind === 'interactive') {
77
77
  interactive.push(s);
78
78
  }
79
79
  }
package/src/service.mjs CHANGED
@@ -4,8 +4,14 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- import { deps, ExitError, isPlainObject } from './internals.mjs';
7
+ import {
8
+ deps,
9
+ ExitError,
10
+ isPlainObject,
11
+ ensurePrivateDir,
12
+ } from './internals.mjs';
8
13
  import { paths, LABEL } from './paths.mjs';
14
+ import { SPECS, thisCamel } from './args.mjs';
9
15
  // paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
10
16
  // deps.paths(测试要整体重定向)。
11
17
  import { scriptProcLogPath } from './sessions.mjs';
@@ -33,7 +39,9 @@ export function launchctl(...args) {
33
39
  }
34
40
 
35
41
  export function launchctlInfo() {
36
- const r = launchctl('print', `${guiTarget()}/${LABEL}`);
42
+ // 走 deps 而非同文件裸名:与 bootout/bootstrap 的打桩面保持一致,测试可
43
+ // 在 launchctl 一层整体模拟 launchd。
44
+ const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
37
45
  if (r.status !== 0) return null;
38
46
  const info = {};
39
47
  for (const key of ['state', 'last exit code']) {
@@ -244,15 +252,11 @@ export function launcherHealth() {
244
252
  // ---- install / uninstall ----
245
253
 
246
254
  // install 的可覆盖项(配置键 → 命令行旗标):--force 缺席时逐项点名「未
247
- // 生效的参数」,与 SPECS 的选项表保持同一批名字。
248
- const IGNORED_INSTALL_FLAGS = [
249
- ['roots', '--roots'],
250
- ['interval', '--interval'],
251
- ['lead', '--lead'],
252
- ['autoRenew', '--auto-renew'],
253
- ['autoMinId', '--auto-min-id'],
254
- ['sessionRetain', '--session-retain'],
255
- ];
255
+ // 生效的参数」。名单直接从解析器的选项表派生——force 不落盘,其余选项都
256
+ // 会写进配置;新加可持久化选项时这里自动跟进,不会静默漏报。
257
+ const IGNORED_INSTALL_FLAGS = Object.keys(SPECS.install.options)
258
+ .filter((name) => name !== 'force')
259
+ .map((name) => [thisCamel(name), `--${name}`]);
256
260
 
257
261
  export async function cmdInstall(args) {
258
262
  // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
@@ -263,12 +267,8 @@ export async function cmdInstall(args) {
263
267
  + `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
264
268
  }
265
269
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
266
- fs.mkdirSync(deps.paths.sessionLogDir, { recursive: true });
267
- try {
268
- fs.chmodSync(deps.paths.sessionLogDir, 0o700);
269
- } catch {
270
- // 维持现状。
271
- }
270
+ // dataDir 只建不 chmod:本分支可能保留旧配置而不写盘,不搭车改权限。
271
+ ensurePrivateDir(deps.paths.sessionLogDir);
272
272
 
273
273
  let cfg;
274
274
  if (fs.existsSync(deps.paths.configPath) && !args.force) {
package/src/sessions.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps } from './internals.mjs';
8
+ import { deps, ensurePrivateDir } from './internals.mjs';
9
9
 
10
10
  // pid 是否对应活进程。走到这儿的 pid 已经过入口范围校验。
11
11
  export function alive(pid) {
@@ -97,12 +97,7 @@ export function rotateOnce(file) {
97
97
  // 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
98
98
  export function spawnSession(ws) {
99
99
  const logPath = sessionLogPath(ws);
100
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
101
- try {
102
- fs.chmodSync(path.dirname(logPath), 0o700);
103
- } catch {
104
- // 目录权限维持现状。
105
- }
100
+ ensurePrivateDir(path.dirname(logPath));
106
101
  // window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
107
102
  // 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
108
103
  // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
package/src/tasks.mjs CHANGED
@@ -124,17 +124,11 @@ export function* discover(roots, maxDepth) {
124
124
  }
125
125
  }
126
126
 
127
- // 读一个工作区的任务列表,坏形状一律返回 null。
127
+ // 读一个工作区的任务列表,坏形状一律返回 null。读盘与顶层形状校验复用
128
+ // loadTaskDoc(renew 也要同一口径 round-trip 整份文档)。
128
129
  export function readTasks(ws) {
129
- let doc;
130
- try {
131
- doc = JSON.parse(fs.readFileSync(
132
- path.join(ws, TASK_REL), 'utf-8'));
133
- } catch {
134
- return null;
135
- }
136
- if (!isPlainObject(doc) || !Array.isArray(doc.tasks)) return null;
137
- return doc.tasks.filter(isPlainObject);
130
+ const doc = deps.loadTaskDoc(path.join(ws, TASK_REL));
131
+ return doc === null ? null : doc.tasks.filter(isPlainObject);
138
132
  }
139
133
 
140
134
  // 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新