@h2562961224/cc-runner 0.1.0 → 0.2.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/README.md CHANGED
@@ -35,6 +35,42 @@ cc-runner install # 挂载 launchd:开机自启、crash 自动拉起
35
35
 
36
36
  验证:`cc-runner status`;日志 `tail -f ~/.cc-runner/logs/daemon.log`。
37
37
 
38
+ ## 多账号:一个进程挂多组 token / 工作区
39
+
40
+ 一台机器要同时接几个 workspace(不同组织、不同项目、公私分开)时,不用起多个进程 —— `add` 就行:
41
+
42
+ ```bash
43
+ cc-runner add --name acme --api https://a.example.com/functions/v1/runner-api --token <tokenA> --workdir ~/work/acme
44
+ cc-runner add --name personal --api https://b.example.com/functions/v1/runner-api --token <tokenB> --workdir ~/side
45
+ cc-runner list # 看所有账号
46
+ cc-runner remove --name acme [--purge]
47
+ ```
48
+
49
+ 每个账号是一个**独立实例**:独立轮询、独立队列、独立数据目录 `~/.cc-runner/data/<账号名>/`。所以
50
+
51
+ - **去重表不串**:两个服务器各发一条 `id=1` 的 job,`dedup_key` 都是 `job:1`,但 `done` 表按账号分家,不会被误吞。
52
+ - **白名单按账号收窄**:`allowed_workdirs` 挂在账号上 —— 多账号场景下这是账号之间**唯一的隔离边界**,acme 的任务落不进 personal 的目录。
53
+ - **并发有闸**:账号内串行,跨账号再过一层进程级 `max_concurrent_jobs`(默认 1),免得 N 个账号同时把机器跑满。要放开就在 config 顶层调大。
54
+
55
+ 配置形态(顶层字段是所有账号的默认值,账号里写同名字段即覆盖):
56
+
57
+ ```jsonc
58
+ {
59
+ "max_concurrent_jobs": 1,
60
+ "poll_interval_seconds": 30, // 公共默认
61
+ "accounts": [
62
+ { "name": "acme", "api_url": "...", "runner_token": "...", "allowed_workdirs": ["~/work/acme"] },
63
+ { "name": "personal", "api_url": "...", "runner_token": "...", "allowed_workdirs": ["~/side"],
64
+ "poll_interval_seconds": 60, // 覆盖公共默认
65
+ "env": { "FOO": "bar" } } // 透传给 claude 子进程的环境变量
66
+ ]
67
+ }
68
+ ```
69
+
70
+ 旧版单账号扁平配置(顶层直接写 `api_url`/`runner_token`)照常能读,首次启动会自动把 `data/state.json`、`data/outbox/` 并进 `data/default/`,待补传的上报和 done 表都不丢。
71
+
72
+ > ⚠️ 多 token 隔离的是**平台侧身份**(哪个 workspace 的队列、结果回填到哪),**不隔离本地 Claude 登录态** —— 所有任务都由同一个 `claude` 二进制、同一份凭证执行。要按账号分开计费/换号,用账号的 `env` 字段透传对应的环境变量。
73
+
38
74
  ## 协议契约(runner-api,全部 POST + `X-Runner-Token`)
39
75
 
40
76
  | 动作 | 调用 | 返回 |
@@ -54,24 +90,25 @@ cc-runner install # 挂载 launchd:开机自启、crash 自动拉起
54
90
  ## 安全边界
55
91
 
56
92
  - 本地只有出站 HTTPS,不监听端口。
57
- - 云端建任务 ≈ 在本机执行代码:`allowed_workdirs` 白名单**由本地 config 说了算**,云端下发的 workdir 不在白名单内直接 `rejected`,不执行。
93
+ - 云端建任务 ≈ 在本机执行代码:`allowed_workdirs` 白名单**由本地 config 说了算**,云端下发的 workdir 不在白名单内直接 `rejected`,不执行。白名单**按账号独立**,多账号之间靠它隔离。
58
94
  - `config.json` 含 runner token,权限 0600;默认 `--dangerously-skip-permissions` 跑 cc,介意就改 `claude_args`(如 `--permission-mode acceptEdits`)。
59
95
 
60
96
  ## 目录
61
97
 
62
98
  ```
63
99
  ~/.cc-runner/
64
- ├── config.json init 生成(0600)
65
- ├── logs/daemon.log launchd 重定向
100
+ ├── config.json init/add 生成(0600)
101
+ ├── logs/daemon.log launchd 重定向
66
102
  └── data/
67
- ├── state.json 定时任务版本+缓存+done 表+outbox 序号
68
- ├── outbox/ 待补传的 /report(可重放)
69
- └── transcripts/ 每次执行的 cc 原始输出(按 run_id)
103
+ └── <账号名>/ 每个账号一套,互不干扰
104
+ ├── state.json 定时任务版本+缓存+done 表+outbox 序号
105
+ ├── outbox/ 待补传的 /report(可重放)
106
+ └── transcripts/ 每次执行的 cc 原始输出(按 run_id)
70
107
  ```
71
108
 
72
109
  ## 已知取舍(MVP)
73
110
 
74
111
  - 轮询间隔即指令延迟(默认 30s);想更低做 long-poll,协议不用改。
75
112
  - cron 回看窗口 48h;`catchup_latest` 停机超 48h 不补。
76
- - 串行执行,同刻多任务排队;超时 SIGKILL。
113
+ - 账号内串行,跨账号受 `max_concurrent_jobs`(默认 1)限流,同刻多任务排队;超时 SIGKILL。
77
114
  - 日志不轮转,大了自己清。
