cron-up 26.9.1

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.
@@ -0,0 +1,187 @@
1
+ // 预热会话的整个生命周期:存活探测、pid 身份指纹、拉起、停止、日志文件
2
+ // 名、进程表兜底。被拉起的是 /usr/bin/script 包裹的 TUI,detached 独立进
3
+ // 程组,与短命的巡检进程在进程层早就分开。
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { deps } from './internals.mjs';
9
+ import {
10
+ LOG_ROTATE_BYTES,
11
+ SESSION_IDLE_SECONDS,
12
+ } from './constants.mjs';
13
+
14
+ // pid 是否对应活进程。走到这儿的 pid 已经过入口范围校验。
15
+ export function alive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ // ps 报告的进程启动时间,作为 pid 的身份指纹:macOS 高负载下几小时就回收
25
+ // pid,单靠 kill -0 分不出我们的预热会话和后来继承同一编号的进程。
26
+ export function procStartedAt(pid) {
27
+ // 固定 locale:这个字符串会跨轮次做精确相等比较,手动 run(终端
28
+ // LANG,可能本地化)与 launchd 驱动的巡检若格式不同,会把自己的会话
29
+ // 误判成回收 pid。
30
+ const r = deps.execFile(
31
+ '/bin/ps',
32
+ ['-p', String(pid), '-o', 'lstart='],
33
+ { env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, timeout: 5000 },
34
+ );
35
+ const out = (r.stdout ?? '').trim();
36
+ return out || null;
37
+ }
38
+
39
+ // 仅当记录的 pid 仍是我们当初 spawn 的那个进程时为 true。
40
+ export function trackedAlive(ent) {
41
+ if (!ent || !ent.pid) return false;
42
+ if (!deps.alive(ent.pid)) return false;
43
+ const started = ent.procStart;
44
+ if (!started) return true; // 旧版本写的条目:退回 kill -0 判定
45
+ return deps.procStartedAt(ent.pid) === started;
46
+ }
47
+
48
+ // 日志文件名折法:下划线转义必须在斜杠折叠之前——/a/b → a_b,
49
+ // /a_b → a__b,两类路径才不会共用同一个日志文件(script(1) 启动即截断,
50
+ // 撞名意味着两个工作区互相覆盖记录)。
51
+ export function sessionLogPath(ws) {
52
+ const slug = ws.replace(/^\/+|\/+$/g, '')
53
+ .replace(/_/g, '__')
54
+ .replace(/\//g, '_');
55
+ return path.join(deps.paths.sessionLogDir, `${slug}.log`);
56
+ }
57
+
58
+ // POSIX shell 引用:安全字符白名单内原样返回,否则单引号包裹,内部单引
59
+ // 号写成 '"'"'。
60
+ export function shellQuote(s) {
61
+ if (s === '') return "''";
62
+ if (/^[-@%+=:,./\w]+$/.test(s)) return s;
63
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
64
+ }
65
+
66
+ export function sessionArgv(ws, logPath) {
67
+ // -l -i 都不能少。-i 加载定义了私有端点/模型路由(claude shell 函数)
68
+ // 的交互 rc;-l 加载 /etc/zprofile 的 path_helper,这是 launchd 最小环
69
+ // 境里唯一把 homebrew(进而 fnm/node)放上 PATH 的东西。光 -ic 在终端
70
+ // 里靠继承现成 PATH 一切正常,交给 launchd 就 fnm: command not found。
71
+ return [
72
+ '/usr/bin/script', '-q', logPath,
73
+ '/bin/zsh', '-lic', `cd ${shellQuote(ws)} && claude`,
74
+ ];
75
+ }
76
+
77
+ // 预热会话继承的环境:strip 掉所有 CLAUDE_CODE_* 注入标记(从另一个 CC
78
+ // 会话里手动 run 时,否则 TUI 会以子会话身份启动,没有 transcript/登记),
79
+ // 再打上自我标识供外部观察工具归组。纯函数:绝不能改父进程的 process.env
80
+ // (Node 没有 fork,子环境只能显式构造)。
81
+ export function buildChildEnv(parentEnv) {
82
+ const env = { ...parentEnv };
83
+ for (const k of Object.keys(env)) {
84
+ if (k.startsWith('CLAUDE_CODE_')) delete env[k];
85
+ }
86
+ // 刻意不带 CLAUDE_CODE_ 前缀(上一步刚把那批 strip 掉);值必须严格是
87
+ // 字符串 '1'(观察侧 === '1' 判定)。
88
+ env.CRON_UP_SESSION = '1';
89
+ return env;
90
+ }
91
+
92
+ // 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
93
+ export function spawnSession(ws) {
94
+ const logPath = sessionLogPath(ws);
95
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
96
+ try {
97
+ fs.chmodSync(path.dirname(logPath), 0o700);
98
+ } catch {
99
+ // 目录权限维持现状。
100
+ }
101
+ // script(1) 不带 O_APPEND 打开 typescript,下次 spawn 会截断,所以只轮
102
+ // 转仍在增长的会话留下的超大日志。
103
+ try {
104
+ if (fs.existsSync(logPath) && fs.statSync(logPath).size > LOG_ROTATE_BYTES) {
105
+ const old = `${logPath}.1`;
106
+ fs.rmSync(old, { force: true });
107
+ fs.renameSync(logPath, old);
108
+ }
109
+ } catch {
110
+ // 轮转失败不阻断拉起。
111
+ }
112
+ // spawn 没有 umask 选项,而 typescript 可能含任务输出、必须 0600:先以
113
+ // 0600 预创建,script 随后以 O_TRUNC 打开已存在文件时模式位不变。
114
+ const fd = fs.openSync(logPath, 'a', 0o600);
115
+ fs.closeSync(fd);
116
+ try {
117
+ fs.chmodSync(logPath, 0o600);
118
+ } catch {
119
+ // 维持 0600。
120
+ }
121
+ const argv = sessionArgv(ws, logPath);
122
+ const child = deps.spawn(argv[0], argv.slice(1), {
123
+ detached: true, // 子侧 setsid:child.pid 即新进程组组长
124
+ stdio: 'ignore', // script 把 typescript 写进 logPath
125
+ cwd: ws,
126
+ env: buildChildEnv(process.env),
127
+ });
128
+ // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出)。
129
+ child.on('error', () => {});
130
+ child.unref();
131
+ return { pid: child.pid, procStart: deps.procStartedAt(child.pid) };
132
+ }
133
+
134
+ // 按进程组结束我们拉起的会话。发信号前先核身份:state 条目可能比它命名
135
+ // 的进程活得久,给回收 pid 发组信号会杀掉无关进程组。
136
+ export async function stopSession(ent) {
137
+ if (!deps.trackedAlive(ent)) return;
138
+ const pid = ent.pid;
139
+ try {
140
+ process.kill(-pid, 'SIGTERM');
141
+ } catch {
142
+ return;
143
+ }
144
+ for (let i = 0; i < 20; i++) {
145
+ await deps.sleep(100);
146
+ if (!deps.alive(pid)) return;
147
+ }
148
+ // 升级 SIGKILL 前再核一次指纹。
149
+ if (deps.trackedAlive(ent)) {
150
+ try {
151
+ process.kill(-pid, 'SIGKILL');
152
+ } catch {
153
+ // 组已消失。
154
+ }
155
+ await deps.sleep(100);
156
+ }
157
+ }
158
+
159
+ // typescript 多少秒没被写。文件缺失读作「没有活动证据」,不挡轮转。
160
+ export function sessionLogIdleSeconds(logPath, cur) {
161
+ try {
162
+ return Math.max(0, cur - Math.floor(fs.statSync(logPath).mtimeMs / 1000));
163
+ } catch {
164
+ return SESSION_IDLE_SECONDS + 1;
165
+ }
166
+ }
167
+
168
+ // 进程表是 state.json 丢失时的兜底:被遗忘的预热会话卡在提问界面时不会出
169
+ // 现在会话登记里,没这层交叉检查就会在同目录堆第二个 TUI。
170
+ export function listScriptProcesses() {
171
+ const r = deps.execFile('/bin/ps', ['-axo', 'pid=,args='], { timeout: 5000 });
172
+ const out = [];
173
+ for (const line of (r.stdout ?? '').split('\n')) {
174
+ const m = line.match(/^\s*(\d+)\s+(\/usr\/bin\/script\s.*)$/);
175
+ if (m) out.push([parseInt(m[1], 10), m[2]]);
176
+ }
177
+ return out;
178
+ }
179
+
180
+ Object.assign(deps, {
181
+ alive,
182
+ procStartedAt,
183
+ trackedAlive,
184
+ spawnSession,
185
+ stopSession,
186
+ listScriptProcesses,
187
+ });
package/src/tasks.mjs ADDED
@@ -0,0 +1,220 @@
1
+ // Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)与
2
+ // 原子写。任务文件是另一个程序写的外部输入,所有入口都校验形状,坏数据
3
+ // 读作「没有任务」而不是抛进巡检循环。
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { deps } from './internals.mjs';
9
+ import { TASK_REL } from './paths.mjs';
10
+ import { PRUNE_DIRS } from './constants.mjs';
11
+
12
+ export function expandHome(p) {
13
+ const home = deps.paths.home;
14
+ if (p === '~') return home;
15
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
16
+ return home + p.slice(1);
17
+ }
18
+ return p; // ~user 形式不支持(macOS 实践中不会出现)
19
+ }
20
+
21
+ function isDir(p) {
22
+ try {
23
+ return fs.statSync(p).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function realpathOrNull(p) {
30
+ try {
31
+ return fs.realpathSync(p);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // 路径在磁盘上真实存储的大小写形态;任何一段读不出时原样返回。
38
+ // macOS 默认文件系统大小写不敏感,而 realpath 不规范化大小写:敲错大小
39
+ // 写的路径照样通过 isDirectory,却与会话登记里真实大小写的 cwd 永远匹配
40
+ // 不上。在扫描边界认回一次真实写法,下游就不必各自提防。
41
+ export function onDiskCase(p) {
42
+ const parts = [];
43
+ let cur = path.resolve(p).replace(/\/+$/, '') || '/';
44
+ while (true) {
45
+ const head = path.dirname(cur);
46
+ const tail = path.basename(cur);
47
+ if (!tail || head === cur) break;
48
+ parts.push(tail);
49
+ cur = head;
50
+ }
51
+ // cur 停在根,自顶向下逐段认回磁盘上的真实条目名。
52
+ for (const name of parts.reverse()) {
53
+ let entries;
54
+ try {
55
+ entries = fs.readdirSync(cur);
56
+ } catch {
57
+ return p;
58
+ }
59
+ const match = entries.find((e) => e.toLowerCase() === name.toLowerCase());
60
+ if (!match) return p;
61
+ cur = path.join(cur, match);
62
+ }
63
+ return cur;
64
+ }
65
+
66
+ // 枚举所有含任务文件的工作区,产出 [workspace, taskfile]。生成器:一个工
67
+ // 作区出问题不该跳过其后所有工作区(调用方另有 per-workspace 保护)。
68
+ export function* discover(roots, maxDepth) {
69
+ const seen = new Set();
70
+ for (const raw of roots) {
71
+ let root = realpathOrNull(path.resolve(expandHome(String(raw))));
72
+ if (root === null || !isDir(root)) continue;
73
+ // 在边界认回真实大小写:带错大小写的 root 会让 ws 与会话登记的 cwd
74
+ // 匹配不上,同目录被重复拉起会话。
75
+ root = onDiskCase(root).replace(/\/+$/, '') || '/';
76
+ const baseDepth = root.split('/').length - 1;
77
+
78
+ function* walk(dirpath) {
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(dirpath, { withFileTypes: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ const depth = dirpath.split('/').length - 1 - baseDepth;
86
+ // 先剪枝:depth==maxDepth 这一层本身仍要检查候选文件,只是不再下
87
+ // 降;再做候选检查。
88
+ const subdirs = depth >= maxDepth
89
+ ? []
90
+ : entries.filter((e) => e.isDirectory() && !PRUNE_DIRS.has(e.name));
91
+ const candidate = path.join(dirpath, TASK_REL);
92
+ const ws = realpathOrNull(dirpath);
93
+ let isFile = false;
94
+ try {
95
+ isFile = fs.statSync(candidate).isFile();
96
+ } catch {
97
+ isFile = false;
98
+ }
99
+ if (ws && isFile && !seen.has(ws)) {
100
+ seen.add(ws);
101
+ yield [ws, candidate];
102
+ }
103
+ for (const d of subdirs) {
104
+ yield* walk(path.join(dirpath, d.name));
105
+ }
106
+ }
107
+ yield* walk(root);
108
+ }
109
+ }
110
+
111
+ function isPlainObject(v) {
112
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
113
+ }
114
+
115
+ // 读一个工作区的任务列表,坏形状一律返回 null。
116
+ export function readTasks(ws) {
117
+ let doc;
118
+ try {
119
+ doc = JSON.parse(fs.readFileSync(
120
+ path.join(ws, TASK_REL), 'utf-8'));
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (!isPlainObject(doc) || !Array.isArray(doc.tasks)) return null;
125
+ return doc.tasks.filter(isPlainObject);
126
+ }
127
+
128
+ // 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
129
+ // lastFiredAt 或删除已触发任务):绝不覆盖,抛 TaskFileChanged 让下轮
130
+ // 巡检基于更新的文档重试。
131
+ export class TaskFileChanged extends Error {
132
+ constructor(file) {
133
+ super(`任务文件已被其他写者改动:${file}`);
134
+ this.name = 'TaskFileChanged';
135
+ }
136
+ }
137
+
138
+ // 读整份文档(未知顶层键保留):renew 要就地重写文件,必须 round-trip
139
+ // Claude Code 或其他工具放进去的每个键,而不只是 tasks 子集。
140
+ export function loadTaskDoc(file) {
141
+ let doc;
142
+ try {
143
+ doc = JSON.parse(fs.readFileSync(file, 'utf-8'));
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (!isPlainObject(doc) || !Array.isArray(doc.tasks)) return null;
148
+ return doc;
149
+ }
150
+
151
+ // 旁边写再 rename 的原子替换,保留原文件权限位与中文字面量。
152
+ // tmp 以 0600 创建(绝不更宽),写完再放宽到原 mode。expectedMtimeNs 是
153
+ // 乐观锁:路径 mtime 自调用方读后变了说明别的写者抢先,抛
154
+ // TaskFileChanged 而不是覆盖它的更新。
155
+ export function atomicWriteJson(file, doc, expectedMtimeNs) {
156
+ let mode = 0o600;
157
+ try {
158
+ mode = fs.statSync(file).mode & 0o777;
159
+ } catch {
160
+ // 新文件用 0600。
161
+ }
162
+ const tmp = `${file}.tmp`;
163
+ // JSON.stringify 两空格缩进:中文按字面写出(不转 \u),无尾换行。
164
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), {
165
+ flag: 'w',
166
+ mode: 0o600,
167
+ });
168
+ try {
169
+ fs.chmodSync(tmp, mode);
170
+ } catch {
171
+ // 保持 0600。
172
+ }
173
+ let current = null;
174
+ try {
175
+ current = fs.statSync(file, { bigint: true }).mtimeNs;
176
+ } catch {
177
+ current = null;
178
+ }
179
+ if (current !== expectedMtimeNs) {
180
+ try {
181
+ fs.unlinkSync(tmp);
182
+ } catch {
183
+ // 忽略清理失败。
184
+ }
185
+ throw new TaskFileChanged(file);
186
+ }
187
+ fs.renameSync(tmp, file);
188
+ }
189
+
190
+ // 给所有缺 truthy permanent 标记的周期任务打上标记。
191
+ // 返回:打标的数量(文件只重写一次);0 表示文件已全部标好(字节级不
192
+ // 动,这是 5 分钟巡检不会永远重写别人文件的幂等性);null 表示文件缺失/
193
+ // 损坏;读到的与写间被改抛 TaskFileChanged。一次性任务与无标志的手写文
194
+ // 件永不修改。
195
+ export function renewWorkspace(taskfile) {
196
+ let mtimeNs;
197
+ try {
198
+ mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
199
+ } catch {
200
+ return null;
201
+ }
202
+ const doc = deps.loadTaskDoc(taskfile);
203
+ if (doc === null) return null;
204
+ let tagged = 0;
205
+ for (const t of doc.tasks) {
206
+ // truthy 而非 === true:与 Claude Code 自己的加载器一致
207
+ // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
208
+ // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
209
+ // 一次性任务不碰。
210
+ if (isPlainObject(t) && t.recurring && !t.permanent) {
211
+ t.permanent = true;
212
+ tagged += 1;
213
+ }
214
+ }
215
+ if (tagged === 0) return 0;
216
+ atomicWriteJson(taskfile, doc, mtimeNs);
217
+ return tagged;
218
+ }
219
+
220
+ Object.assign(deps, { discover, readTasks, loadTaskDoc });