@h2562961224/cc-runner 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 chuzhen
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 ADDED
@@ -0,0 +1,77 @@
1
+ # cc-runner
2
+
3
+ 云端下发、本地执行的 Claude Code 任务守护进程。**做这套东西的初衷:告警 webhook 一响,本地 cc 自动排查、把"要不要紧"回给你。** 云端是唯一事实源;本地只做三件事——轮询拉取、到点执行、结果上报。零 npm 依赖,Node ≥ 18。
4
+
5
+ 本地全程**只有出站 HTTPS,不监听任何端口**——所以告警回调打到云端,不需要在你的机器上开公网入口。每次请求带 `X-Runner-Token`,**令牌即身份**(服务器据此认出是哪台 runner),凭证在控制台 Runner 页复制、可随时重置。
6
+
7
+ ## 纯中心化:一律先领取
8
+
9
+ runner **不做任何本地自主调度**——不算 cron、不离线自跑。**要执行的任务(webhook 触发 / 手动立即执行 / 定时到期)全部由服务器生成为待领队列,runner 先 `/claim` 领到才执行。** 断网就是纯粹拉不到任务,恢复后接着领。
10
+
11
+ ```
12
+ 云端应用(唯一调度中心)
13
+ 告警/CI/GitLab webhook ─┐
14
+ 界面手动"立即执行" ├──▶ 服务器生成待领队列(pending job)
15
+ 定时任务到期(服务器算)─┘ │
16
+ ▼ /claim 逐条领取(服务器原子置 claimed)
17
+ ┌──────────────────────── cc-runner 守护进程(launchd 常驻)────────────────────┐
18
+ │ claude -p(headless) 飞书通知 │
19
+ └──────────────────────────────────────┬──────────────────────────────────────┘
20
+ ▼ /report 回填(outbox 断线补传)
21
+ ```
22
+
23
+ - **`/heartbeat`** 每 30s 打卡,返回定时任务版本号。
24
+ - **`/sync`** 版本变了拉本机定时任务定义——**仅本地缓存供 `status` 展示,不驱动执行**(执行一律走 `/claim`)。
25
+ - **`/claim`** 唯一执行入口:逐条领取服务器生成的 pending job。
26
+ - **`/report`** 回填结果(只能改自己领走的那条)。
27
+
28
+ ## 安装(用户视角,一次粘贴)
29
+
30
+ ```bash
31
+ npm i -g @h2562961224/cc-runner
32
+ cc-runner init --api https://<your-project>.example.com/functions/v1/runner-api --token <X-Runner-Token> --workdir ~/your-project
33
+ cc-runner install # 挂载 launchd:开机自启、crash 自动拉起
34
+ ```
35
+
36
+ 验证:`cc-runner status`;日志 `tail -f ~/.cc-runner/logs/daemon.log`。
37
+
38
+ ## 协议契约(runner-api,全部 POST + `X-Runner-Token`)
39
+
40
+ | 动作 | 调用 | 返回 |
41
+ |---|---|---|
42
+ | 心跳 + 拿版本 | `POST /heartbeat` | `{ok, runner, orgId, orgName, schedulesVersion}` |
43
+ | 拉定时任务定义 | `POST /sync`(版本变了才拉,全量覆盖) | `{version, runner, orgId, schedules:[...]}` |
44
+ | 领队列任务 | `POST /claim`(逐条领到空为止) | `{job}` 或 `{job: null}` |
45
+ | 回填队列结果 | `POST /report`(只能改自己领走的那条) | `{ok, jobId, status}` |
46
+
47
+ `/report` body:`{jobId, status, exitCode, summary, error, startedAt, finishedAt}`。
48
+
49
+ - **幂等**:所有任务由服务器 `/claim` 原子认领,天然不会被两台 runner 抢到同一条;本地 `done` 表再兜一层,防同一进程内重复入队。
50
+ - **上报走 outbox**:结果先落 `~/.cc-runner/data/outbox/`,每轮先补传再领新;断线累积、恢复补传。网络/5xx 重试,4xx(如 job 已被接管)丢弃不死循环。
51
+ - **定时任务**:cron/once 到期由**服务器**生成待领 job,runner 和 webhook/手动一视同仁地领取、执行、`/report` 回填——所以定时任务结果同样进服务器 job 历史。
52
+ - **崩溃恢复**:领取后没跑完就 crash 的任务,会停在服务器侧 `claimed`——本 runner 不自动重跑(宁缺勿重),由服务器/控制台按超时回收或人工重新触发。
53
+
54
+ ## 安全边界
55
+
56
+ - 本地只有出站 HTTPS,不监听端口。
57
+ - 云端建任务 ≈ 在本机执行代码:`allowed_workdirs` 白名单**由本地 config 说了算**,云端下发的 workdir 不在白名单内直接 `rejected`,不执行。
58
+ - `config.json` 含 runner token,权限 0600;默认 `--dangerously-skip-permissions` 跑 cc,介意就改 `claude_args`(如 `--permission-mode acceptEdits`)。
59
+
60
+ ## 目录
61
+
62
+ ```
63
+ ~/.cc-runner/
64
+ ├── config.json init 生成(0600)
65
+ ├── logs/daemon.log launchd 重定向
66
+ └── data/
67
+ ├── state.json 定时任务版本+缓存+done 表+outbox 序号
68
+ ├── outbox/ 待补传的 /report(可重放)
69
+ └── transcripts/ 每次执行的 cc 原始输出(按 run_id)
70
+ ```
71
+
72
+ ## 已知取舍(MVP)
73
+
74
+ - 轮询间隔即指令延迟(默认 30s);想更低做 long-poll,协议不用改。
75
+ - cron 回看窗口 48h;`catchup_latest` 停机超 48h 不补。
76
+ - 串行执行,同刻多任务排队;超时 SIGKILL。
77
+ - 日志不轮转,大了自己清。
package/bin/cli.js ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ // cc-runner CLI:init(写配置)/ start(前台跑)/ install(挂 launchd 常驻)
3
+ // / uninstall / status
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+
11
+ const HOME_DIR = process.env.CC_RUNNER_HOME || path.join(os.homedir(), '.cc-runner');
12
+ const CONFIG_PATH = path.join(HOME_DIR, 'config.json');
13
+ const LOG_DIR = path.join(HOME_DIR, 'logs');
14
+ const PLIST_LABEL = 'com.cc-runner.daemon';
15
+ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
16
+
17
+ const [cmd, ...rest] = process.argv.slice(2);
18
+
19
+ // 简易 flag 解析:--key value,可重复的收集为数组
20
+ function parseFlags(argv, multi = []) {
21
+ const out = {};
22
+ for (let i = 0; i < argv.length; i++) {
23
+ if (!argv[i].startsWith('--')) continue;
24
+ const k = argv[i].slice(2);
25
+ const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
26
+ if (multi.includes(k)) (out[k] = out[k] || []).push(v);
27
+ else out[k] = v;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function usage() {
33
+ console.log(`cc-runner — 云端下发、本地执行的 Claude Code 任务守护进程
34
+
35
+ 用法:
36
+ cc-runner init --api <runner-api地址> --token <X-Runner-Token> [选项] 写配置到 ~/.cc-runner/
37
+ 选项: --workdir <path> 允许执行任务的目录白名单,可重复(默认 ~)
38
+ --feishu <webhook> 默认飞书通知地址
39
+ --poll <秒> 轮询间隔(默认 30)
40
+ --model <model> 执行任务用的模型
41
+ 说明: runner 身份由 token 决定,凭证在控制台 Runner 页复制、可随时重置
42
+ cc-runner start 前台运行(调试用)
43
+ cc-runner install 挂载为 launchd 常驻服务(macOS,开机自启+守护)
44
+ cc-runner uninstall 卸载 launchd 服务
45
+ cc-runner status 查看运行状态与最近任务`);
46
+ }
47
+
48
+ function requireConfig() {
49
+ if (!fs.existsSync(CONFIG_PATH)) {
50
+ console.error('还没配置,先跑 cc-runner init(命令可从云端控制台「接入指引」页复制)');
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ switch (cmd) {
56
+ case 'init': {
57
+ const f = parseFlags(rest, ['workdir']);
58
+ if (!f.api || !f.token) { console.error('init 需要 --api 和 --token'); process.exit(1); }
59
+ fs.mkdirSync(HOME_DIR, { recursive: true });
60
+ const cfg = {
61
+ api_url: f.api.replace(/\/$/, ''),
62
+ runner_token: f.token,
63
+ runner_id: f.runner || '', // 留空:身份以服务器 heartbeat 返回的为准
64
+ poll_interval_seconds: f.poll ? parseInt(f.poll, 10) : 30,
65
+ allowed_workdirs: f.workdir || [os.homedir()],
66
+ default_feishu_webhook: f.feishu || '',
67
+ model: f.model || '',
68
+ claude_args: ['--dangerously-skip-permissions'],
69
+ };
70
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
71
+ fs.chmodSync(CONFIG_PATH, 0o600); // 里面有 runner token
72
+ console.log(`已写入 ${CONFIG_PATH}`);
73
+ console.log('下一步: cc-runner install(常驻)或 cc-runner start(前台调试)');
74
+ break;
75
+ }
76
+
77
+ case 'start': {
78
+ requireConfig();
79
+ require(path.join(__dirname, '..', 'runner.js'));
80
+ break;
81
+ }
82
+
83
+ case 'install': {
84
+ requireConfig();
85
+ if (process.platform !== 'darwin') {
86
+ console.error('install 目前只做了 macOS launchd;Linux 请用 systemd 跑 `cc-runner start`');
87
+ process.exit(1);
88
+ }
89
+ fs.mkdirSync(LOG_DIR, { recursive: true });
90
+ fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
91
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
92
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93
+ <plist version="1.0"><dict>
94
+ <key>Label</key><string>${PLIST_LABEL}</string>
95
+ <key>ProgramArguments</key><array>
96
+ <string>${process.execPath}</string>
97
+ <string>${path.join(__dirname, 'cli.js')}</string>
98
+ <string>start</string>
99
+ </array>
100
+ <key>RunAtLoad</key><true/>
101
+ <key>KeepAlive</key><true/>
102
+ <key>StandardOutPath</key><string>${path.join(LOG_DIR, 'daemon.log')}</string>
103
+ <key>StandardErrorPath</key><string>${path.join(LOG_DIR, 'daemon.log')}</string>
104
+ <key>EnvironmentVariables</key><dict>
105
+ <key>PATH</key><string>${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}</string>
106
+ <key>HOME</key><string>${os.homedir()}</string>
107
+ </dict>
108
+ </dict></plist>`;
109
+ fs.writeFileSync(PLIST_PATH, plist);
110
+ try { execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`); } catch (_) { /* 未加载过 */ }
111
+ execSync(`launchctl load -w ${PLIST_PATH}`);
112
+ console.log(`已挂载 launchd 服务 ${PLIST_LABEL}(开机自启,crash 自动拉起)`);
113
+ console.log(`日志: tail -f ${path.join(LOG_DIR, 'daemon.log')}`);
114
+ break;
115
+ }
116
+
117
+ case 'uninstall': {
118
+ try { execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`); } catch (_) { /* 忽略 */ }
119
+ if (fs.existsSync(PLIST_PATH)) fs.unlinkSync(PLIST_PATH);
120
+ console.log('已卸载(配置和数据保留在 ~/.cc-runner,需要的话手动删)');
121
+ break;
122
+ }
123
+
124
+ case 'status': {
125
+ requireConfig();
126
+ let loaded = false;
127
+ try { loaded = execSync('launchctl list').toString().includes(PLIST_LABEL); } catch (_) { /* 非 macOS */ }
128
+ console.log(`launchd: ${loaded ? '已挂载' : '未挂载'}`);
129
+ try {
130
+ const st = JSON.parse(fs.readFileSync(path.join(HOME_DIR, 'data', 'state.json'), 'utf8'));
131
+ console.log(`同步版本: ${st.schedule_version},定时任务数: ${(st.schedules || []).length}`);
132
+ const done = Object.entries(st.done || {}).sort((a, b) => (a[1].at < b[1].at ? 1 : -1)).slice(0, 5);
133
+ for (const [k, v] of done) console.log(` 最近执行: ${k} ${v.status} ${v.at}`);
134
+ } catch (_) { console.log('还没有本地状态(从未成功同步/执行)'); }
135
+ const outbox = path.join(HOME_DIR, 'data', 'outbox');
136
+ if (fs.existsSync(outbox)) {
137
+ const n = fs.readdirSync(outbox).length;
138
+ if (n) console.log(`待补传上报: ${n} 条`);
139
+ }
140
+ break;
141
+ }
142
+
143
+ default:
144
+ usage();
145
+ process.exit(cmd ? 1 : 0);
146
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@h2562961224/cc-runner",
3
+ "version": "0.1.0",
4
+ "description": "云端下发、本地执行的 Claude Code 任务守护进程(纯中心化 pull 模型,零依赖)",
5
+ "bin": {
6
+ "cc-runner": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/cli.js",
10
+ "runner.js",
11
+ "README.md",
12
+ "skills/"
13
+ ],
14
+ "keywords": [
15
+ "claude-code",
16
+ "runner",
17
+ "daemon",
18
+ "webhook",
19
+ "cron",
20
+ "automation"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/h2562961224/cc-runner.git"
25
+ },
26
+ "homepage": "https://github.com/h2562961224/cc-runner#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/h2562961224/cc-runner/issues"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "MIT"
37
+ }
package/runner.js ADDED
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ // cc-runner: 本地任务守护进程(pull 模型,纯中心化)。走 runner 专属通道 runner-api,
3
+ // 每次请求带 X-Runner-Token(令牌即身份,服务器据此认出是哪台 runner)。
4
+ //
5
+ // 中心化:runner 不做任何本地自主调度。**所有要执行的任务(webhook / 手动 / 定时)
6
+ // 一律由服务器生成为待领队列,runner 先 /claim 领到才执行** —— 不在本地算 cron、
7
+ // 不离线自跑。断网就是纯粹拉不到任务,恢复后继续领。
8
+ //
9
+ // 四个操作,全部 POST:
10
+ // /heartbeat 每 30s 打卡,返回 { schedulesVersion }
11
+ // /sync 版本变了拉本机定时任务定义(仅本地缓存供 status 展示,不驱动执行)
12
+ // /claim 领一条待执行任务,返回 { job } 或 { job: null }
13
+ // /report 回报结果 { jobId, status, exitCode, summary, error, startedAt, finishedAt }
14
+ // 只能改自己领走的那条(服务器校验)
15
+ //
16
+ // 结果先写本地 outbox 再上报 /report,断线累积、恢复补传。
17
+ // 零 npm 依赖:Node >= 18(需要全局 fetch)。入口是 bin/cli.js(cc-runner start)。
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+ const { spawn } = require('child_process');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // 配置与状态
29
+ // ---------------------------------------------------------------------------
30
+ const HOME_DIR = process.env.CC_RUNNER_HOME || path.join(os.homedir(), '.cc-runner');
31
+ const CONFIG_PATH = process.env.CC_RUNNER_CONFIG || path.join(HOME_DIR, 'config.json');
32
+ let cfg;
33
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch (e) {
34
+ console.error(`[fatal] 读不到配置 ${CONFIG_PATH},先跑: cc-runner init --api <runner-api地址> --token <X-Runner-Token>`);
35
+ process.exit(1);
36
+ }
37
+ for (const k of ['api_url', 'runner_token']) {
38
+ if (!cfg[k]) { console.error(`[fatal] config 缺少 ${k}(旧版 cloud_url/service_key 已废弃,请重新 cc-runner init)`); process.exit(1); }
39
+ }
40
+ const API_BASE = cfg.api_url.replace(/\/$/, '');
41
+ let runnerId = cfg.runner_id || 'default'; // 本地标签;首次 heartbeat 后用服务器返回的真实值覆盖
42
+ let orgName = '';
43
+ const POLL_MS = (cfg.poll_interval_seconds || 30) * 1000;
44
+ const CLAUDE_BIN = cfg.claude_bin || 'claude';
45
+ const CLAUDE_ARGS = cfg.claude_args || ['--dangerously-skip-permissions'];
46
+ const MODEL = cfg.model || '';
47
+ // 云端下发的 workdir 常含字面量 ~(平台 UI 里用户自然会填 ~/xxx),path.resolve 不认
48
+ // ~,会拼成 <cwd>/~/xxx。这里统一把开头的 ~ 展开成 home,再交给 resolve/白名单/spawn。
49
+ function expandTilde(p) {
50
+ if (typeof p !== 'string' || !p) return p;
51
+ if (p === '~') return os.homedir();
52
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
53
+ return p;
54
+ }
55
+ const ALLOWED_WORKDIRS = (cfg.allowed_workdirs || []).map((p) => path.resolve(expandTilde(p)));
56
+ const JOB_PULL_LIMIT = cfg.job_pull_limit || 20; // 每轮最多 /claim 多少条,防一次拉太多
57
+
58
+ const DATA_DIR = path.join(HOME_DIR, 'data');
59
+ const OUTBOX_DIR = path.join(DATA_DIR, 'outbox');
60
+ const TRANSCRIPT_DIR = path.join(DATA_DIR, 'transcripts');
61
+ const STATE_PATH = path.join(DATA_DIR, 'state.json');
62
+ for (const d of [DATA_DIR, OUTBOX_DIR, TRANSCRIPT_DIR]) fs.mkdirSync(d, { recursive: true });
63
+
64
+ // state = { schedule_version, schedules: [](仅缓存展示), done: {"<dedup_key>": {status, at}}, outbox_seq }
65
+ let state = { schedule_version: -1, schedules: [], done: {}, outbox_seq: 0 };
66
+ try { state = { ...state, ...JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')) }; } catch (_) { /* 首次运行 */ }
67
+
68
+ function saveState() {
69
+ const keys = Object.keys(state.done);
70
+ if (keys.length > 5000) {
71
+ keys.sort((a, b) => (state.done[a].at < state.done[b].at ? -1 : 1));
72
+ for (const k of keys.slice(0, keys.length - 5000)) delete state.done[k];
73
+ }
74
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state));
75
+ }
76
+
77
+ function log(msg) { console.log(`${new Date().toISOString()} ${msg}`); }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // runner-api 调用(POST + X-Runner-Token)
81
+ // 抛出的 error 带 .retriable:网络错误/5xx 可重试;4xx/{error} 视为永久失败。
82
+ // ---------------------------------------------------------------------------
83
+ async function api(op, body) {
84
+ let res;
85
+ try {
86
+ res = await fetch(`${API_BASE}/${op}`, {
87
+ method: 'POST',
88
+ headers: { 'X-Runner-Token': cfg.runner_token, 'Content-Type': 'application/json' },
89
+ body: JSON.stringify(body || {}),
90
+ signal: AbortSignal.timeout(20000),
91
+ });
92
+ } catch (e) {
93
+ const err = new Error(`${op} 网络错误: ${e.message}`); err.retriable = true; throw err;
94
+ }
95
+ const text = await res.text();
96
+ let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { /* 非 JSON */ }
97
+ if (!res.ok || (data && data.error)) {
98
+ const msg = (data && data.error) ? data.error : `${res.status} ${text.slice(0, 200)}`;
99
+ const err = new Error(`${op} -> ${msg}`); err.retriable = res.status >= 500; throw err;
100
+ }
101
+ return data;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // 心跳拿版本 + 定时任务定义同步(仅缓存,供 status 展示;执行一律走 /claim)
106
+ // ---------------------------------------------------------------------------
107
+ async function heartbeat() {
108
+ const r = await api('heartbeat');
109
+ if (r && r.runner) runnerId = r.runner;
110
+ if (r && r.orgName) orgName = r.orgName;
111
+ return r && r.schedulesVersion != null ? Number(r.schedulesVersion) : null;
112
+ }
113
+
114
+ async function syncSchedules() {
115
+ const r = await api('sync');
116
+ state.schedules = Array.isArray(r && r.schedules) ? r.schedules : [];
117
+ if (r && r.version != null) state.schedule_version = Number(r.version);
118
+ saveState();
119
+ log(`[sync] 定时任务版本 ${state.schedule_version},清单 ${state.schedules.length} 条(仅缓存,执行走 /claim)`);
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // 领取任务队列(服务器原子认领,逐条领到空为止)。webhook / 手动 / 定时 都在这。
124
+ // ---------------------------------------------------------------------------
125
+ async function claimJobs() {
126
+ const items = [];
127
+ for (let i = 0; i < JOB_PULL_LIMIT; i++) {
128
+ const r = await api('claim');
129
+ const job = r && r.job;
130
+ if (!job) break; // 队列空
131
+ items.push({
132
+ dedupKey: job.dedup_key || `job:${job.id}`, source: job.source || 'webhook', jobId: job.id,
133
+ scheduledAt: job.scheduled_at || job.created_at,
134
+ name: job.source === 'webhook' ? `webhook:${job.trigger_id || ''}` : (job.source || 'job'),
135
+ prompt: job.prompt, workdir: expandTilde(job.workdir),
136
+ timeoutSeconds: job.timeout_seconds || 900, output: job.output || {},
137
+ });
138
+ }
139
+ return items;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // 执行
144
+ // ---------------------------------------------------------------------------
145
+ function workdirAllowed(dir) {
146
+ if (!ALLOWED_WORKDIRS.length) return false; // 白名单空 = 全拒,安全兜底本地说了算
147
+ const r = path.resolve(dir || '');
148
+ return ALLOWED_WORKDIRS.some((w) => r === w || r.startsWith(w + path.sep));
149
+ }
150
+
151
+ function runClaude(item, runId) {
152
+ return new Promise((resolve) => {
153
+ const args = ['-p', item.prompt, '--output-format', 'json', ...CLAUDE_ARGS];
154
+ if (MODEL) args.push('--model', MODEL);
155
+ const timeoutMs = item.timeoutSeconds * 1000;
156
+ const child = spawn(CLAUDE_BIN, args, { cwd: item.workdir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
157
+ let out = ''; let errOut = ''; let timedOut = false;
158
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
159
+ child.stdout.on('data', (d) => { out += d; });
160
+ child.stderr.on('data', (d) => { errOut += d.toString().slice(0, 4000); });
161
+ child.on('error', (e) => { clearTimeout(timer); resolve({ status: 'failed', error: `spawn 失败: ${e.message}` }); });
162
+ child.on('close', (code) => {
163
+ clearTimeout(timer);
164
+ fs.writeFileSync(path.join(TRANSCRIPT_DIR, `${runId}.json`), out || errOut || '');
165
+ if (timedOut) return resolve({ status: 'timeout', error: `超时(${timeoutMs / 1000}s)强杀`, exitCode: code });
166
+ let summary = '';
167
+ try { summary = String(JSON.parse(out).result || '').slice(0, 4000); } catch (_) { summary = (out || '').slice(0, 4000); }
168
+ if (code !== 0) return resolve({ status: 'failed', exitCode: code, summary, error: errOut.slice(0, 1000) });
169
+ resolve({ status: 'success', exitCode: 0, summary });
170
+ });
171
+ });
172
+ }
173
+
174
+ async function notifyFeishu(item, run) {
175
+ const url = (item.output && item.output.feishu_webhook) || cfg.default_feishu_webhook;
176
+ if (!url) return;
177
+ const icon = { success: '✅', failed: '❌', timeout: '⏱️', rejected: '🚫' }[run.status] || '•';
178
+ const body = {
179
+ msg_type: 'interactive',
180
+ card: {
181
+ header: { title: { tag: 'plain_text', content: `${icon} [${run.status}] ${item.name}` },
182
+ template: run.status === 'success' ? 'green' : 'red' },
183
+ elements: [{ tag: 'markdown',
184
+ content: `${(run.summary || run.error || '(无输出)').slice(0, 2000)}\n<font color="grey">runner ${runnerId} · ${item.source} · ${run.scheduled_at}</font>` }],
185
+ },
186
+ };
187
+ try {
188
+ await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify(body), signal: AbortSignal.timeout(10000) });
190
+ } catch (e) { log(`[feishu] ${e.message}`); }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // 上报(outbox:存可重放的 /report 调用;断线累积,恢复补传)
195
+ // ---------------------------------------------------------------------------
196
+ function enqueueOp(op) {
197
+ const seq = String(state.outbox_seq++).padStart(9, '0');
198
+ fs.writeFileSync(path.join(OUTBOX_DIR, `${seq}.json`), JSON.stringify(op));
199
+ saveState();
200
+ }
201
+
202
+ async function flushOutbox() {
203
+ for (const f of fs.readdirSync(OUTBOX_DIR).sort()) {
204
+ const p = path.join(OUTBOX_DIR, f);
205
+ let op;
206
+ try { op = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { fs.unlinkSync(p); continue; }
207
+ if (!op || !op.op) { fs.unlinkSync(p); continue; } // 旧格式/损坏,丢弃
208
+ try {
209
+ await api(op.op, op.body);
210
+ fs.unlinkSync(p);
211
+ } catch (e) {
212
+ if (e.retriable) { log(`[outbox] ${f} 稍后重试: ${e.message}`); return; } // 网络/5xx:留待下轮
213
+ log(`[outbox] ${f} 永久失败,丢弃: ${e.message}`); fs.unlinkSync(p); // 4xx(如 job 已被接管)
214
+ }
215
+ }
216
+ }
217
+
218
+ function reportRun(item, run) {
219
+ enqueueOp({ op: 'report', body: {
220
+ jobId: item.jobId, status: run.status, exitCode: run.exit_code ?? null,
221
+ summary: run.summary, error: run.error, startedAt: run.started_at, finishedAt: run.finished_at,
222
+ } });
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // 串行执行队列 + 主循环
227
+ // ---------------------------------------------------------------------------
228
+ const queue = [];
229
+ const enqueued = new Set(); // 本进程内已排队的 dedupKey,防同一 tick 重复入队
230
+ let working = false;
231
+
232
+ function enqueue(items) {
233
+ for (const it of items) {
234
+ if (state.done[it.dedupKey] || enqueued.has(it.dedupKey)) continue;
235
+ enqueued.add(it.dedupKey);
236
+ queue.push(it);
237
+ }
238
+ }
239
+
240
+ async function processQueue() {
241
+ if (working) return;
242
+ working = true;
243
+ while (queue.length) {
244
+ const item = queue.shift();
245
+ const key = item.dedupKey;
246
+ if (state.done[key]) { enqueued.delete(key); continue; }
247
+
248
+ const runId = crypto.randomUUID();
249
+ const startedAt = new Date().toISOString();
250
+ log(`[run] ${item.name}(${item.source})sched=${item.scheduledAt}`);
251
+
252
+ let result;
253
+ if (!workdirAllowed(item.workdir)) {
254
+ result = { status: 'rejected', error: `workdir 不在本地白名单: ${item.workdir}` };
255
+ } else {
256
+ result = await runClaude(item, runId);
257
+ }
258
+
259
+ const run = {
260
+ scheduled_at: item.scheduledAt, started_at: startedAt, finished_at: new Date().toISOString(),
261
+ status: result.status, exit_code: result.exitCode ?? null,
262
+ summary: result.summary || null, error: result.error || null,
263
+ };
264
+ state.done[key] = { status: result.status, at: run.finished_at }; saveState();
265
+ enqueued.delete(key);
266
+ log(`[run] ${item.name} -> ${result.status}`);
267
+ reportRun(item, run);
268
+ await flushOutbox();
269
+ await notifyFeishu(item, run);
270
+ }
271
+ working = false;
272
+ }
273
+
274
+ async function tick() {
275
+ let online = true;
276
+ // 先补传上一轮攒下的上报
277
+ try { await flushOutbox(); } catch (e) { log(`[tick/outbox] ${e.message}`); }
278
+ // 心跳拿版本
279
+ let remoteVersion = null;
280
+ try { remoteVersion = await heartbeat(); } catch (e) { online = false; log(`[heartbeat] ${e.message}`); }
281
+ // 版本变了才拉全量定时任务定义(仅刷新本地缓存,不驱动执行)
282
+ if (online && remoteVersion !== null && remoteVersion !== state.schedule_version) {
283
+ try { await syncSchedules(); } catch (e) { log(`[sync] ${e.message}`); }
284
+ }
285
+ // 唯一执行入口:领取待办队列(webhook / 手动 / 定时都在服务器侧生成为 job)
286
+ if (online) {
287
+ try { enqueue(await claimJobs()); } catch (e) { log(`[claim] ${e.message}`); }
288
+ }
289
+ processQueue();
290
+ }
291
+
292
+ log(`[cc-runner] 启动 runner_id=${runnerId} poll=${POLL_MS / 1000}s(纯中心化,任务一律 /claim 领取)`);
293
+ tick();
294
+ setInterval(tick, POLL_MS);
@@ -0,0 +1,222 @@
1
+ # CC 任务控制台接入指南(Skill)
2
+
3
+ > 面向 **agent 自动执行**。把告警 webhook 或定时任务,自动交给你本地机器上的 Claude Code 执行 —— 告警一响,本地 cc 自动排查并回填结论。按顺序做完四步即可:① 连中心项目 → ② 装本地 runner → ③ 建触发器/定时 → ④ 查记录。涉及在本机执行代码,装 runner 前务必和用户确认 workdir(见第二步安全提示)。
4
+
5
+ ## 这是什么
6
+
7
+ CC 任务控制台把两类触发,转成本地 Claude Code 的自主执行:
8
+
9
+ - **Webhook 触发(主用途)**:腾讯云告警、GitLab、CI 等打一个回调过来 → 本地 `claude -p` 自动排查 → 结论回填控制台。做这套东西的初衷就是「告警一响,本地 cc 自动告诉你要不要紧」。
10
+ - **定时触发**:cron / 一次性任务,到点由服务器生成待领任务,runner 领走执行。
11
+
12
+ 架构是**中心化的**:云端一个中心项目记录任务、下发指令、汇总结果;你本地跑一个守护进程 `cc-runner`,只做出站轮询(不监听任何端口),拉指令、执行、回填。
13
+
14
+ ---
15
+
16
+ ## 配置(已填好,可直接复制运行)
17
+
18
+ 中心项目地址与 anon key(anon key 设计上就可公开,安全)已在本文填好,所有用户通用:
19
+
20
+ - 中心项目地址:`https://bb41veoe8re43upmktc6.db.superun.com`
21
+ - Edge Function OpenAPI manifest:`https://id--1799105425833984-40e8d3eebf4e4e18893a4b0309261dc4.superun.yun/superun/openapi.json`
22
+
23
+ 以下每个用户各自持有、**切勿写进公开文档或提交仓库**:
24
+
25
+ - 你的登录凭证(`superun login` 用的 token 或邮箱密码)
26
+ - runner token(`cc-runner init --token` 用,在控制台 **Runner 页**复制,可随时重置)
27
+ - 每个 webhook 触发器的 `secret`
28
+
29
+ ---
30
+
31
+ ## 前置条件
32
+
33
+ 在开始前,确认本机具备(缺什么先装什么):
34
+
35
+ ```bash
36
+ node -v # 需要 >= 18
37
+ claude --version # Claude Code CLI 必须已安装并登录过(runner 会调 claude -p 执行任务)
38
+ ```
39
+
40
+ - **Claude Code CLI 必须能直接跑** —— runner 靠它执行任务。先 `claude` 交互跑一次确认已登录。
41
+ - 安装两个 CLI:
42
+
43
+ ```bash
44
+ npm i -g superun-cli # 管理侧:连项目、建任务、查记录(命令为 superun)
45
+ npm i -g @h2562961224/cc-runner # 执行侧:本地守护进程(命令为 cc-runner)
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 第一步:连接中心项目(superun)
51
+
52
+ 注册中心项目并以本人身份登录。**注意 superun 用的是项目根地址,不带 `/rest/v1`;`--manifest` 让 `superun fn` 认得 Edge Functions。**
53
+
54
+ ```bash
55
+ superun init --name cc-task-console \
56
+ --url https://bb41veoe8re43upmktc6.db.superun.com \
57
+ --anon-key eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg0ODg4NDAwLCJleHAiOjIxMDAyNDg0MDB9.mhgBlmntm49GKhGxH4OEUX8v0yglI48mzjN1knyQ9Vk \
58
+ --manifest https://id--1799105425833984-40e8d3eebf4e4e18893a4b0309261dc4.superun.yun/superun/openapi.json
59
+ ```
60
+
61
+ 登录(拿到 token 用 token,最省事;也支持邮箱密码):
62
+
63
+ ```bash
64
+ superun login --token <你的访问令牌>
65
+ # 或: superun login --password --email <邮箱> --pass <密码>
66
+ ```
67
+
68
+ 验证连上了、看得到那几张表:
69
+
70
+ ```bash
71
+ superun whoami # 能解出你的身份
72
+ superun db tables # 应能看到 schedules / webhook_triggers / jobs / runners / sync_meta
73
+ ```
74
+
75
+ > 之后所有 `superun db ...` 都以你登录的身份执行,受 RLS 约束 —— 你只能看到/操作自己有权限的数据。这也是为什么管理走 superun,而不是拿 service key 直连。
76
+
77
+ ---
78
+
79
+ ## 第二步:安装本地 runner(cc-runner)
80
+
81
+ runner 是真正在你机器上执行任务的守护进程。它走 **runner 专属通道**,令牌即身份 —— 在控制台 **Runner 页**「创建 runner」拿到 `X-Runner-Token`,填进 `--token`。
82
+
83
+ > ⚠️ **安全边界(务必和用户确认)**:`--workdir` 是白名单,等于**授权云端在这台机器上、以这个目录为工作区,跑 Claude Code**(默认 `--dangerously-skip-permissions`)。只把你确实想让它操作的项目目录加进来。云端下发的任务若 workdir 不在白名单,会被本地直接拒绝执行。
84
+
85
+ ```bash
86
+ cc-runner init \
87
+ --api https://bb41veoe8re43upmktc6.db.superun.com/functions/v1/runner-api \
88
+ --token <控制台 Runner 页复制的 X-Runner-Token> \
89
+ --workdir ~/你的项目目录
90
+
91
+ cc-runner install # 挂常驻:macOS 走 launchd(开机自启 + crash 自动拉起);Linux 用 systemd 跑 `cc-runner start`
92
+ cc-runner status # 看是否已同步、launchd 是否挂载
93
+ ```
94
+
95
+ 这台机器的 runner 身份由 token 决定(不再手填 `--runner`)。首次心跳后,它会自动出现在控制台「看板」。想在多台机器跑,就在控制台各建一个 runner、拿各自的 token,在每台机器 `init`。
96
+
97
+ 验证心跳到了云端:
98
+
99
+ ```bash
100
+ superun db select runners --select id,last_seen_at,version
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 第三步:创建 webhook 触发器(主用途)
106
+
107
+ 一条触发器 = 一个回调地址 + 一段排查说明书。用 `superun db insert` 建:
108
+
109
+ ```bash
110
+ superun db insert webhook_triggers --data '{
111
+ "name": "节点/Pod 健康告警",
112
+ "slug": "k8s-health",
113
+ "secret": "<生成一个随机串,如 openssl rand -hex 16>",
114
+ "prompt_template": "收到一条 K8s 告警。请只读排查:定位告警对象,看 describe / logs --previous / events / top,判断是 urgent / attention / ignore,最后给出结论和依据。不要做任何写操作。",
115
+ "workdir": "~/你的项目目录",
116
+ "runner": "<目标 runner 名,见控制台看板,如 default>",
117
+ "timeout_seconds": 900,
118
+ "dedup_paths": ["alarmPolicyInfo.policyName", "alarmObjInfo.dimensions"],
119
+ "dedup_window_seconds": 1800
120
+ }'
121
+ ```
122
+
123
+ 字段要点:
124
+
125
+ - **`slug`**:回调地址的路径段,唯一。
126
+ - **`secret`**:调用方必须带的令牌,自己生成一个随机串。
127
+ - **`prompt_template`**:交给 Claude 的说明书;系统会自动把原始告警 JSON 附在末尾。
128
+ - **`runner`**:在哪台机器执行,填目标 runner 名(控制台看板里那个,由令牌决定,默认 `default`)。
129
+ - **`dedup_paths` + `dedup_window_seconds`**:按 payload 里这几个字段的值 + 时间窗做去重,同一窗口内的重复告警只跑一次,天然抗告警风暴。
130
+
131
+ 拿到回调地址(把 `<slug>` `<secret>` 换成上面的值),填进腾讯云可观测→告警策略的接口回调、或 GitLab/CI 的 webhook:
132
+
133
+ ```
134
+ https://bb41veoe8re43upmktc6.db.superun.com/functions/v1/hook/<slug>?token=<secret>
135
+ ```
136
+
137
+ 原有的飞书/邮件通知照留,这个回调是**额外加一条**。
138
+
139
+ 自测一下整条链路(发一条假 payload,看是否入队 → 被 runner 认领 → 跑出结论):
140
+
141
+ ```bash
142
+ curl -X POST "https://bb41veoe8re43upmktc6.db.superun.com/functions/v1/hook/<slug>?token=<secret>" \
143
+ -H "Content-Type: application/json" \
144
+ -d '{"alarmPolicyInfo":{"policyName":"点火测试"},"note":"self test, not a real alert"}'
145
+ # 返回 {"queued":true,"jobId":"..."} 即入队成功;约 30~60s 后在「查看记录」里能看到结果
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 第四步(可选):创建定时任务
151
+
152
+ 定时任务走 `schedules` 表。cron(5 段:分 时 日 月 周)或一次性 `once`:
153
+
154
+ ```bash
155
+ # 每个工作日 18:00 跑一次回归检查
156
+ superun db insert schedules --data '{
157
+ "name": "每日回归检查",
158
+ "type": "cron",
159
+ "cron": "0 18 * * 1-5",
160
+ "prompt": "跑一遍冒烟测试,把失败项和可疑点总结给我。",
161
+ "workdir": "~/你的项目目录",
162
+ "runner": "<目标 runner 名,如 default>",
163
+ "timeout_seconds": 600
164
+ }'
165
+
166
+ # 一次性:某个时间点之后跑一次(type=once + not_before)
167
+ superun db insert schedules --data '{
168
+ "name": "上线后 2 小时看效果",
169
+ "type": "once",
170
+ "not_before": "2026-08-01T12:00:00Z",
171
+ "prompt": "检查新功能上线后的日志和指标,判断有没有异常。",
172
+ "workdir": "~/你的项目目录",
173
+ "runner": "<目标 runner 名,如 default>"
174
+ }'
175
+ ```
176
+
177
+ 定时任务由**服务器统一调度**:到期时服务器生成一条待领 job,runner `/claim` 领走执行、回填结果(和 webhook / 手动任务同一条路径)。runner 不在本地算 cron。`once` 跑成功后自动停用。
178
+
179
+ ---
180
+
181
+ ## 查看执行记录
182
+
183
+ 所有执行结果(webhook / 定时 / 手动)都落在 `jobs` 表 —— 定时任务到期由服务器生成 job,runner 领取执行后同样回填,历史完整。
184
+
185
+ ```bash
186
+ # 最近 20 条
187
+ superun db select jobs \
188
+ --select "source,status,summary,started_at,finished_at" \
189
+ --order "created_at.desc" --limit 20
190
+
191
+ # 只看失败的
192
+ superun db select jobs --eq status=failed --order "created_at.desc" --limit 20
193
+
194
+ # 只看某个 runner
195
+ superun db select jobs --eq runner=<目标 runner 名,如 default> --order "created_at.desc" --limit 20
196
+ ```
197
+
198
+ `status` 含义:`pending` 待认领 / `claimed` 执行中 / `success` 成功 / `failed` 失败 / `timeout` 超时 / `rejected`(workdir 不在本地白名单,被拒)。`summary` 是 Claude 给出的结论,`error` 是失败原因。
199
+
200
+ 本地侧也能快速看:
201
+
202
+ ```bash
203
+ cc-runner status # 同步版本、最近执行、待补传条数
204
+ tail -f ~/.cc-runner/logs/daemon.log # 守护进程实时日志
205
+ ```
206
+
207
+ ---
208
+
209
+ ## 运维与排查
210
+
211
+ - **runner 显示离线**(看板灰点 / `last_seen_at` 很旧):`cc-runner status` 看 launchd 是否挂载;`tail ~/.cc-runner/logs/daemon.log` 看报错。断网期间领不到新任务(纯中心化,不本地自跑);已跑完待回填的结果攒在 outbox,恢复后自动补传。
212
+ - **任务 `rejected`**:workdir 不在本地白名单。要么改触发器/定时的 `workdir`,要么 `cc-runner init` 时把目录加进 `--workdir`(可重复传多个)。
213
+ - **webhook 打过去没反应**:确认地址带对了 `?token=<secret>`;`secret` 不匹配返回 401,`slug` 不存在返回 404。
214
+ - **卸载**:`cc-runner uninstall`(配置和数据保留在 `~/.cc-runner`,需要再手删)。
215
+
216
+ ---
217
+
218
+ ## 安全边界(一句话版)
219
+
220
+ - 本地只有**出站 HTTPS**,不监听任何端口 —— 告警回调打到云端,不需要在你机器上开公网入口。
221
+ - workdir 白名单**由本地说了算**,云端下发不在白名单的目录一律拒绝执行。
222
+ - 管理操作走 `superun` 登录身份(RLS 约束),不发 service key 给用户;runner 的访问 key 存在本地 `~/.cc-runner/config.json`(权限 0600),别提交进仓库。