package/bin/cli.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // cc-runner CLI:init(写配置)/ start(前台跑)/ install(挂 launchd 常驻)
3
- // / uninstall / status
2
+ // cc-runner CLI:init(写配置)/ add|remove|list(多账号)/ start(前台跑)
3
+ // / install(挂 launchd 常驻)/ uninstall / status
4
4
  'use strict';
5
5
 
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
9
  const { execSync } = require('child_process');
10
+ const {
11
+ HOME_DIR, CONFIG_PATH, INHERITED, dataDirFor, loadRaw, saveRaw, normalize, validate,
12
+ } = require('../config');
10
13
 
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
14
  const LOG_DIR = path.join(HOME_DIR, 'logs');
14
15
  const PLIST_LABEL = 'com.cc-runner.daemon';
15
16
  const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
@@ -34,15 +35,23 @@ function usage() {
34
35
 
35
36
  用法:
36
37
  cc-runner init --api <runner-api地址> --token <X-Runner-Token> [选项] 写配置到 ~/.cc-runner/
37
- 选项: --workdir <path> 允许执行任务的目录白名单,可重复(默认 ~)
38
+ 选项: --name <账号名> 账号标识,决定数据目录(默认 default)
39
+ --workdir <path> 允许执行任务的目录白名单,可重复(默认 ~)
38
40
  --feishu <webhook> 默认飞书通知地址
39
41
  --poll <秒> 轮询间隔(默认 30)
40
42
  --model <model> 执行任务用的模型
43
+ --force 覆盖已有的多账号配置
41
44
  说明: runner 身份由 token 决定,凭证在控制台 Runner 页复制、可随时重置
45
+
46
+ cc-runner add --name <账号名> --api <地址> --token <token> [同上选项]
47
+ 再挂一组 token/工作区到同一个进程(多 workspace)
48
+ cc-runner remove --name <账号名> [--purge] 摘掉一个账号(--purge 连数据目录一起删)
49
+ cc-runner list 列出所有账号
50
+
42
51
  cc-runner start 前台运行(调试用)
43
52
  cc-runner install 挂载为 launchd 常驻服务(macOS,开机自启+守护)
44
53
  cc-runner uninstall 卸载 launchd 服务
45
- cc-runner status 查看运行状态与最近任务`);
54
+ cc-runner status 查看运行状态与最近任务(按账号分组)`);
46
55
  }
47
56
 
48
57
  function requireConfig() {
@@ -52,28 +61,108 @@ function requireConfig() {
52
61
  }
53
62
  }
54
63
 
64
+ // 从 flag 拼一个 account 对象,只写用户实际给了的字段
65
+ function accountFromFlags(f, defaultName) {
66
+ const a = {
67
+ name: f.name || defaultName,
68
+ api_url: f.api.replace(/\/$/, ''),
69
+ runner_token: f.token,
70
+ allowed_workdirs: f.workdir || [os.homedir()],
71
+ };
72
+ if (f.poll) a.poll_interval_seconds = parseInt(f.poll, 10);
73
+ if (f.feishu) a.default_feishu_webhook = f.feishu;
74
+ if (f.model) a.model = f.model;
75
+ return a;
76
+ }
77
+
78
+ // 读出来永远按多账号形态回写,避免 CLI 还要处理扁平/嵌套两种写法
79
+ function loadForEdit() {
80
+ const raw = loadRaw() || {};
81
+ const conf = normalize(raw);
82
+ return { raw, accounts: conf.accounts };
83
+ }
84
+
85
+ function writeAccounts(raw, accounts, maxConcurrent) {
86
+ // 顶层的公共默认值(api_url/poll/model/...)normalize 时已经并进各 account,
87
+ // 不再重复写回顶层;其余顶层字段是用户自己加的,原样留着。
88
+ const out = { ...raw };
89
+ for (const k of INHERITED) delete out[k];
90
+ delete out.name; delete out.runner_token; delete out.runner_id; // 旧扁平配置的残留
91
+ out.max_concurrent_jobs = maxConcurrent ?? raw.max_concurrent_jobs ?? 1;
92
+ out.accounts = accounts;
93
+ saveRaw(out);
94
+ }
95
+
55
96
  switch (cmd) {
56
97
  case 'init': {
57
98
  const f = parseFlags(rest, ['workdir']);
58
99
  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}`);
100
+ const existing = loadRaw();
101
+ if (existing && !f.force) {
102
+ const n = normalize(existing).accounts.length;
103
+ if (n > 1) {
104
+ console.error(`已有 ${n} 个账号的配置,init 会全部覆盖。想再加一个用 cc-runner add,确实要重置加 --force`);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ const acct = accountFromFlags(f, 'default');
109
+ acct.claude_args = ['--dangerously-skip-permissions'];
110
+ writeAccounts({}, [acct], 1);
111
+ console.log(`已写入 ${CONFIG_PATH}(账号 ${acct.name})`);
73
112
  console.log('下一步: cc-runner install(常驻)或 cc-runner start(前台调试)');
74
113
  break;
75
114
  }
76
115
 
116
+ case 'add': {
117
+ const f = parseFlags(rest, ['workdir']);
118
+ if (!f.api || !f.token || !f.name) { console.error('add 需要 --name、--api 和 --token'); process.exit(1); }
119
+ const { raw, accounts } = loadForEdit();
120
+ if (accounts.some((a) => a.name === f.name)) {
121
+ console.error(`账号 ${f.name} 已存在(名字决定数据目录,必须唯一)。先 remove 或换个名字`);
122
+ process.exit(1);
123
+ }
124
+ const acct = accountFromFlags(f);
125
+ acct.claude_args = ['--dangerously-skip-permissions'];
126
+ accounts.push(acct);
127
+ writeAccounts(raw, accounts);
128
+ console.log(`已添加账号 ${acct.name},白名单: ${acct.allowed_workdirs.join(', ')}`);
129
+ console.log('重启守护进程生效: cc-runner install(会先卸载再挂载)');
130
+ break;
131
+ }
132
+
133
+ case 'remove': {
134
+ const f = parseFlags(rest);
135
+ if (!f.name) { console.error('remove 需要 --name'); process.exit(1); }
136
+ const { raw, accounts } = loadForEdit();
137
+ const left = accounts.filter((a) => a.name !== f.name);
138
+ if (left.length === accounts.length) { console.error(`没有叫 ${f.name} 的账号`); process.exit(1); }
139
+ writeAccounts(raw, left);
140
+ console.log(`已摘掉账号 ${f.name}`);
141
+ if (f.purge) {
142
+ fs.rmSync(dataDirFor(f.name), { recursive: true, force: true });
143
+ console.log(`数据目录已删除: ${dataDirFor(f.name)}`);
144
+ } else {
145
+ console.log(`数据目录保留在 ${dataDirFor(f.name)}(要删加 --purge)`);
146
+ }
147
+ break;
148
+ }
149
+
150
+ case 'list': {
151
+ requireConfig();
152
+ const conf = normalize(loadRaw() || {});
153
+ const accounts = conf.accounts;
154
+ console.log(`并发上限: ${conf.maxConcurrent}`);
155
+ for (const a of accounts) {
156
+ const wd = (a.allowed_workdirs || []).join(', ') || '(空 — 所有任务都会被拒)';
157
+ console.log(`- ${a.name} ${a.api_url}`);
158
+ console.log(` token: ...${String(a.runner_token || '').slice(-6)} poll: ${a.poll_interval_seconds || 30}s`);
159
+ console.log(` workdirs: ${wd}`);
160
+ }
161
+ const problems = validate(conf);
162
+ for (const p of problems) console.log(` ! ${p}`);
163
+ break;
164
+ }
165
+
77
166
  case 'start': {
78
167
  requireConfig();
79
168
  require(path.join(__dirname, '..', 'runner.js'));
@@ -126,16 +215,21 @@ switch (cmd) {
126
215
  let loaded = false;
127
216
  try { loaded = execSync('launchctl list').toString().includes(PLIST_LABEL); } catch (_) { /* 非 macOS */ }
128
217
  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} 条`);
