@shaogefenhao/overnight 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 linksgo2011
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # overnight
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **让 AI 上夜班。**
4
+
5
+ 把 coding agent 的 plan 排进队列,到点(默认夜间)自动跑,左边看 runner 重试,右边看 agent 输出。
6
+
7
+ ## 它是什么
8
+
9
+ 一个纯脚本化的 Runner。没有 AI 判断、没有魔法:
10
+
11
+ - **队列 = 一组 plan**:`queue/NNN.yaml` 里是 `plans` 列表(id / title / 文件位置 / depends_on)
12
+ - **依赖调度**:每个 plan 的 `depends_on` 全部完成才轮到它;失败的重试到 `retry.max` 次,依赖它的保持 pending
13
+ - **定时执行**:默认夜间窗口 `20:00-08:00`,也可配成全天
14
+ - **看住不跑飞**:卡死、超时自动杀;失败按次数重试
15
+ - **无打扰会话**:起 agent 带 bypass 权限 + 通用提示词(执行 plan、自行决策、写决策日志)
16
+ - **多 agent**:opencode、claude code,配好命令即可
17
+ - **分屏日志**:左 runner / 右 agent
18
+
19
+ ## plan 必须能重复执行
20
+
21
+ 失败时 overnight 会**原样重启整个 plan**,并且**不会**自动清理工作区。plan 的重启进展
22
+ 靠 `dev-build` skill 维护的**进度文件**:`docs/progress/NNN-name.progress.md` 用稳定编号
23
+ 记录每步 `[ ]`/`[x]`,重跑时的新 agent 读 plan + 进度,从没完成的地方继续。
24
+
25
+ ## 用法
26
+
27
+ ```bash
28
+ overnight # 启动,进分屏日志界面(Ctrl+C 退出)
29
+ overnight add "标题" # 建一个任务:自动生成 plan 骨架 + 队列文件
30
+ overnight status # 看队列(plan 树)
31
+ overnight logs # 看今天的报告
32
+ ```
33
+
34
+ ### 队列文件
35
+
36
+ 一个队列文件 = 一组 plan,按顺序 + 依赖执行:
37
+
38
+ ```yaml
39
+ title: 项目安全加固 # 元信息:这组任务的标题
40
+ agent: opencode # 可选,默认 agent
41
+
42
+ plans: # plans 节点:至少一个
43
+ - id: 001
44
+ title: 把硬编码密钥迁到环境变量
45
+ file: docs/plans/001-keys-to-env.md # plan 文件位置(相对项目根目录)
46
+ depends_on: [] # 可选,先跑完这些 id 再轮到这个
47
+ - id: 002
48
+ title: 补单元测试
49
+ file: docs/plans/002-unit-tests.md
50
+ depends_on: [001]
51
+ ```
52
+
53
+ plan 文档放 `docs/plans/`,进度文件放 `docs/progress/`(两者都进 git)。
54
+
55
+ ## 配置(`overnight.yaml`)
56
+
57
+ ```yaml
58
+ agents:
59
+ opencode: { command: opencode, args: ["run", "--dangerously-skip-permissions", "{prompt}"] }
60
+ claude: { command: claude, args: ["-p", "{prompt}", "--dangerously-skip-permissions"] }
61
+ default_agent: opencode
62
+
63
+ runner:
64
+ timeout: 60m # 单个计划硬超时
65
+ idle_kill: 10m # 多久没输出算卡死
66
+ prompt_template: | # 追加给 agent 的通用提示词({plan_file}/{decision_log_dir} 会替换)
67
+ 执行 plan 文件 {plan_file},执行过程中不要询问,自行决策,
68
+ 并记录决策日志,写入 {decision_log_dir}
69
+ decision_log_dir: logs/decisions # 决策日志目录
70
+
71
+ schedule:
72
+ window: "20:00-08:00" # 跨夜窗口;null = 全天
73
+
74
+ retry:
75
+ max: 3
76
+ backoff: 5m
77
+ ```
78
+
79
+ ## 在 opencode 里用
80
+
81
+ 仓库带三个 skill:
82
+
83
+ - `overnight`:把需求排进 overnight 夜间跑(建队列)
84
+ - `dev-plan`:需求 → plan 文档
85
+ - `dev-build`:执行 plan 文档 + 维护进度文件
86
+
87
+ ## 全局安装
88
+
89
+ ```bash
90
+ npm i -g . # 或 npm link,之后任意目录都能用 overnight
91
+ ```
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@shaogefenhao/overnight",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "让 AI 上夜班:把 coding agent 的 plan 排进队列,夜里自动跑,左右分屏看 runner 和 agent 日志",
5
+ "type": "module",
6
+ "bin": {
7
+ "overnight": "./src/index.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "test": "node --test test/*.test.js",
13
+ "prepublishOnly": "npm test"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "ai",
20
+ "coding-agent",
21
+ "automation",
22
+ "opencode",
23
+ "claude",
24
+ "night"
25
+ ],
26
+ "author": {
27
+ "name": "linksgo2011"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/linksgo2011/overnight.git"
33
+ },
34
+ "homepage": "https://github.com/linksgo2011/overnight#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/linksgo2011/overnight/issues"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "yaml": "^2.5.1"
43
+ },
44
+ "files": [
45
+ "src"
46
+ ]
47
+ }
package/src/agents.js ADDED
@@ -0,0 +1,115 @@
1
+ // 多 agent 适配(opencode / claude code ...)
2
+ import { spawn } from 'node:child_process';
3
+ import { appendFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ // OpenCode 桌面 App 注入的变量会让子进程误连桌面富服务(Session not found),
8
+ // 起 agent 时必须剥掉;其余环境变量原样继承
9
+ const OPENCODE_DESKTOP_VARS = ['OPENCODE_CLIENT', 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD'];
10
+ function agentEnv() {
11
+ const env = { ...process.env };
12
+ for (const k of OPENCODE_DESKTOP_VARS) delete env[k];
13
+ return env;
14
+ }
15
+
16
+ export class Agent {
17
+ constructor(cfg) {
18
+ this.cfg = cfg;
19
+ this.templates = cfg.agents;
20
+ }
21
+
22
+ resolve(name) {
23
+ const key = name || this.cfg.default_agent;
24
+ const tpl = this.templates[key];
25
+ if (!tpl) {
26
+ const known = Object.keys(this.templates).join(', ');
27
+ throw new Error(`没配置 agent "${key}"。已知的: ${known}`);
28
+ }
29
+ return { name: key, ...tpl };
30
+ }
31
+
32
+ // 起一个 agent 跑 prompt,收集输出
33
+ run(name, prompt, { onOutput } = {}) {
34
+ const tpl = this.resolve(name);
35
+
36
+ // 数组传参,prompt 里的空格引号不会炸
37
+ const args = tpl.args.map((a) => String(a).replace('{prompt}', prompt));
38
+
39
+ // spawn 不认 ~,手动展开
40
+ const cmd = String(tpl.command).replace(/^~(?=\/|$)/, homedir());
41
+
42
+ const proc = spawn(cmd, args, {
43
+ cwd: this.cfg.paths.root,
44
+ env: agentEnv(),
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ // 独立进程组,这样 kill 能连带干掉 agent 起的子进程
47
+ detached: true,
48
+ });
49
+
50
+ let buf = '';
51
+ let errBuf = '';
52
+ const push = (s, isErr) => {
53
+ buf += s;
54
+ if (isErr) errBuf += s;
55
+ onOutput?.(s, isErr);
56
+ };
57
+
58
+ proc.stdout.on('data', (d) => push(d.toString(), false));
59
+ proc.stderr.on('data', (d) => push(d.toString(), true));
60
+
61
+ return {
62
+ name: tpl.name,
63
+ command: `${tpl.command} ${tpl.args.map((a) => String(a).replace('{prompt}', '"…"')).join(' ')}`,
64
+ proc,
65
+ get output() { return buf; },
66
+ get stderr() { return errBuf; },
67
+ pid: proc.pid,
68
+ };
69
+ }
70
+
71
+ // 杀掉整个进程组:agent 起的子进程(dev server、测试进程)也得死
72
+ kill(handle, { graceMs = 3000 } = {}) {
73
+ if (!handle?.proc || handle._killed) return;
74
+ const pid = handle.proc.pid;
75
+ if (pid == null) return;
76
+ handle._killed = true;
77
+
78
+ // agent 自己可能不转发信号给子进程,所以只杀父进程会留一堆孤儿
79
+ const fire = (sig) => {
80
+ try { process.kill(-pid, sig); return; } catch { /* 没有独立进程组 */ }
81
+ try { handle.proc.kill(sig); } catch { /* 已经死了 */ }
82
+ };
83
+
84
+ fire('SIGTERM');
85
+
86
+ // SIGTERM 不一定管用(agent 常忽略),必须补一刀 SIGKILL
87
+ // 不能 unref:这个定时器是防止留孤儿进程的唯一保障
88
+ this._pending = new Promise((resolve) => {
89
+ setTimeout(() => {
90
+ try { process.kill(-pid, 0); } catch { return resolve(); } // 已经死了
91
+ fire('SIGKILL');
92
+ resolve();
93
+ }, graceMs);
94
+ });
95
+ }
96
+
97
+ // 等所有 kill 的兜底都发完(测试和优雅退出用)
98
+ drainKills() {
99
+ return this._pending || Promise.resolve();
100
+ }
101
+ }
102
+
103
+ export function tail(str, lines) {
104
+ const arr = String(str).split('\n');
105
+ return arr.slice(-lines).join('\n');
106
+ }
107
+
108
+ // 报告:一天一个 md 文件,纯记录 runner 干了啥(无 AI 判断)
109
+ export function appendReport(cfg, section) {
110
+ const day = new Date().toISOString().slice(0, 10);
111
+ const file = join(cfg.paths.reports, `${day}.md`);
112
+
113
+ if (!existsSync(file)) writeFileSync(file, `# ${day}\n`, 'utf8');
114
+ appendFileSync(file, section, 'utf8');
115
+ }
package/src/config.js ADDED
@@ -0,0 +1,125 @@
1
+ // 配置加载 + 时长解析 + 窗口判断
2
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import YAML from 'yaml';
5
+
6
+ const CONFIG_FILE = 'overnight.yaml';
7
+
8
+ const DEFAULTS = {
9
+ agents: {
10
+ opencode: { command: 'opencode', args: ['run', '--dangerously-skip-permissions', '{prompt}'] },
11
+ claude: { command: 'claude', args: ['-p', '{prompt}', '--dangerously-skip-permissions'] },
12
+ },
13
+ default_agent: 'opencode',
14
+ runner: {
15
+ timeout: '60m',
16
+ idle_kill: '10m',
17
+ tail_lines: 80,
18
+ prompt_template: '执行 plan 文件 {plan_file},执行过程中不要询问,自行决策,并记录决策日志,写入 {decision_log_dir}',
19
+ decision_log_dir: 'logs/decisions',
20
+ },
21
+ schedule: {
22
+ window: '20:00-08:00', // null / 空 = 全天
23
+ idle_sleep: '24h',
24
+ },
25
+ retry: { max: 3, backoff: '5m' },
26
+ report: { dir: 'reports' },
27
+ };
28
+
29
+ function deepMerge(base, over) {
30
+ if (over === undefined) return base;
31
+ // null 是显式覆盖(比如 schedule.window: null 表示全天),不能当成没填
32
+ if (over === null || Array.isArray(over) || typeof over !== 'object') return over;
33
+ if (typeof base !== 'object' || base === null) return over;
34
+ const out = { ...base };
35
+ for (const [k, v] of Object.entries(over)) {
36
+ out[k] = k in base ? deepMerge(base[k], v) : v;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // ${VAR} 展开
42
+ function expandEnv(str) {
43
+ return String(str).replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '');
44
+ }
45
+
46
+ function deepExpand(obj) {
47
+ if (typeof obj === 'string') return expandEnv(obj);
48
+ if (Array.isArray(obj)) return obj.map(deepExpand);
49
+ if (obj && typeof obj === 'object') {
50
+ const out = {};
51
+ for (const [k, v] of Object.entries(obj)) out[k] = deepExpand(v);
52
+ return out;
53
+ }
54
+ return obj;
55
+ }
56
+
57
+ export function loadConfig(cwd = process.cwd()) {
58
+ const file = resolve(cwd, CONFIG_FILE);
59
+ let user = {};
60
+ if (existsSync(file)) {
61
+ try {
62
+ user = YAML.parse(readFileSync(file, 'utf8')) || {};
63
+ } catch (e) {
64
+ throw new Error(`${CONFIG_FILE} 解析失败: ${e.message}`);
65
+ }
66
+ }
67
+
68
+ const cfg = deepExpand(deepMerge(DEFAULTS, user));
69
+
70
+ // 时长转毫秒
71
+ cfg.runner.timeoutMs = parseDuration(cfg.runner.timeout);
72
+ cfg.runner.idleKillMs = parseDuration(cfg.runner.idle_kill);
73
+ cfg.schedule.idleSleepMs = parseDuration(cfg.schedule.idle_sleep);
74
+ cfg.retry.backoffMs = parseDuration(cfg.retry.backoff);
75
+
76
+ // 目录
77
+ cfg.paths = {
78
+ root: cwd,
79
+ queue: resolve(cwd, 'queue'),
80
+ plans: resolve(cwd, 'docs', 'plans'),
81
+ progress: resolve(cwd, 'docs', 'progress'),
82
+ decisions: resolve(cwd, cfg.runner.decision_log_dir),
83
+ logs: resolve(cwd, 'logs'),
84
+ reports: resolve(cwd, cfg.report.dir),
85
+ };
86
+ for (const p of [cfg.paths.queue, cfg.paths.plans, cfg.paths.progress,
87
+ cfg.paths.decisions, cfg.paths.logs, cfg.paths.reports]) {
88
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
89
+ }
90
+
91
+ return cfg;
92
+ }
93
+
94
+ // 60m / 30s / 2h / 1d
95
+ export function parseDuration(s) {
96
+ if (typeof s === 'number') return s * 1000;
97
+ const m = String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
98
+ if (!m) throw new Error(`时长格式不对: ${s}`);
99
+ const n = parseFloat(m[1]);
100
+ const unit = (m[2] || 'm').toLowerCase();
101
+ const mult = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }[unit];
102
+ return n * mult;
103
+ }
104
+
105
+ // "20:00-08:00" -> 是否在窗口内(支持跨夜);null/空 = 全天
106
+ export function parseWindow(str) {
107
+ if (str === null || str === undefined || String(str).trim() === '') return null;
108
+ const m = String(str).match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
109
+ if (!m) throw new Error(`时间窗口格式不对: ${str}(应该像 20:00-08:00,或 null 表示全天)`);
110
+
111
+ const toMin = (h, mm) => parseInt(h, 10) * 60 + parseInt(mm, 10);
112
+ const start = toMin(m[1], m[2]);
113
+ const end = toMin(m[3], m[4]);
114
+
115
+ if (start === end) return { start, end, always: true };
116
+ return { start, end, always: false, crossNight: end < start };
117
+ }
118
+
119
+ export function inWindow(window, now = new Date()) {
120
+ if (!window || window.always) return true;
121
+ const cur = now.getHours() * 60 + now.getMinutes();
122
+ const { start, end, crossNight } = window;
123
+ if (crossNight) return cur >= start || cur < end;
124
+ return cur >= start && cur < end;
125
+ }
package/src/index.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ // overnight — 让 AI 上夜班。queue 里的一组 plan 排好队,到点自动串行执行。
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join, basename } from 'node:path';
5
+ import { loadConfig, parseWindow, inWindow } from './config.js';
6
+ import { Queue } from './queue.js';
7
+ import { Runner, sleep } from './runner.js';
8
+ import { appendReport } from './agents.js';
9
+ import { TUI } from './tui.js';
10
+
11
+ const argv = process.argv.slice(2);
12
+ const CMD = argv[0] || 'run';
13
+
14
+ const PLAN_STATUS_TEXT = { done: 'done', failed: 'FAILED', broken: 'broken', running: 'RUNNING', pending: 'pending' };
15
+
16
+ const cfg = loadConfig();
17
+ const queue = new Queue(cfg);
18
+
19
+ const state = { current: null, paused: false, windowLabel: '—' };
20
+
21
+ switch (CMD) {
22
+ case 'init':
23
+ init();
24
+ break;
25
+ case 'logs':
26
+ case 'report':
27
+ showReport(argv[1]);
28
+ break;
29
+ case 'status':
30
+ showStatus();
31
+ break;
32
+ case 'add':
33
+ add();
34
+ break;
35
+ default:
36
+ run();
37
+ }
38
+
39
+ function init() {
40
+ console.log(existsSync('overnight.yaml') ? 'overnight.yaml 已存在' : '没有 overnight.yaml,会用默认配置运行。');
41
+ console.log('\n用法:');
42
+ console.log(' overnight 启动,进分屏日志界面');
43
+ console.log(' overnight add "标题" 建一个队列任务(自动生成 plan 骨架)');
44
+ console.log(' overnight status 看队列(按 plan 树)');
45
+ console.log(' overnight logs 看今天的报告');
46
+ process.exit(0);
47
+ }
48
+
49
+ function showReport(day) {
50
+ const d = day || new Date().toISOString().slice(0, 10);
51
+ const f = join(cfg.paths.reports, `${d}.md`);
52
+ if (!existsSync(f)) { console.log(`没有 ${d} 的报告`); process.exit(0); }
53
+ console.log(readFileSync(f, 'utf8'));
54
+ }
55
+
56
+ function showStatus() {
57
+ const jobs = queue.list();
58
+ console.log(`\n队列 ${jobs.length} 条:\n`);
59
+ for (const j of jobs) {
60
+ console.log(` ${j.id} ${j.status.padEnd(7)} ${j.title}`);
61
+ if (j.broken) {
62
+ console.log(dim(` ! ${j.broken}`));
63
+ continue;
64
+ }
65
+ for (const p of j.plans) {
66
+ const st = PLAN_STATUS_TEXT[p.status] || p.status;
67
+ const dep = p.depends_on.length ? dim(` (depends_on: ${p.depends_on.join(',')})`) : '';
68
+ const re = p.reason ? dim(` — ${p.reason}`) : '';
69
+ console.log(` ${st.padEnd(8)} ${p.id} ${p.title}${dep}${re}`);
70
+ }
71
+ }
72
+ console.log('');
73
+ }
74
+
75
+ function dim(s) {
76
+ return `\x1b[2m${s}\x1b[0m`;
77
+ }
78
+
79
+ // ---- add:生成 plan 骨架 + 队列文件 ----
80
+
81
+ function slugify(s) {
82
+ const slug = String(s).toLowerCase()
83
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
84
+ .replace(/^-+|-+$/g, '');
85
+ return slug || 'plan';
86
+ }
87
+
88
+ function add() {
89
+ const args = argv.slice(1);
90
+ if (!args.length) {
91
+ console.error('用法: overnight add "标题" [agent]');
92
+ process.exit(1);
93
+ }
94
+ // 最后一个参数是已知 agent 名时当该 job 的 agent 用
95
+ let title, agent = null;
96
+ if (args.length >= 2 && cfg.agents[args[args.length - 1]]) {
97
+ agent = args.pop();
98
+ }
99
+ title = args.join(' ');
100
+
101
+ // 下一个可用编号(和 queue.add 的算法一致)
102
+ let n = 1;
103
+ while (existsSync(join(cfg.paths.queue, `${String(n).padStart(3, '0')}.yaml`))) n++;
104
+ const jobId = String(n).padStart(3, '0');
105
+ const planFile = `docs/plans/${jobId}-${slugify(title)}.md`;
106
+
107
+ writeFileSync(join(cfg.paths.root, planFile), planSkeleton(title, planFile), 'utf8');
108
+ const id = queue.add({ title, planFile, agent });
109
+ console.log(`已加入: ${id}`);
110
+ console.log(` queue 文件: queue/${id}.yaml`);
111
+ console.log(` plan 文档: ${planFile}(自己补步骤和验收)`);
112
+ process.exit(0);
113
+ }
114
+
115
+ function planSkeleton(title, planFile) {
116
+ const progress = planFile.replace(/^docs\/plans\//, 'docs/progress/').replace(/\.md$/, '.progress.md');
117
+ return `# plan 001 — ${title}
118
+
119
+ > 执行第一条:用 dev-build 维护进度,**这个 plan 必须能安全重跑**。
120
+ > 先读进度文件 \`${progress}\` 和现状(git status / git diff),做过的跳过,别重复。
121
+
122
+ ## 1. 现状检查
123
+ - 「运行指令」确认现在的基线
124
+
125
+ ## 2. 步骤
126
+ 1. (第一步)
127
+ 2. (第二步)
128
+
129
+ ## 3. 验收
130
+ - (可测试的标准)
131
+ `;
132
+ }
133
+
134
+ // ---- 主循环 ----
135
+
136
+ async function run() {
137
+ const win = parseWindow(cfg.schedule.window);
138
+ const label = () => {
139
+ if (!win) return '全天';
140
+ return inWindow(win) ? '窗口内' : '窗外';
141
+ };
142
+ state.windowLabel = label();
143
+
144
+ // 新会话:把之前失败的 plan 打回 pending,给它们新的尝试
145
+ queue.resetFailed();
146
+ queue.prune();
147
+
148
+ const tui = new TUI(cfg, { queue, state });
149
+ const runner = new Runner(cfg, {
150
+ log: (l) => tui.log(l),
151
+ onAgentOutput: (s) => tui.agentOutput(s),
152
+ });
153
+
154
+ (async () => {
155
+ while (true) {
156
+ try {
157
+ state.windowLabel = label();
158
+
159
+ // 有 plan 在跑就等它跑完(跑起来的任务不打断)
160
+ if (state.current) { await sleep(20_000); continue; }
161
+
162
+ // 窗口只决定要不要开新任务
163
+ if (win && !inWindow(win)) { await sleep(60_000); continue; }
164
+
165
+ queue.prune();
166
+ const nxt = queue.next(); // { job, plan } 或 null
167
+ if (!nxt) { await sleep(cfg.schedule.idleSleepMs); continue; }
168
+
169
+ const { job, plan } = nxt;
170
+ state.current = { jobId: job.id, planId: plan.id, startedAt: Date.now() };
171
+ queue.markPlan(job.id, plan.id, 'running');
172
+ appendReport(cfg, `\n## 开始 — ${job.id}/${plan.id} (${plan.title})\n`
173
+ + `- plan: ${plan.relFile}\n- agent: ${job.agent || cfg.default_agent}\n`);
174
+
175
+ const res = await runner.runPlan(job, plan);
176
+
177
+ state.current = null;
178
+ queue.markPlan(job.id, plan.id, res.status, { reason: res.attempts > 1 ? `尝试了 ${res.attempts} 次` : null });
179
+ tui.log(`[${job.id}/${plan.id}] ${res.status}${res.reason ? ' — ' + res.reason : ''}`);
180
+
181
+ await sleep(1000);
182
+ } catch (e) {
183
+ tui.log('主循环出错: ' + e.message);
184
+ await sleep(60_000);
185
+ }
186
+ }
187
+ })();
188
+
189
+ tui.start();
190
+ }
package/src/queue.js ADDED
@@ -0,0 +1,330 @@
1
+ // 队列读写:queue/*.yaml(plans 格式)+ state.json
2
+ // 一个队列文件 = 一组 plan;每个 plan 有 id/title/file/depends_on(文件内依赖)
3
+ import { readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import YAML from 'yaml';
6
+
7
+ const JOB_STATUS = { PENDING: 'pending', RUNNING: 'running', DONE: 'done', FAILED: 'failed', BROKEN: 'broken' };
8
+ const PLAN_STATUS = { PENDING: 'pending', RUNNING: 'running', DONE: 'done', FAILED: 'failed', BROKEN: 'broken' };
9
+
10
+ export class Queue {
11
+ constructor(cfg) {
12
+ this.cfg = cfg;
13
+ this.dir = cfg.paths.queue;
14
+ this.stateFile = join(this.dir, 'state.json');
15
+ this.state = this.#loadState();
16
+ this.listeners = new Set();
17
+ }
18
+
19
+ #loadState() {
20
+ if (!existsSync(this.stateFile)) return {};
21
+ try {
22
+ return JSON.parse(readFileSync(this.stateFile, 'utf8')) || {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ #saveState() {
29
+ // 先写临时文件再 rename,避免写一半断电
30
+ const tmp = `${this.stateFile}.tmp`;
31
+ writeFileSync(tmp, JSON.stringify(this.state, null, 2));
32
+ renameSync(tmp, this.stateFile);
33
+ }
34
+
35
+ onChange(fn) {
36
+ this.listeners.add(fn);
37
+ return () => this.listeners.delete(fn);
38
+ }
39
+
40
+ #emit() {
41
+ for (const fn of this.listeners) {
42
+ try { fn(this.list()); } catch { /* 监听器出错不影响主流程 */ }
43
+ }
44
+ }
45
+
46
+ // ---- 解析 ----
47
+
48
+ #parseJobs() {
49
+ let files = [];
50
+ try {
51
+ files = readdirSync(this.dir)
52
+ .filter((f) => /\.ya?ml$/i.test(f) && !f.startsWith('_'))
53
+ .sort();
54
+ } catch {
55
+ return [];
56
+ }
57
+
58
+ const jobs = [];
59
+ for (const f of files) {
60
+ const jobId = f.replace(/\.ya?ml$/i, '');
61
+ jobs.push(this.#parseJob(jobId, f));
62
+ }
63
+ return jobs;
64
+ }
65
+
66
+ #parseJob(jobId, fileName) {
67
+ let raw;
68
+ try {
69
+ raw = YAML.parse(readFileSync(join(this.dir, fileName), 'utf8')) || {};
70
+ } catch (e) {
71
+ return this.#brokenJob(jobId, fileName, `yaml 解析失败: ${e.message}`);
72
+ }
73
+
74
+ if (!Array.isArray(raw.plans) || raw.plans.length === 0) {
75
+ return this.#brokenJob(jobId, fileName, 'plans 节点缺失或为空');
76
+ }
77
+
78
+ const plans = raw.plans.map((p, i) => this.#parsePlan(jobId, p, i));
79
+
80
+ const cycle = this.#findCycle(plans);
81
+ if (cycle.length) {
82
+ for (const c of cycle) {
83
+ c.status = PLAN_STATUS.BROKEN;
84
+ c.reason = `依赖成环: ${cycle.map((x) => x.id).join(' -> ')}`;
85
+ }
86
+ }
87
+
88
+ return {
89
+ id: jobId,
90
+ file: fileName,
91
+ title: raw.title || fileName,
92
+ agent: raw.agent || null, // null = 用默认 agent
93
+ plans,
94
+ broken: null,
95
+ };
96
+ }
97
+
98
+ #brokenJob(jobId, fileName, reason) {
99
+ return { id: jobId, file: fileName, title: fileName, agent: null, plans: [], broken: reason };
100
+ }
101
+
102
+ #parsePlan(jobId, p, idx) {
103
+ const id = String(p?.id ?? (idx + 1)).
104
+ // YAML 把 001 读成八进制 1,统一补成三位
105
+ replace(/^\d+$/, (m) => m.padStart(3, '0'));
106
+ const plan = {
107
+ id,
108
+ jobId,
109
+ title: p?.title || id,
110
+ relFile: p?.file || null,
111
+ depends_on: Array.isArray(p?.depends_on)
112
+ ? p.depends_on.map(String).map((d) => d.replace(/^\d+$/, (m) => m.padStart(3, '0')))
113
+ : [],
114
+ status: this.state[jobId]?.plans?.[id]?.status || PLAN_STATUS.PENDING,
115
+ reason: this.state[jobId]?.plans?.[id]?.reason || null,
116
+ content: null,
117
+ };
118
+
119
+ // 读 plan 文档;缺文件 = broken
120
+ if (plan.relFile) {
121
+ const abs = resolve(this.cfg.paths.root, plan.relFile);
122
+ if (existsSync(abs)) {
123
+ plan.content = readFileSync(abs, 'utf8');
124
+ } else {
125
+ plan.status = PLAN_STATUS.BROKEN;
126
+ plan.reason = `plan 文件不存在: ${plan.relFile}`;
127
+ }
128
+ } else {
129
+ plan.status = PLAN_STATUS.BROKEN;
130
+ plan.reason = '缺少 file 字段';
131
+ }
132
+
133
+ // 依赖的 plan id 不存在
134
+ for (const dep of plan.depends_on) {
135
+ if (plan.status === PLAN_STATUS.BROKEN) break;
136
+ // 存在性检查放到 job 级(这里拿不到完整列表),见 #findBrokenDeps
137
+ }
138
+ return plan;
139
+ }
140
+
141
+ // 返回成环的 plan 数组(空 = 无环)
142
+ #findCycle(plans) {
143
+ const byId = new Map(plans.map((p) => [p.id, p]));
144
+ const color = new Map(plans.map((p) => [p.id, 0])); // 0 未访问 / 1 访问中 / 2 完成
145
+ const stack = [];
146
+
147
+ const visit = (id) => {
148
+ if (!byId.has(id) || byId.get(id).status === PLAN_STATUS.BROKEN) return [];
149
+ if (color.get(id) === 1) {
150
+ // 找到环:从栈顶往回切到当前 id
151
+ const i = stack.lastIndexOf(id);
152
+ return stack.slice(i).map((x) => byId.get(x));
153
+ }
154
+ if (color.get(id) === 2) return [];
155
+ color.set(id, 1);
156
+ stack.push(id);
157
+ for (const dep of byId.get(id).depends_on) {
158
+ const c = visit(dep);
159
+ if (c.length) return c;
160
+ }
161
+ stack.pop();
162
+ color.set(id, 2);
163
+ return [];
164
+ };
165
+
166
+ for (const p of plans) {
167
+ const c = visit(p.id);
168
+ if (c.length) return c;
169
+ }
170
+ return [];
171
+ }
172
+
173
+ #brokenDeps(job) {
174
+ const ids = new Set(job.plans.map((p) => p.id));
175
+ for (const p of job.plans) {
176
+ if (p.status === PLAN_STATUS.BROKEN) continue;
177
+ for (const dep of p.depends_on) {
178
+ if (!ids.has(dep)) {
179
+ p.status = PLAN_STATUS.BROKEN;
180
+ p.reason = `依赖的 plan 不存在: ${dep}`;
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // ---- 查询 ----
187
+
188
+ list() {
189
+ const jobs = this.#parseJobs();
190
+ for (const j of jobs) {
191
+ if (!j.broken) this.#brokenDeps(j);
192
+ j.status = this.#jobStatus(j);
193
+ if (j.broken) j.status = JOB_STATUS.BROKEN;
194
+ }
195
+ return this.#sorted(jobs);
196
+ }
197
+
198
+ #jobStatus(job) {
199
+ if (job.broken) return JOB_STATUS.BROKEN;
200
+ const s = job.plans.map((p) => p.status);
201
+ if (s.some((x) => x === PLAN_STATUS.RUNNING)) return JOB_STATUS.RUNNING;
202
+ if (s.every((x) => x === PLAN_STATUS.DONE)) return JOB_STATUS.DONE;
203
+ if (s.some((x) => x === PLAN_STATUS.FAILED || x === PLAN_STATUS.BROKEN)) return JOB_STATUS.FAILED;
204
+ return JOB_STATUS.PENDING;
205
+ }
206
+
207
+ // 下一个能执行的 plan:{ job, plan } 或 null
208
+ // 依赖没解完的(含依赖失败/成环)不会返回
209
+ next() {
210
+ for (const job of this.list()) {
211
+ if (job.broken) continue;
212
+ for (const plan of job.plans) {
213
+ if (plan.status !== PLAN_STATUS.PENDING) continue;
214
+ if (this.ready(job, plan)) return { job, plan };
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ // plan 的 depends_on 全部为 done
221
+ ready(job, plan) {
222
+ if (plan.status !== PLAN_STATUS.PENDING) return false;
223
+ for (const dep of plan.depends_on) {
224
+ const d = job.plans.find((p) => p.id === dep);
225
+ if (!d || d.status !== PLAN_STATUS.DONE) return false;
226
+ }
227
+ return true;
228
+ }
229
+
230
+ // 新会话开始时:把失败的 plan 打回 pending,让它们今晚/下轮重新尝试。
231
+ // 顺带清掉卡在 running 的残留(上次被 kill 时留下,新会话里没人真在跑它)
232
+ resetFailed() {
233
+ let changed = false;
234
+ for (const job of this.#parseJobs()) {
235
+ if (job.broken) continue;
236
+ for (const p of job.plans) {
237
+ const st = p.status;
238
+ if ((st === PLAN_STATUS.FAILED || st === PLAN_STATUS.RUNNING)
239
+ && this.state[job.id]?.plans?.[p.id]) {
240
+ this.state[job.id].plans[p.id].status = PLAN_STATUS.PENDING;
241
+ this.state[job.id].plans[p.id].reason = null;
242
+ changed = true;
243
+ }
244
+ }
245
+ }
246
+ if (changed) this.#saveState();
247
+ }
248
+
249
+ // ---- 操作 ----
250
+
251
+ // 新增一个队列文件(引用已存在的 plan 文档)
252
+ add({ title, planFile, agent = null }) {
253
+ let n = 1;
254
+ while (existsSync(join(this.dir, `${String(n).padStart(3, '0')}.yaml`))) n++;
255
+ const jobId = String(n).padStart(3, '0');
256
+ const body = {
257
+ title,
258
+ ...(agent ? { agent } : {}),
259
+ plans: [{
260
+ id: '001',
261
+ title,
262
+ file: planFile,
263
+ depends_on: [],
264
+ }],
265
+ };
266
+ writeFileSync(join(this.dir, `${jobId}.yaml`), YAML.stringify(body, null, 2), 'utf8');
267
+ this.#emit();
268
+ return jobId;
269
+ }
270
+
271
+ remove(jobId) {
272
+ const f = join(this.dir, `${jobId}.yaml`);
273
+ if (!existsSync(f)) return false;
274
+ unlinkSync(f);
275
+ delete this.state[jobId];
276
+ this.#saveState();
277
+ this.#emit();
278
+ return true;
279
+ }
280
+
281
+ markPlan(jobId, planId, status, extra = {}) {
282
+ const jobState = (this.state[jobId] ||= { plans: {} });
283
+ jobState.plans[planId] = { status, at: new Date().toISOString(), ...extra };
284
+ this.#saveState();
285
+ this.#emit();
286
+ }
287
+
288
+ // 任务文件被删了,state 里对应的记录清掉
289
+ prune() {
290
+ const jobs = this.#parseJobs();
291
+ const jobIds = new Set(jobs.map((j) => j.id));
292
+ let changed = false;
293
+
294
+ for (const id of Object.keys(this.state)) {
295
+ if (id === '__order') continue;
296
+ if (!jobIds.has(id)) { delete this.state[id]; changed = true; }
297
+ }
298
+
299
+ // 文件内删掉的 plan,清掉对应状态
300
+ for (const job of jobs) {
301
+ const st = this.state[job.id];
302
+ if (!st?.plans) continue;
303
+ const alive = new Set(job.plans.map((p) => p.id));
304
+ for (const pid of Object.keys(st.plans)) {
305
+ if (!alive.has(pid)) { delete st.plans[pid]; changed = true; }
306
+ }
307
+ }
308
+
309
+ // 顺序表里删掉已经不存在的 id
310
+ if (Array.isArray(this.state.__order)) {
311
+ const before = this.state.__order.length;
312
+ this.state.__order = this.state.__order.filter((id) => jobIds.has(id));
313
+ if (this.state.__order.length !== before) changed = true;
314
+ }
315
+
316
+ if (changed) this.#saveState();
317
+ }
318
+
319
+ // ---- 排序 ----
320
+
321
+ // state.json 里记的顺序优先,没记的按文件名追加
322
+ #sorted(jobs) {
323
+ const order = this.state.__order || [];
324
+ const rank = (id) => {
325
+ const i = order.indexOf(id);
326
+ return i === -1 ? 1e9 : i;
327
+ };
328
+ return [...jobs].sort((a, b) => rank(a.id) - rank(b.id) || a.id.localeCompare(b.id));
329
+ }
330
+ }
package/src/runner.js ADDED
@@ -0,0 +1,166 @@
1
+ // Runner:纯脚本看护。起 agent、盯三条线(退出/卡死/超时)、失败按次数重试。
2
+ // 调度按 plan 粒度:谁 ready 谁跑;deps 没解完的(含依赖失败)不碰。
3
+ import { createWriteStream, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { Agent, tail, appendReport } from './agents.js';
6
+
7
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ const fmtDur = (ms) => {
10
+ const s = Math.floor(ms / 1000);
11
+ const h = Math.floor(s / 3600);
12
+ const m = Math.floor((s % 3600) / 60);
13
+ const sec = s % 60;
14
+ return h ? `${h}小时${m}分` : m ? `${m}分${sec}秒` : `${sec}秒`;
15
+ };
16
+
17
+ // 退出码 + 日志关键字 -> 人话原因(只归类,不决策)
18
+ const SIGNATURES = [
19
+ [/out of memory|heap out of memory|ENOMEM|oom-?kill/i, '内存爆了 (OOM)'],
20
+ [/ENOTFOUND|ETIMEDOUT|ECONNREFUSED|ECONNRESET|getaddrinfo/i, '网络不通'],
21
+ [/rate.?limit|429|too many requests|quota/i, 'API 限流 / 额度用完'],
22
+ [/authentication|401|403|invalid.*key|unauthorized/i, '认证失败 (key 过期?)'],
23
+ [/permission denied|EACCES|not permitted/i, '权限不足'],
24
+ [/git.*conflict|CONFLICT|merge.*fail/i, 'git 冲突'],
25
+ [/test.*fail|assertion|jest.*failed|npm ERR! test/i, '测试没过'],
26
+ [/command not found|ENOENT/i, '命令找不到'],
27
+ [/ENOSPC|no space left/i, '磁盘满了'],
28
+ ];
29
+
30
+ export function classifyExit(code, sig, output) {
31
+ for (const [re, why] of SIGNATURES) {
32
+ if (re.test(output)) return why;
33
+ }
34
+ if (code === 137) return '内存爆了 (OOM)';
35
+ if (code === 143) return '被正常终止 (SIGTERM)';
36
+ if (sig) return `被信号 ${sig} 杀掉`;
37
+ return `进程异常退出 (code ${code})`;
38
+ }
39
+
40
+ export class Runner {
41
+ constructor(cfg, { log, onAgentOutput } = {}) {
42
+ this.cfg = cfg;
43
+ this.agent = new Agent(cfg);
44
+ this.log = log || (() => {}); // runner 日志 -> 左栏
45
+ this.onAgentOutput = onAgentOutput || (() => {}); // agent 输出 -> 右栏
46
+ this.current = null;
47
+ }
48
+
49
+ // 按配置把 prompt_template 拼上 plan 全文
50
+ buildPrompt(plan) {
51
+ const tpl = this.cfg.runner.prompt_template || '';
52
+ const header = tpl
53
+ .replace(/\{plan_file\}/g, plan.relFile || plan.title)
54
+ .replace(/\{decision_log_dir\}/g, this.cfg.runner.decision_log_dir)
55
+ .trim();
56
+ return [header, '--- plan 内容 ---', plan.content].filter(Boolean).join('\n\n');
57
+ }
58
+
59
+ // 跑一个 plan:单 plan 重试,最多 retry.max 次
60
+ async runPlan(job, plan) {
61
+ const max = this.cfg.retry.max;
62
+ let lastErr = '';
63
+
64
+ for (let attempt = 1; attempt <= max; attempt++) {
65
+ // 重放同一份 prompt:plan 自己靠 docs/progress/ 的进度文件续跑
66
+ const prompt = this.buildPrompt(plan);
67
+ const res = await this.#oneShot(job, plan, prompt, attempt);
68
+ if (res.status === 'done') return { status: 'done', attempts: attempt };
69
+
70
+ lastErr = res.error;
71
+ this.#report(job, plan, attempt, res.reason, res.error, attempt < max ? 'retry' : 'failed');
72
+ this.log(`✗ [${job.id}/${plan.id}] 第 ${attempt}/${max} 次失败 — ${res.reason}`);
73
+
74
+ if (attempt < max) await sleep(this.cfg.retry.backoffMs);
75
+ }
76
+
77
+ return { status: 'failed', attempts: max, reason: lastErr ? '重试用完' : '重试用完' };
78
+ }
79
+
80
+ // 跑一次,盯到出结果
81
+ async #oneShot(job, plan, prompt, attempt) {
82
+ mkdirSync(this.cfg.paths.logs, { recursive: true });
83
+ const logFile = join(this.cfg.paths.logs, `${job.id}-${plan.id}-a${attempt}.log`);
84
+
85
+ let handle;
86
+ try {
87
+ handle = this.agent.run(job.agent || this.cfg.default_agent, prompt, {
88
+ onOutput: (s) => this.onAgentOutput(s),
89
+ });
90
+ } catch (e) {
91
+ return { status: 'err', reason: '启动失败', error: e.message };
92
+ }
93
+
94
+ this.current = { job, plan, handle, startedAt: Date.now() };
95
+ this.log(`▶ [${job.id}/${plan.id}] 第 ${attempt} 次: ${handle.name} — ${handle.command}`);
96
+
97
+ const outcome = await this.#watch(job, plan, handle, logFile);
98
+ this.current = null;
99
+ return outcome;
100
+ }
101
+
102
+ // 三条线:退出 / 卡死 / 超时
103
+ #watch(job, plan, handle, logFile) {
104
+ const { idleKillMs, timeoutMs, tailLines } = this.cfg.runner;
105
+ const started = Date.now();
106
+ const tag = `${job.id}/${plan.id}`;
107
+
108
+ return new Promise((resolve) => {
109
+ let settled = false;
110
+ let idleTimer, hardTimer;
111
+ const done = (r) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ clearInterval(idleTimer);
115
+ clearTimeout(hardTimer);
116
+ resolve(r);
117
+ };
118
+
119
+ // 1. 进程退出
120
+ handle.proc.on('exit', (code, sig) => {
121
+ if (code === 0) {
122
+ this.log(`✓ [${tag}] 完成,跑了 ${fmtDur(Date.now() - started)}`);
123
+ return done({ status: 'done' });
124
+ }
125
+ const all = handle.output + '\n' + handle.stderr;
126
+ const err = tail(all, tailLines) || `进程退出 code=${code} sig=${sig},无输出`;
127
+ done({ status: 'err', reason: classifyExit(code, sig, all), error: err });
128
+ });
129
+
130
+ handle.proc.on('error', (e) => {
131
+ done({ status: 'err', reason: '进程起不来', error: e.message });
132
+ });
133
+
134
+ // 2. 卡死:多久没新输出
135
+ let last = Date.now();
136
+ const poll = Math.max(1000, Math.min(30_000, Math.floor(idleKillMs / 3)));
137
+ idleTimer = setInterval(() => {
138
+ if (Date.now() - last > idleKillMs) {
139
+ this.agent.kill(handle);
140
+ done({ status: 'err', reason: '卡死(长时间无输出)',
141
+ error: tail(handle.output + '\n' + handle.stderr, tailLines) });
142
+ }
143
+ }, poll);
144
+
145
+ // 3. 硬超时
146
+ hardTimer = setTimeout(() => {
147
+ this.agent.kill(handle);
148
+ done({ status: 'err', reason: '超时', error: tail(handle.output, tailLines) });
149
+ }, timeoutMs);
150
+
151
+ // 落盘原始输出,方便事后翻
152
+ const stream = createWriteStream(logFile, { flags: 'w' });
153
+ handle.proc.stdout.on('data', (d) => { last = Date.now(); stream.write(d); });
154
+ handle.proc.stderr.on('data', (d) => { last = Date.now(); stream.write(d); });
155
+ handle.proc.on('exit', () => stream.end());
156
+ });
157
+ }
158
+
159
+ #report(job, plan, attempt, reason, err, result) {
160
+ const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
161
+ appendReport(this.cfg,
162
+ `\n## ${result === 'retry' ? '重试' : '失败'} — ${job.id}/${plan.id} (${now})\n`
163
+ + `- 任务: ${plan.title}\n- 第 ${attempt} 次尝试,${reason}\n`
164
+ + `- 日志末尾:\n\`\`\`\n${String(err).slice(-600)}\n\`\`\`\n`);
165
+ }
166
+ }
package/src/tui.js ADDED
@@ -0,0 +1,175 @@
1
+ // TUI:左右分屏。左 = runner 日志(派单/重试),右 = agent 实时输出。
2
+ // 纯显示,无交互。Ctrl+C 退出。
3
+ const ESC = '\x1b';
4
+ const hideCursor = () => process.stdout.write(`${ESC}[?25l`);
5
+ const showCursor = () => process.stdout.write(`${ESC}[?25h`);
6
+ const enterAlt = () => process.stdout.write(`${ESC}[?1049h`);
7
+ const leaveAlt = () => process.stdout.write(`${ESC}[?1049l`);
8
+ const home = () => `${ESC}[H`;
9
+ const clearScreen = () => `${ESC}[2J${ESC}[H`;
10
+
11
+ const dim = (s) => `${ESC}[2m${s}${ESC}[0m`;
12
+ const bold = (s) => `${ESC}[1m${s}${ESC}[0m`;
13
+ const cyan = (s) => `${ESC}[36m${s}${ESC}[0m`;
14
+ const yellow = (s) => `${ESC}[33m${s}${ESC}[0m`;
15
+ const green = (s) => `${ESC}[32m${s}${ESC}[0m`;
16
+
17
+ // 去掉 ANSI 转义,避免 agent 自带颜色冲垮分栏
18
+ const ANSI = new RegExp(`${ESC}\\[[0-9;?]*[A-Za-z]`, 'g');
19
+ const stripAnsi = (s) => String(s).replace(ANSI, '').replace(/\t/g, ' ');
20
+
21
+ // 东亚宽字符算 2 列,不然中文分栏会错位
22
+ function charWidth(ch) {
23
+ const cp = ch.codePointAt(0);
24
+ if (cp === 0x200d || cp === 0xfe0f || cp === 0x200b) return 0;
25
+ if (cp >= 0x1f300 && cp <= 0x1faff) return 2;
26
+ if (cp >= 0x2600 && cp <= 0x27bf) return 2;
27
+ if (
28
+ (cp >= 0x1100 && cp <= 0x115f) ||
29
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
30
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
31
+ (cp >= 0xf900 && cp <= 0xfaff) ||
32
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
33
+ (cp >= 0xff00 && cp <= 0xff60) ||
34
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
35
+ (cp >= 0x3000 && cp <= 0x303f)
36
+ ) return 2;
37
+ return 1;
38
+ }
39
+
40
+ export const visWidth = (s) => {
41
+ let w = 0;
42
+ for (const ch of s) w += charWidth(ch);
43
+ return w;
44
+ };
45
+
46
+ export const padTo = (s, width) => s + ' '.repeat(Math.max(0, width - visWidth(s)));
47
+
48
+ // 按显示宽度折行
49
+ export function wrapLine(line, width) {
50
+ if (width < 1) return [''];
51
+ const out = [];
52
+ let cur = '';
53
+ let w = 0;
54
+ for (const ch of line) {
55
+ const c = charWidth(ch);
56
+ if (w + c > width && cur) { out.push(cur); cur = ''; w = 0; }
57
+ cur += ch;
58
+ w += c;
59
+ }
60
+ out.push(cur);
61
+ return out;
62
+ }
63
+
64
+ const fmtDur = (ms) => {
65
+ const s = Math.floor(ms / 1000);
66
+ const h = Math.floor(s / 3600);
67
+ const m = Math.floor((s % 3600) / 60);
68
+ return h ? `${h}h${m}m` : m ? `${m}m${s % 60}s` : `${s}s`;
69
+ };
70
+
71
+ export class TUI {
72
+ constructor(cfg, { queue, state }) {
73
+ this.cfg = cfg;
74
+ this.queue = queue;
75
+ this.state = state; // { current, paused, windowLabel }
76
+ this.runnerLog = []; // 左栏:runner 事件
77
+ this.agentLog = []; // 右栏:agent 输出
78
+ this._agentBuf = '';
79
+ this.rendering = false;
80
+ }
81
+
82
+ start() {
83
+ enterAlt();
84
+ hideCursor();
85
+ process.on('SIGINT', () => this.stop());
86
+ process.on('SIGTERM', () => this.stop());
87
+ process.stdout.on('resize', () => this.render());
88
+ this.timer = setInterval(() => this.render(), 250);
89
+ this.render();
90
+ }
91
+
92
+ stop() {
93
+ clearInterval(this.timer);
94
+ process.stdout.write(clearScreen());
95
+ leaveAlt();
96
+ showCursor();
97
+ process.exit(0);
98
+ }
99
+
100
+ // 左栏:runner 日志
101
+ log(line) {
102
+ for (const l of stripAnsi(line).split('\n')) this.runnerLog.push(l);
103
+ if (this.runnerLog.length > 2000) this.runnerLog.splice(0, 1000);
104
+ }
105
+
106
+ // 右栏:agent 原始输出,按行切
107
+ agentOutput(chunk) {
108
+ this._agentBuf += stripAnsi(chunk).replace(/\r/g, '');
109
+ const parts = this._agentBuf.split('\n');
110
+ this._agentBuf = parts.pop();
111
+ for (const p of parts) this.agentLog.push(p);
112
+ if (this.agentLog.length > 2000) this.agentLog.splice(0, 1000);
113
+ }
114
+
115
+ // 顶部边框,内嵌标题:┌─ runner ──┬─ agent 001 ──┐
116
+ #topRow(leftW, rightW, rightLabel) {
117
+ const seg = (label, w, lc, mc) => {
118
+ const t = label ? ` ${label} ` : '';
119
+ const tW = visWidth(t);
120
+ return lc + (t ? t + '─'.repeat(Math.max(0, w - tW)) : '─'.repeat(w)) + mc;
121
+ };
122
+ return seg('runner', leftW, '┌', '┬') + seg(rightLabel, rightW, '', '┐');
123
+ }
124
+
125
+ render() {
126
+ if (this.rendering) return;
127
+ this.rendering = true;
128
+
129
+ const W = process.stdout.columns || 100;
130
+ const H = process.stdout.rows || 30;
131
+ const total = Math.max(20, W - 1); // 末列留空,防止自动换行
132
+ const leftW = Math.max(16, Math.floor((total - 1) * 0.38));
133
+ const rightW = total - leftW - 1;
134
+ const bodyH = Math.max(3, H - 4); // 标题 + 上下边框 + 状态行
135
+
136
+ const jobs = this.queue.list();
137
+ const cur = this.state.current;
138
+ const agentLabel = cur ? `agent ${cur.jobId}/${cur.planId}` : 'agent';
139
+
140
+ const lines = [];
141
+ lines.push(bold('overnight'.padEnd(12)) + dim(' 让 AI 上夜班 · Ctrl+C 退出'));
142
+ lines.push(this.#topRow(leftW, rightW, agentLabel));
143
+
144
+ // 两栏各自折行,取最后 bodyH 行,底对齐
145
+ const leftWrapped = wrapAll(this.runnerLog, leftW).slice(-bodyH);
146
+ const rightWrapped = wrapAll(this.agentLog, rightW).slice(-bodyH);
147
+ while (leftWrapped.length < bodyH) leftWrapped.unshift('');
148
+ while (rightWrapped.length < bodyH) rightWrapped.unshift('');
149
+
150
+ for (let i = 0; i < bodyH; i++) {
151
+ lines.push('│' + padTo(leftWrapped[i], leftW) + '│' + padTo(rightWrapped[i], rightW) + '│');
152
+ }
153
+
154
+ lines.push('└' + '─'.repeat(leftW) + '┴' + '─'.repeat(rightW) + '┘');
155
+
156
+ // 状态行:统计的是 plan 粒度
157
+ const pendingPlans = jobs.reduce((n, j) => n + (j.broken ? 0 : j.plans.filter((p) => p.status === 'pending').length), 0);
158
+ const totalPlans = jobs.reduce((n, j) => n + (j.broken ? 0 : j.plans.length), 0);
159
+ const curInfo = cur
160
+ ? `当前 ${cur.jobId}/${cur.planId} (跑了 ${fmtDur(Date.now() - cur.startedAt)})`
161
+ : '待命';
162
+ const status = `plans ${pendingPlans}/${totalPlans} 待跑 · ${curInfo} · 窗口 ${this.state.windowLabel}`
163
+ + (this.state.paused ? ' · 已暂停' : '');
164
+ lines.push(cyan(status));
165
+
166
+ process.stdout.write(home() + lines.join('\n') + `${ESC}[0K`);
167
+ this.rendering = false;
168
+ }
169
+ }
170
+
171
+ function wrapAll(arr, width) {
172
+ const out = [];
173
+ for (const line of arr) out.push(...wrapLine(line, width));
174
+ return out;
175
+ }