218
+ const { accounts } = loadForEdit();
219
+ for (const a of accounts) {
220
+ const dir = dataDirFor(a.name);
221
+ console.log(`\n账号 ${a.name} (${dir})`);
222
+ try {
223
+ const st = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
224
+ console.log(` 同步版本: ${st.schedule_version},定时任务数: ${(st.schedules || []).length}`);
225
+ const done = Object.entries(st.done || {}).sort((x, y) => (x[1].at < y[1].at ? 1 : -1)).slice(0, 5);
226
+ for (const [k, v] of done) console.log(` 最近执行: ${k} ${v.status} ${v.at}`);
227
+ } catch (_) { console.log(' 还没有本地状态(从未成功同步/执行)'); }
228
+ const outbox = path.join(dir, 'outbox');
229
+ if (fs.existsSync(outbox)) {
230
+ const n = fs.readdirSync(outbox).length;
231
+ if (n) console.log(` 待补传上报: ${n} 条`);
232
+ }
139
233
  }
140
234
  break;
141
235
  }
package/config.js ADDED
@@ -0,0 +1,96 @@
1
+ // 配置加载与规范化。runner.js 和 bin/cli.js 共用,保证两边对"账号"的理解一致。
2
+ //
3
+ // 配置有两种形态,都能读:
4
+ // 1) 多账号(推荐):{ max_concurrent_jobs, accounts: [{ name, api_url, runner_token, ... }] }
5
+ // 2) 单账号扁平(旧版):{ api_url, runner_token, ... } —— 自动包成 name 为 default 的单账号
6
+ // 顶层的公共字段(poll_interval_seconds / claude_bin / model / ...)是所有账号的默认值,
7
+ // account 里写同名字段即覆盖。
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const HOME_DIR = process.env.CC_RUNNER_HOME || path.join(os.homedir(), '.cc-runner');
16
+ const CONFIG_PATH = process.env.CC_RUNNER_CONFIG || path.join(HOME_DIR, 'config.json');
17
+ const DATA_ROOT = path.join(HOME_DIR, 'data');
18
+
19
+ // 云端下发的 workdir 常含字面量 ~(平台 UI 里用户自然会填 ~/xxx),path.resolve 不认
20
+ // ~,会拼成 <cwd>/~/xxx。这里统一把开头的 ~ 展开成 home,再交给 resolve/白名单/spawn。
21
+ function expandTilde(p) {
22
+ if (typeof p !== 'string' || !p) return p;
23
+ if (p === '~') return os.homedir();
24
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
25
+ return p;
26
+ }
27
+
28
+ // 账号名 -> 文件系统安全的目录名。账号名在配置里校验唯一,所以这个 key 也唯一。
29
+ function accountKey(name) {
30
+ return String(name || '').replace(/[^a-zA-Z0-9._-]/g, '_') || 'default';
31
+ }
32
+
33
+ function dataDirFor(name) {
34
+ return path.join(DATA_ROOT, accountKey(name));
35
+ }
36
+
37
+ // 顶层可写、account 可覆盖的字段
38
+ const INHERITED = [
39
+ 'api_url', 'poll_interval_seconds', 'claude_bin', 'claude_args', 'model',
40
+ 'default_feishu_webhook', 'job_pull_limit', 'allowed_workdirs', 'env',
41
+ ];
42
+
43
+ function loadRaw() {
44
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch (_) { return null; }
45
+ }
46
+
47
+ function saveRaw(raw) {
48
+ fs.mkdirSync(HOME_DIR, { recursive: true });
49
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2));
50
+ fs.chmodSync(CONFIG_PATH, 0o600); // 里面有 runner token
51
+ }
52
+
53
+ // raw config -> { maxConcurrent, accounts: [...] }
54
+ function normalize(raw) {
55
+ const maxConcurrent = Math.max(1, Number(raw.max_concurrent_jobs) || 1);
56
+ let accounts = [];
57
+ if (Array.isArray(raw.accounts) && raw.accounts.length) {
58
+ accounts = raw.accounts.map((a, i) => {
59
+ const inherited = {};
60
+ for (const k of INHERITED) if (raw[k] !== undefined) inherited[k] = raw[k];
61
+ return { name: `account${i + 1}`, ...inherited, ...a };
62
+ });
63
+ } else if (raw.api_url || raw.runner_token) {
64
+ const flat = { ...raw };
65
+ delete flat.accounts;
66
+ delete flat.max_concurrent_jobs;
67
+ accounts = [{ name: 'default', ...flat }];
68
+ }
69
+ return { maxConcurrent, accounts };
70
+ }
71
+
72
+ // 返回人类可读的问题列表,空数组 = 配置可用
73
+ function validate(conf) {
74
+ const problems = [];
75
+ if (!conf.accounts.length) {
76
+ problems.push('配置里没有任何账号,先跑: cc-runner init --api <runner-api地址> --token <X-Runner-Token>');
77
+ return problems;
78
+ }
79
+ const seen = new Set();
80
+ for (const a of conf.accounts) {
81
+ const label = a.name || '(未命名)';
82
+ if (!a.name) problems.push('有账号缺 name');
83
+ else if (seen.has(a.name)) problems.push(`账号名重复: ${a.name}(名字决定数据目录,必须唯一)`);
84
+ else seen.add(a.name);
85
+ for (const k of ['api_url', 'runner_token']) {
86
+ if (!a[k]) problems.push(`账号 ${label} 缺少 ${k}(旧版 cloud_url/service_key 已废弃)`);
87
+ }
88
+ }
89
+ return problems;
90
+ }
91
+
92
+ module.exports = {
93
+ HOME_DIR, CONFIG_PATH, DATA_ROOT, INHERITED,
94
+ expandTilde, accountKey, dataDirFor,
95
+ loadRaw, saveRaw, normalize, validate,
96
+ };
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@h2562961224/cc-runner",
3
- "version": "0.1.0",
4
- "description": "云端下发、本地执行的 Claude Code 任务守护进程(纯中心化 pull 模型,零依赖)",
3
+ "version": "0.2.0",
4
+ "description": "云端下发、本地执行的 Claude Code 任务守护进程(纯中心化 pull 模型,零依赖,支持一个进程挂多组 token/工作区)",
5
5
  "bin": {
6
6
  "cc-runner": "bin/cli.js"
7
7
  },
8
8
  "files": [
9
9
  "bin/cli.js",
10
+ "config.js",
10
11
  "runner.js",
11
12
  "README.md",
12
13
  "skills/"
package/runner.js CHANGED
@@ -6,6 +6,11 @@
6
6
  // 一律由服务器生成为待领队列,runner 先 /claim 领到才执行** —— 不在本地算 cron、
7
7
  // 不离线自跑。断网就是纯粹拉不到任务,恢复后继续领。
8
8
  //
9
+ // 多账号:一个进程可以同时挂多组 (token, workdir 白名单)。每个账号是一个独立实例 ——
10
+ // 独立轮询、独立队列、独立数据目录(~/.cc-runner/data/<账号名>/),互不串扰;
11
+ // 真正 spawn claude 时过一个**进程级并发闸**(max_concurrent_jobs,默认 1),
12
+ // 免得 N 个账号同时炸开把机器跑满。
13
+ //
9
14
  // 四个操作,全部 POST:
10
15
  // /heartbeat 每 30s 打卡,返回 { schedulesVersion }
11
16
  // /sync 版本变了拉本机定时任务定义(仅本地缓存供 status 展示,不驱动执行)
@@ -19,141 +24,59 @@
19
24
  'use strict';
20
25
 
21
26
  const fs = require('fs');
22
- const os = require('os');
23
27
  const path = require('path');
24
28
  const crypto = require('crypto');
25
29
  const { spawn } = require('child_process');
30
+ const {
31
+ CONFIG_PATH, DATA_ROOT, expandTilde, dataDirFor, loadRaw, normalize, validate,
32
+ } = require('./config');
26
33
 
27
34
  // ---------------------------------------------------------------------------
28
- // 配置与状态
35
+ // 配置
29
36
  // ---------------------------------------------------------------------------
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) {
37
+ const raw = loadRaw();
38
+ if (!raw) {
34
39
  console.error(`[fatal] 读不到配置 ${CONFIG_PATH},先跑: cc-runner init --api <runner-api地址> --token <X-Runner-Token>`);
35
40
  process.exit(1);
36
41
  }
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;
42
+ const conf = normalize(raw);
43
+ const problems = validate(conf);
44
+ if (problems.length) {
45
+ for (const p of problems) console.error(`[fatal] ${p}`);
46
+ process.exit(1);
112
47
  }
113
48
 
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
- }
49
+ function ts() { return new Date().toISOString(); }
121
50
 
122
51
  // ---------------------------------------------------------------------------
123
- // 领取任务队列(服务器原子认领,逐条领到空为止)。webhook / 手动 / 定时 都在这。
52
+ // 进程级并发闸:限制同时在跑的 claude 进程数,跨账号共享
124
53
  // ---------------------------------------------------------------------------
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;
54
+ function createSemaphore(limit) {
55
+ let active = 0;
56
+ const waiters = [];
57
+ return {
58
+ acquire() {
59
+ if (active < limit) { active++; return Promise.resolve(); }
60
+ return new Promise((resolve) => waiters.push(resolve));
61
+ },
62
+ release() {
63
+ const next = waiters.shift();
64
+ if (next) next(); // 名额直接转交,active 不变
65
+ else active--;
66
+ },
67
+ };
140
68
  }
69
+ const gate = createSemaphore(conf.maxConcurrent);
141
70
 
142
71
  // ---------------------------------------------------------------------------
143
- // 执行
72
+ // 执行 claude(纯函数,所有账号相关的东西从 ctx 传进来)
144
73
  // ---------------------------------------------------------------------------
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) {
74
+ function runClaude(item, runId, ctx) {
152
75
  return new Promise((resolve) => {
153
- const args = ['-p', item.prompt, '--output-format', 'json', ...CLAUDE_ARGS];
154
- if (MODEL) args.push('--model', MODEL);
76
+ const args = ['-p', item.prompt, '--output-format', 'json', ...ctx.claudeArgs];
77
+ if (ctx.model) args.push('--model', ctx.model);
155
78
  const timeoutMs = item.timeoutSeconds * 1000;
156
- const child = spawn(CLAUDE_BIN, args, { cwd: item.workdir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
79
+ const child = spawn(ctx.claudeBin, args, { cwd: item.workdir, env: ctx.env, stdio: ['ignore', 'pipe', 'pipe'] });
157
80
  let out = ''; let errOut = ''; let timedOut = false;
158
81
  const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
159
82
  child.stdout.on('data', (d) => { out += d; });
@@ -161,7 +84,7 @@ function runClaude(item, runId) {
161
84
  child.on('error', (e) => { clearTimeout(timer); resolve({ status: 'failed', error: `spawn 失败: ${e.message}` }); });
162
85
  child.on('close', (code) => {
163
86
  clearTimeout(timer);
164
- fs.writeFileSync(path.join(TRANSCRIPT_DIR, `${runId}.json`), out || errOut || '');
87
+ fs.writeFileSync(path.join(ctx.transcriptDir, `${runId}.json`), out || errOut || '');
165
88
  if (timedOut) return resolve({ status: 'timeout', error: `超时(${timeoutMs / 1000}s)强杀`, exitCode: code });
166
89
  let summary = '';
167
90
  try { summary = String(JSON.parse(out).result || '').slice(0, 4000); } catch (_) { summary = (out || '').slice(0, 4000); }
@@ -171,124 +94,275 @@ function runClaude(item, runId) {
171
94
  });
172
95
  }
173
96
 
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
97
  // ---------------------------------------------------------------------------
194
- // 上报(outbox:存可重放的 /report 调用;断线累积,恢复补传)
98
+ // 一个账号 = 一个 runner 实例
195
99
  // ---------------------------------------------------------------------------
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
- }
100
+ function createRunner(acct) {
101
+ const API_BASE = String(acct.api_url).replace(/\/$/, '');
102
+ const pollMs = (acct.poll_interval_seconds || 30) * 1000;
103
+ const jobPullLimit = acct.job_pull_limit || 20; // 每轮最多 /claim 多少条,防一次拉太多
104
+ const allowedWorkdirs = (acct.allowed_workdirs || []).map((p) => path.resolve(expandTilde(p)));
105
+ const ctx = {
106
+ claudeBin: acct.claude_bin || 'claude',
107
+ claudeArgs: acct.claude_args || ['--dangerously-skip-permissions'],
108
+ model: acct.model || '',
109
+ // account.env 可覆盖子进程环境(比如给不同 workspace 配不同的 Claude 凭证目录)
110
+ env: { ...process.env, ...(acct.env || {}) },
111
+ transcriptDir: path.join(dataDirFor(acct.name), 'transcripts'),
112
+ };
201
113
 
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; } // 旧格式/损坏,丢弃
114
+ let runnerId = acct.runner_id || acct.name; // 本地标签;首次 heartbeat 后用服务器返回的真实值覆盖
115
+ let orgName = '';
116
+
117
+ const dataDir = dataDirFor(acct.name);
118
+ const outboxDir = path.join(dataDir, 'outbox');
119
+ const statePath = path.join(dataDir, 'state.json');
120
+ for (const d of [dataDir, outboxDir, ctx.transcriptDir]) fs.mkdirSync(d, { recursive: true });
121
+
122
+ // state = { schedule_version, schedules: [](仅缓存展示), done: {"<dedup_key>": {status, at}}, outbox_seq }
123
+ let state = { schedule_version: -1, schedules: [], done: {}, outbox_seq: 0 };
124
+ try { state = { ...state, ...JSON.parse(fs.readFileSync(statePath, 'utf8')) }; } catch (_) { /* 首次运行 */ }
125
+
126
+ function saveState() {
127
+ const keys = Object.keys(state.done);
128
+ if (keys.length > 5000) {
129
+ keys.sort((a, b) => (state.done[a].at < state.done[b].at ? -1 : 1));
130
+ for (const k of keys.slice(0, keys.length - 5000)) delete state.done[k];
131
+ }
132
+ fs.writeFileSync(statePath, JSON.stringify(state));
133
+ }
134
+
135
+ function log(msg) { console.log(`${ts()} [${acct.name}] ${msg}`); }
136
+
137
+ // -------------------------------------------------------------------------
138
+ // runner-api 调用(POST + X-Runner-Token)
139
+ // 抛出的 error 带 .retriable:网络错误/5xx 可重试;4xx/{error} 视为永久失败。
140
+ // -------------------------------------------------------------------------
141
+ async function api(op, body) {
142
+ let res;
208
143
  try {
209
- await api(op.op, op.body);
210
- fs.unlinkSync(p);
144
+ res = await fetch(`${API_BASE}/${op}`, {
145
+ method: 'POST',
146
+ headers: { 'X-Runner-Token': acct.runner_token, 'Content-Type': 'application/json' },
147
+ body: JSON.stringify(body || {}),
148
+ signal: AbortSignal.timeout(20000),
149
+ });
211
150
  } catch (e) {
212
- if (e.retriable) { log(`[outbox] ${f} 稍后重试: ${e.message}`); return; } // 网络/5xx:留待下轮
213
- log(`[outbox] ${f} 永久失败,丢弃: ${e.message}`); fs.unlinkSync(p); // 4xx(如 job 已被接管)
151
+ const err = new Error(`${op} 网络错误: ${e.message}`); err.retriable = true; throw err;
152
+ }
153
+ const text = await res.text();
154
+ let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { /* 非 JSON */ }
155
+ if (!res.ok || (data && data.error)) {
156
+ const msg = (data && data.error) ? data.error : `${res.status} ${text.slice(0, 200)}`;
157
+ const err = new Error(`${op} -> ${msg}`); err.retriable = res.status >= 500; throw err;
214
158
  }
159
+ return data;
215
160
  }
216
- }
217
161
 
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
- }
162
+ // 心跳拿版本 + 定时任务定义同步(仅缓存,供 status 展示;执行一律走 /claim)
163
+ async function heartbeat() {
164
+ const r = await api('heartbeat');
165
+ if (r && r.runner) runnerId = r.runner;
166
+ if (r && r.orgName) orgName = r.orgName;
167
+ return r && r.schedulesVersion != null ? Number(r.schedulesVersion) : null;
168
+ }
224
169
 
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);
170
+ async function syncSchedules() {
171
+ const r = await api('sync');
172
+ state.schedules = Array.isArray(r && r.schedules) ? r.schedules : [];
173
+ if (r && r.version != null) state.schedule_version = Number(r.version);
174
+ saveState();
175
+ log(`[sync] 定时任务版本 ${state.schedule_version},清单 ${state.schedules.length} 条(仅缓存,执行走 /claim)`);
237
176
  }
238
- }
239
177
 
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);
178
+ // 领取任务队列(服务器原子认领,逐条领到空为止)。webhook / 手动 / 定时 都在这。
179
+ async function claimJobs() {
180
+ const items = [];
181
+ for (let i = 0; i < jobPullLimit; i++) {
182
+ const r = await api('claim');
183
+ const job = r && r.job;
184
+ if (!job) break; // 队列空
185
+ items.push({
186
+ dedupKey: job.dedup_key || `job:${job.id}`, source: job.source || 'webhook', jobId: job.id,
187
+ scheduledAt: job.scheduled_at || job.created_at,
188
+ name: job.source === 'webhook' ? `webhook:${job.trigger_id || ''}` : (job.source || 'job'),
189
+ prompt: job.prompt, workdir: expandTilde(job.workdir),
190
+ timeoutSeconds: job.timeout_seconds || 900, output: job.output || {},
191
+ });
257
192
  }
193
+ return items;
194
+ }
258
195
 
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,
196
+ // 白名单由本地说了算:云端下发的 workdir 不在白名单内直接拒。多账号下这是账号之间
197
+ // 唯一的隔离边界,所以白名单必须挂在账号上,不能全局共用。
198
+ function workdirAllowed(dir) {
199
+ if (!allowedWorkdirs.length) return false; // 白名单空 = 全拒,安全兜底本地说了算
200
+ const r = path.resolve(dir || '');
201
+ return allowedWorkdirs.some((w) => r === w || r.startsWith(w + path.sep));
202
+ }
203
+
204
+ async function notifyFeishu(item, run) {
205
+ const url = (item.output && item.output.feishu_webhook) || acct.default_feishu_webhook;
206
+ if (!url) return;
207
+ const icon = { success: '✅', failed: '❌', timeout: '⏱️', rejected: '🚫' }[run.status] || '•';
208
+ const body = {
209
+ msg_type: 'interactive',
210
+ card: {
211
+ header: { title: { tag: 'plain_text', content: `${icon} [${run.status}] ${item.name}` },
212
+ template: run.status === 'success' ? 'green' : 'red' },
213
+ elements: [{ tag: 'markdown',
214
+ content: `${(run.summary || run.error || '(无输出)').slice(0, 2000)}\n<font color="grey">${orgName ? `${orgName} · ` : ''}runner ${runnerId} · ${item.source} · ${run.scheduled_at}</font>` }],
215
+ },
263
216
  };
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);
217
+ try {
218
+ await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify(body), signal: AbortSignal.timeout(10000) });
220
+ } catch (e) { log(`[feishu] ${e.message}`); }
270
221
  }
271
- working = false;
272
- }
273
222
 
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}`); }
223
+ // 上报(outbox:存可重放的 /report 调用;断线累积,恢复补传)
224
+ function enqueueOp(op) {
225
+ const seq = String(state.outbox_seq++).padStart(9, '0');
226
+ fs.writeFileSync(path.join(outboxDir, `${seq}.json`), JSON.stringify(op));
227
+ saveState();
284
228
  }
285
- // 唯一执行入口:领取待办队列(webhook / 手动 / 定时都在服务器侧生成为 job)
286
- if (online) {
287
- try { enqueue(await claimJobs()); } catch (e) { log(`[claim] ${e.message}`); }
229
+
230
+ async function flushOutbox() {
231
+ for (const f of fs.readdirSync(outboxDir).sort()) {
232
+ const p = path.join(outboxDir, f);
233
+ let op;
234
+ try { op = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { fs.unlinkSync(p); continue; }
235
+ if (!op || !op.op) { fs.unlinkSync(p); continue; } // 旧格式/损坏,丢弃
236
+ try {
237
+ await api(op.op, op.body);
238
+ fs.unlinkSync(p);
239
+ } catch (e) {
240
+ if (e.retriable) { log(`[outbox] ${f} 稍后重试: ${e.message}`); return; } // 网络/5xx:留待下轮
241
+ log(`[outbox] ${f} 永久失败,丢弃: ${e.message}`); fs.unlinkSync(p); // 4xx(如 job 已被接管)
242
+ }
243
+ }
288
244
  }
289
- processQueue();
245
+
246
+ function reportRun(item, run) {
247
+ enqueueOp({ op: 'report', body: {
248
+ jobId: item.jobId, status: run.status, exitCode: run.exit_code ?? null,
249
+ summary: run.summary, error: run.error, startedAt: run.started_at, finishedAt: run.finished_at,
250
+ } });
251
+ }
252
+
253
+ // 串行执行队列(账号内串行;跨账号再过一层 gate)
254
+ const queue = [];
255
+ const enqueued = new Set(); // 本实例内已排队的 dedupKey,防同一 tick 重复入队
256
+ let working = false;
257
+ let timer = null;
258
+ let stopped = false;
259
+
260
+ function enqueue(items) {
261
+ for (const it of items) {
262
+ if (state.done[it.dedupKey] || enqueued.has(it.dedupKey)) continue;
263
+ enqueued.add(it.dedupKey);
264
+ queue.push(it);
265
+ }
266
+ }
267
+
268
+ async function processQueue() {
269
+ if (working) return;
270
+ working = true;
271
+ while (queue.length) {
272
+ const item = queue.shift();
273
+ const key = item.dedupKey;
274
+ if (state.done[key]) { enqueued.delete(key); continue; }
275
+
276
+ const runId = crypto.randomUUID();
277
+ const startedAt = new Date().toISOString();
278
+ log(`[run] ${item.name}(${item.source})sched=${item.scheduledAt}`);
279
+
280
+ let result;
281
+ if (!workdirAllowed(item.workdir)) {
282
+ result = { status: 'rejected', error: `workdir 不在本账号白名单: ${item.workdir}` };
283
+ } else {
284
+ await gate.acquire();
285
+ try { result = await runClaude(item, runId, ctx); } finally { gate.release(); }
286
+ }
287
+
288
+ const run = {
289
+ scheduled_at: item.scheduledAt, started_at: startedAt, finished_at: new Date().toISOString(),
290
+ status: result.status, exit_code: result.exitCode ?? null,
291
+ summary: result.summary || null, error: result.error || null,
292
+ };
293
+ state.done[key] = { status: result.status, at: run.finished_at }; saveState();
294
+ enqueued.delete(key);
295
+ log(`[run] ${item.name} -> ${result.status}`);
296
+ reportRun(item, run);
297
+ await flushOutbox();
298
+ await notifyFeishu(item, run);
299
+ }
300
+ working = false;
301
+ }
302
+
303
+ async function tick() {
304
+ if (stopped) return;
305
+ let online = true;
306
+ // 先补传上一轮攒下的上报
307
+ try { await flushOutbox(); } catch (e) { log(`[tick/outbox] ${e.message}`); }
308
+ // 心跳拿版本
309
+ let remoteVersion = null;
310
+ try { remoteVersion = await heartbeat(); } catch (e) { online = false; log(`[heartbeat] ${e.message}`); }
311
+ // 版本变了才拉全量定时任务定义(仅刷新本地缓存,不驱动执行)
312
+ if (online && remoteVersion !== null && remoteVersion !== state.schedule_version) {
313
+ try { await syncSchedules(); } catch (e) { log(`[sync] ${e.message}`); }
314
+ }
315
+ // 唯一执行入口:领取待办队列(webhook / 手动 / 定时都在服务器侧生成为 job)
316
+ if (online) {
317
+ try { enqueue(await claimJobs()); } catch (e) { log(`[claim] ${e.message}`); }
318
+ }
319
+ processQueue();
320
+ }
321
+
322
+ return {
323
+ name: acct.name,
324
+ start() {
325
+ log(`启动 api=${API_BASE} poll=${pollMs / 1000}s workdirs=${allowedWorkdirs.length || '(空,全拒)'}`);
326
+ tick();
327
+ timer = setInterval(tick, pollMs);
328
+ },
329
+ stop() { stopped = true; if (timer) clearInterval(timer); },
330
+ };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // 旧版单账号的数据目录(data/state.json + data/outbox)并进 data/<账号名>/
335
+ // 只在配置刚好只有一个账号时做,免得猜错归属。
336
+ // ---------------------------------------------------------------------------
337
+ function migrateLegacyData(name) {
338
+ const legacyState = path.join(DATA_ROOT, 'state.json');
339
+ if (!fs.existsSync(legacyState)) return;
340
+ const dst = dataDirFor(name);
341
+ if (fs.existsSync(path.join(dst, 'state.json'))) return; // 已迁过
342
+ for (const sub of ['outbox', 'transcripts']) fs.mkdirSync(path.join(dst, sub), { recursive: true });
343
+ fs.renameSync(legacyState, path.join(dst, 'state.json'));
344
+ for (const sub of ['outbox', 'transcripts']) {
345
+ const from = path.join(DATA_ROOT, sub);
346
+ if (!fs.existsSync(from)) continue;
347
+ for (const f of fs.readdirSync(from)) {
348
+ try { fs.renameSync(path.join(from, f), path.join(dst, sub, f)); } catch (_) { /* 跳过 */ }
349
+ }
350
+ try { fs.rmdirSync(from); } catch (_) { /* 非空就留着 */ }
351
+ }
352
+ console.log(`${ts()} [migrate] 旧数据目录已并入账号 ${name}`);
290
353
  }
291
354
 
292
- log(`[cc-runner] 启动 runner_id=${runnerId} poll=${POLL_MS / 1000}s(纯中心化,任务一律 /claim 领取)`);
293
- tick();
294
- setInterval(tick, POLL_MS);
355
+ // ---------------------------------------------------------------------------
356
+ // 起进程:每个账号一个实例
357
+ // ---------------------------------------------------------------------------
358
+ if (conf.accounts.length === 1) migrateLegacyData(conf.accounts[0].name);
359
+
360
+ const runners = conf.accounts.map(createRunner);
361
+ console.log(`${ts()} [cc-runner] 启动 ${runners.length} 个账号: ${runners.map((r) => r.name).join(', ')}`
362
+ + `(并发上限 ${conf.maxConcurrent},纯中心化,任务一律 /claim 领取)`);
363
+ // 错开首轮,免得多账号同刻齐发
364
+ runners.forEach((r, i) => (i === 0 ? r.start() : setTimeout(() => r.start(), i * 1000)));
365
+
366
+ for (const sig of ['SIGTERM', 'SIGINT']) {
367
+ process.on(sig, () => { for (const r of runners) r.stop(); process.exit(0); });
368
+ }
@@ -94,6 +94,18 @@ cc-runner status # 看是否已同步、launchd 是否挂载
94
94
 
95
95
  这台机器的 runner 身份由 token 决定(不再手填 `--runner`)。首次心跳后,它会自动出现在控制台「看板」。想在多台机器跑,就在控制台各建一个 runner、拿各自的 token,在每台机器 `init`。
96
96
 
97
+ **同一台机器要接多个 workspace**(不同组织、公私分开)时,不用起多个进程 —— `cc-runner add` 再挂一组 token 和白名单即可:
98
+
99
+ ```bash
100
+ cc-runner add --name <账号名> --api <该 workspace 的 runner-api 地址> --token <该 workspace 的 token> --workdir ~/对应项目目录
101
+ cc-runner list # 看所有账号
102
+ cc-runner remove --name <账号名> # 摘掉(加 --purge 连数据一起删)
103
+ ```
104
+
105
+ 每个账号独立轮询、独立队列、独立数据目录(`~/.cc-runner/data/<账号名>/`),**白名单按账号各管各的** —— 这是账号之间唯一的隔离边界,加 workdir 时同样要和用户确认。跨账号的 claude 并发受 config 顶层 `max_concurrent_jobs` 限制(默认 1,串行)。改完配置要重启守护进程才生效(`cc-runner install` 会先卸载再挂载)。
106
+
107
+ > 注意:多 token 隔离的是**平台侧身份**,不隔离本地 Claude 登录态 —— 所有任务都由同一份 `claude` 凭证执行。
108
+
97
109
  验证心跳到了云端:
98
110
 
99
111
  ```bash
@@ -209,7 +221,7 @@ tail -f ~/.cc-runner/logs/daemon.log # 守护进程实时日志
209
221
  ## 运维与排查
210
222
 
211
223
  - **runner 显示离线**(看板灰点 / `last_seen_at` 很旧):`cc-runner status` 看 launchd 是否挂载;`tail ~/.cc-runner/logs/daemon.log` 看报错。断网期间领不到新任务(纯中心化,不本地自跑);已跑完待回填的结果攒在 outbox,恢复后自动补传。
212
- - **任务 `rejected`**:workdir 不在本地白名单。要么改触发器/定时的 `workdir`,要么 `cc-runner init` 时把目录加进 `--workdir`(可重复传多个)。
224
+ - **任务 `rejected`**:workdir 不在**该账号**的本地白名单。要么改触发器/定时的 `workdir`,要么在 `cc-runner init` / `add` 时把目录加进 `--workdir`(可重复传多个);`cc-runner list` 能看到每个账号当前的白名单。
213
225
  - **webhook 打过去没反应**:确认地址带对了 `?token=<secret>`;`secret` 不匹配返回 401,`slug` 不存在返回 404。
214
226
  - **卸载**:`cc-runner uninstall`(配置和数据保留在 `~/.cc-runner`,需要再手删)。
215
227