claude-pool 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 +21 -0
- package/README.md +179 -2
- package/bin/cli.js +340 -0
- package/package.json +36 -4
- package/src/config.js +150 -0
- package/src/metrics.js +160 -0
- package/src/quota.js +104 -0
- package/src/runner.js +132 -0
- package/src/sessionSync.js +79 -0
- package/src/setup.js +187 -0
- package/src/ui.js +128 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.claude-pool');
|
|
6
|
+
export const CONFIG_FILE = path.join(DEFAULT_CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export function getDefaultAccounts() {
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
const candidates = [
|
|
11
|
+
{ name: 'primary', configDir: path.join(home, '.claude'), priority: 1 },
|
|
12
|
+
{ name: 'account4', configDir: path.join(home, '.claude-account4'), priority: 2 },
|
|
13
|
+
{ name: 'account2', configDir: path.join(home, '.claude-account2'), priority: 3 },
|
|
14
|
+
{ name: 'bedrock', configDir: path.join(home, '.claude-bedrock'), priority: 4 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const detected = [];
|
|
18
|
+
for (const c of candidates) {
|
|
19
|
+
if (fs.existsSync(c.configDir)) {
|
|
20
|
+
detected.push({
|
|
21
|
+
...c,
|
|
22
|
+
enabled: true,
|
|
23
|
+
email: null,
|
|
24
|
+
orgName: null,
|
|
25
|
+
subscriptionType: null,
|
|
26
|
+
rateLimitedUntil: null,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (detected.length === 0) {
|
|
32
|
+
detected.push({
|
|
33
|
+
name: 'primary',
|
|
34
|
+
configDir: path.join(home, '.claude'),
|
|
35
|
+
priority: 1,
|
|
36
|
+
enabled: true,
|
|
37
|
+
email: null,
|
|
38
|
+
orgName: null,
|
|
39
|
+
subscriptionType: null,
|
|
40
|
+
rateLimitedUntil: null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return detected;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ConfigManager {
|
|
48
|
+
constructor(customConfigFile = null) {
|
|
49
|
+
this.configFile = customConfigFile || CONFIG_FILE;
|
|
50
|
+
this.configDir = path.dirname(this.configFile);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ensureConfigDir() {
|
|
54
|
+
if (!fs.existsSync(this.configDir)) {
|
|
55
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
loadAccounts() {
|
|
60
|
+
if (!fs.existsSync(this.configFile)) {
|
|
61
|
+
const defaults = getDefaultAccounts();
|
|
62
|
+
this.saveAccounts(defaults);
|
|
63
|
+
return defaults;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(fs.readFileSync(this.configFile, 'utf8'));
|
|
68
|
+
const accounts = (raw.accounts || []).map(a => ({
|
|
69
|
+
name: a.name,
|
|
70
|
+
configDir: a.configDir || a.config_dir,
|
|
71
|
+
priority: Number(a.priority || 99),
|
|
72
|
+
enabled: a.enabled !== false,
|
|
73
|
+
email: a.email || null,
|
|
74
|
+
orgName: a.orgName || a.org_name || null,
|
|
75
|
+
subscriptionType: a.subscriptionType || a.subscription_type || null,
|
|
76
|
+
rateLimitedUntil: a.rateLimitedUntil || a.rate_limited_until || null,
|
|
77
|
+
quota: null,
|
|
78
|
+
}));
|
|
79
|
+
accounts.sort((a, b) => a.priority - b.priority);
|
|
80
|
+
return accounts;
|
|
81
|
+
} catch {
|
|
82
|
+
return getDefaultAccounts();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
saveAccounts(accounts) {
|
|
87
|
+
this.ensureConfigDir();
|
|
88
|
+
accounts.sort((a, b) => a.priority - b.priority);
|
|
89
|
+
const data = {
|
|
90
|
+
version: '1.0',
|
|
91
|
+
accounts: accounts.map(a => ({
|
|
92
|
+
name: a.name,
|
|
93
|
+
config_dir: a.configDir,
|
|
94
|
+
priority: a.priority,
|
|
95
|
+
enabled: a.enabled,
|
|
96
|
+
email: a.email,
|
|
97
|
+
org_name: a.orgName,
|
|
98
|
+
subscription_type: a.subscriptionType,
|
|
99
|
+
rate_limited_until: a.rateLimitedUntil,
|
|
100
|
+
})),
|
|
101
|
+
settings: {
|
|
102
|
+
cooldown_minutes: 60,
|
|
103
|
+
auto_switch: true,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
fs.writeFileSync(this.configFile, JSON.stringify(data, null, 2), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
updateAccountPriority(accountName, newPriority) {
|
|
110
|
+
const accounts = this.loadAccounts();
|
|
111
|
+
const target = accounts.find(a => a.name.toLowerCase() === accountName.toLowerCase());
|
|
112
|
+
if (!target) return false;
|
|
113
|
+
|
|
114
|
+
const others = accounts.filter(a => a.name.toLowerCase() !== accountName.toLowerCase());
|
|
115
|
+
const insertIdx = Math.max(0, Math.min(newPriority - 1, others.length));
|
|
116
|
+
others.splice(insertIdx, 0, target);
|
|
117
|
+
|
|
118
|
+
others.forEach((a, idx) => {
|
|
119
|
+
a.priority = idx + 1;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.saveAccounts(others);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
reorderPriorities(accountNames) {
|
|
127
|
+
const accounts = this.loadAccounts();
|
|
128
|
+
const map = new Map(accounts.map(a => [a.name.toLowerCase(), a]));
|
|
129
|
+
const newOrder = [];
|
|
130
|
+
|
|
131
|
+
for (const name of accountNames) {
|
|
132
|
+
const key = name.trim().toLowerCase();
|
|
133
|
+
if (map.has(key)) {
|
|
134
|
+
newOrder.push(map.get(key));
|
|
135
|
+
map.delete(key);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const remaining of map.values()) {
|
|
140
|
+
newOrder.push(remaining);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
newOrder.forEach((a, idx) => {
|
|
144
|
+
a.priority = idx + 1;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.saveAccounts(newOrder);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export class MetricsEngine {
|
|
5
|
+
static readAccountStats(account) {
|
|
6
|
+
const stats = {
|
|
7
|
+
accountName: account.name,
|
|
8
|
+
todayInputTokens: 0,
|
|
9
|
+
todayOutputTokens: 0,
|
|
10
|
+
todayCacheReadTokens: 0,
|
|
11
|
+
todayCacheCreationTokens: 0,
|
|
12
|
+
todayTotalTokens: 0,
|
|
13
|
+
todayTokensByModel: {},
|
|
14
|
+
todayModelsDetail: {},
|
|
15
|
+
lifetimeModels: {},
|
|
16
|
+
lifetimeTotalInput: 0,
|
|
17
|
+
lifetimeTotalOutput: 0,
|
|
18
|
+
lifetimeTotalCache: 0,
|
|
19
|
+
lifetimeTotalTokens: 0,
|
|
20
|
+
totalSessions: 0,
|
|
21
|
+
totalMessages: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const configPath = account.configDir;
|
|
25
|
+
|
|
26
|
+
// 1. Base lifetime metrics from stats-cache.json
|
|
27
|
+
const statsFile = path.join(configPath, 'stats-cache.json');
|
|
28
|
+
if (fs.existsSync(statsFile)) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
31
|
+
stats.totalSessions = raw.totalSessions || 0;
|
|
32
|
+
stats.totalMessages = raw.totalMessages || 0;
|
|
33
|
+
|
|
34
|
+
const rawModels = raw.modelUsage || {};
|
|
35
|
+
for (const [mName, u] of Object.entries(rawModels)) {
|
|
36
|
+
const inp = u.inputTokens || 0;
|
|
37
|
+
const out = u.outputTokens || 0;
|
|
38
|
+
const cRead = u.cacheReadInputTokens || 0;
|
|
39
|
+
const cCreate = u.cacheCreationInputTokens || 0;
|
|
40
|
+
const tot = inp + out + cRead + cCreate;
|
|
41
|
+
|
|
42
|
+
stats.lifetimeModels[mName] = { input: inp, output: out, cacheRead: cRead, cacheCreate: cCreate, total: tot };
|
|
43
|
+
stats.lifetimeTotalInput += inp;
|
|
44
|
+
stats.lifetimeTotalOutput += out;
|
|
45
|
+
stats.lifetimeTotalCache += cRead + cCreate;
|
|
46
|
+
stats.lifetimeTotalTokens += tot;
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Real-time extraction from live project session .jsonl files for today
|
|
52
|
+
const projDir = path.join(configPath, 'projects');
|
|
53
|
+
if (!fs.existsSync(projDir)) return stats;
|
|
54
|
+
|
|
55
|
+
const todayUtc = new Date().toISOString().slice(0, 10);
|
|
56
|
+
const todayLocal = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
|
57
|
+
const cutoffMtime = Date.now() - 48 * 3600 * 1000;
|
|
58
|
+
|
|
59
|
+
let sessionCount = 0;
|
|
60
|
+
|
|
61
|
+
function walkDir(dir) {
|
|
62
|
+
try {
|
|
63
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
64
|
+
for (const ent of entries) {
|
|
65
|
+
const full = path.join(dir, ent.name);
|
|
66
|
+
if (ent.isDirectory() && ent.name !== 'subagents') {
|
|
67
|
+
walkDir(full);
|
|
68
|
+
} else if (ent.isFile() && ent.name.endsWith('.jsonl') && !full.includes('subagents')) {
|
|
69
|
+
sessionCount++;
|
|
70
|
+
try {
|
|
71
|
+
const st = fs.statSync(full);
|
|
72
|
+
if (st.mtimeMs < cutoffMtime) continue;
|
|
73
|
+
|
|
74
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
75
|
+
const lines = content.split('\n');
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (!line.includes('input_tokens')) continue;
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(line);
|
|
81
|
+
const ts = data.timestamp || '';
|
|
82
|
+
if (!ts.startsWith(todayUtc) && !ts.startsWith(todayLocal)) continue;
|
|
83
|
+
|
|
84
|
+
const msg = data.message || {};
|
|
85
|
+
const usage = msg.usage || {};
|
|
86
|
+
const model = msg.model || 'unknown';
|
|
87
|
+
|
|
88
|
+
const inp = usage.input_tokens || 0;
|
|
89
|
+
const out = usage.output_tokens || 0;
|
|
90
|
+
const cRead = usage.cache_read_input_tokens || 0;
|
|
91
|
+
const cCreate = usage.cache_creation_input_tokens || 0;
|
|
92
|
+
const cTotal = cRead + cCreate;
|
|
93
|
+
const turnTokens = inp + out + cTotal;
|
|
94
|
+
|
|
95
|
+
stats.todayInputTokens += inp;
|
|
96
|
+
stats.todayOutputTokens += out;
|
|
97
|
+
stats.todayCacheReadTokens += cRead;
|
|
98
|
+
stats.todayCacheCreationTokens += cCreate;
|
|
99
|
+
stats.todayTotalTokens += turnTokens;
|
|
100
|
+
|
|
101
|
+
stats.todayTokensByModel[model] = (stats.todayTokensByModel[model] || 0) + turnTokens;
|
|
102
|
+
|
|
103
|
+
if (!stats.todayModelsDetail[model]) {
|
|
104
|
+
stats.todayModelsDetail[model] = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, total: 0 };
|
|
105
|
+
}
|
|
106
|
+
const mDet = stats.todayModelsDetail[model];
|
|
107
|
+
mDet.input += inp;
|
|
108
|
+
mDet.output += out;
|
|
109
|
+
mDet.cacheRead += cRead;
|
|
110
|
+
mDet.cacheCreate += cCreate;
|
|
111
|
+
mDet.total += turnTokens;
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
walkDir(projDir);
|
|
121
|
+
|
|
122
|
+
if (stats.totalSessions === 0) {
|
|
123
|
+
stats.totalSessions = sessionCount;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return stats;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static getPoolStats(accounts) {
|
|
130
|
+
const pool = {
|
|
131
|
+
accounts: [],
|
|
132
|
+
todayPoolTokens: 0,
|
|
133
|
+
todayPoolInput: 0,
|
|
134
|
+
todayPoolOutput: 0,
|
|
135
|
+
todayPoolCache: 0,
|
|
136
|
+
lifetimePoolInput: 0,
|
|
137
|
+
lifetimePoolOutput: 0,
|
|
138
|
+
lifetimePoolCache: 0,
|
|
139
|
+
lifetimePoolTokens: 0,
|
|
140
|
+
totalPoolSessions: 0,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const acct of accounts) {
|
|
144
|
+
const acctStats = this.readAccountStats(acct);
|
|
145
|
+
pool.accounts.push(acctStats);
|
|
146
|
+
pool.todayPoolTokens += acctStats.todayTotalTokens;
|
|
147
|
+
pool.todayPoolInput += acctStats.todayInputTokens;
|
|
148
|
+
pool.todayPoolOutput += acctStats.todayOutputTokens;
|
|
149
|
+
pool.todayPoolCache += acctStats.todayCacheReadTokens + acctStats.todayCacheCreationTokens;
|
|
150
|
+
|
|
151
|
+
pool.lifetimePoolInput += acctStats.lifetimeTotalInput;
|
|
152
|
+
pool.lifetimePoolOutput += acctStats.lifetimeTotalOutput;
|
|
153
|
+
pool.lifetimePoolCache += acctStats.lifetimeTotalCache;
|
|
154
|
+
pool.lifetimePoolTokens += acctStats.lifetimeTotalTokens;
|
|
155
|
+
pool.totalPoolSessions += acctStats.totalSessions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return pool;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/quota.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const CACHE_FILE = path.join(os.homedir(), '.claude-pool', 'quota_cache.json');
|
|
7
|
+
|
|
8
|
+
export class QuotaEngine {
|
|
9
|
+
static loadCache() {
|
|
10
|
+
if (!fs.existsSync(CACHE_FILE)) return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static saveCache(data) {
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
|
|
21
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static fetchAccountQuota(account, forceRefresh = false) {
|
|
26
|
+
const cache = this.loadCache();
|
|
27
|
+
const cachedEntry = cache[account.name];
|
|
28
|
+
|
|
29
|
+
if (!forceRefresh && cachedEntry && cachedEntry.lastChecked) {
|
|
30
|
+
const ageSeconds = (Date.now() - cachedEntry.lastChecked) / 1000;
|
|
31
|
+
if (ageSeconds < 300) {
|
|
32
|
+
account.quota = cachedEntry;
|
|
33
|
+
return cachedEntry;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const env = { ...process.env };
|
|
38
|
+
const defaultClaudePath = path.resolve(path.join(os.homedir(), '.claude'));
|
|
39
|
+
if (path.resolve(account.configDir) !== defaultClaudePath) {
|
|
40
|
+
env.CLAUDE_CONFIG_DIR = account.configDir;
|
|
41
|
+
} else {
|
|
42
|
+
delete env.CLAUDE_CONFIG_DIR;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
46
|
+
delete env.ANTHROPIC_API_KEY;
|
|
47
|
+
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const res = spawnSync('claude', ['-p', '/usage'], {
|
|
51
|
+
input: '',
|
|
52
|
+
env,
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
timeout: 15000,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const output = (res.stdout || '') + '\n' + (res.stderr || '');
|
|
58
|
+
|
|
59
|
+
const mSession = output.match(/Current session:\s*(\d+)%[\s\S]*?resets\s*([^(\n\r]+)/i);
|
|
60
|
+
const mWeek = output.match(/Current week[\s\S]*?:\s*(\d+)%[\s\S]*?resets\s*([^(\n\r]+)/i);
|
|
61
|
+
|
|
62
|
+
let sessionUsed = null;
|
|
63
|
+
let sessionReset = null;
|
|
64
|
+
let weekUsed = null;
|
|
65
|
+
let weekReset = null;
|
|
66
|
+
|
|
67
|
+
if (mSession) {
|
|
68
|
+
sessionUsed = parseInt(mSession[1], 10);
|
|
69
|
+
sessionReset = mSession[2].trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mWeek) {
|
|
73
|
+
weekUsed = parseInt(mWeek[1], 10);
|
|
74
|
+
weekReset = mWeek[2].trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sessionUsed !== null || weekUsed !== null) {
|
|
78
|
+
const qInfo = {
|
|
79
|
+
sessionUsedPct: sessionUsed,
|
|
80
|
+
sessionRemainingPct: sessionUsed !== null ? Math.max(0, 100 - sessionUsed) : null,
|
|
81
|
+
sessionReset,
|
|
82
|
+
weekUsedPct: weekUsed,
|
|
83
|
+
weekRemainingPct: weekUsed !== null ? Math.max(0, 100 - weekUsed) : null,
|
|
84
|
+
weekReset,
|
|
85
|
+
lastChecked: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
account.quota = qInfo;
|
|
89
|
+
cache[account.name] = qInfo;
|
|
90
|
+
this.saveCache(cache);
|
|
91
|
+
return qInfo;
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static fetchAllQuotas(accounts, forceRefresh = false) {
|
|
99
|
+
for (const a of accounts) {
|
|
100
|
+
if (a.enabled === false) continue;
|
|
101
|
+
this.fetchAccountQuota(a, forceRefresh);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { SessionSyncEngine } from './sessionSync.js';
|
|
5
|
+
import { MetricsEngine } from './metrics.js';
|
|
6
|
+
import { QuotaEngine } from './quota.js';
|
|
7
|
+
import { renderSessionHeader, renderFailoverBanner, colors } from './ui.js';
|
|
8
|
+
|
|
9
|
+
export class ProcessRunner {
|
|
10
|
+
constructor(configManager) {
|
|
11
|
+
this.configManager = configManager;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
cleanEnvForAccount(account) {
|
|
15
|
+
const env = { ...process.env };
|
|
16
|
+
const defaultClaudePath = path.resolve(path.join(os.homedir(), '.claude'));
|
|
17
|
+
|
|
18
|
+
if (path.resolve(account.configDir) !== defaultClaudePath) {
|
|
19
|
+
env.CLAUDE_CONFIG_DIR = account.configDir;
|
|
20
|
+
} else {
|
|
21
|
+
delete env.CLAUDE_CONFIG_DIR;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Clear overrides
|
|
25
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
26
|
+
delete env.ANTHROPIC_API_KEY;
|
|
27
|
+
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
28
|
+
delete env.CLAUDE_CODE_USE_BEDROCK;
|
|
29
|
+
delete env.AWS_PROFILE;
|
|
30
|
+
delete env.AWS_REGION;
|
|
31
|
+
delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
32
|
+
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getReadyAccounts(accounts) {
|
|
37
|
+
const now = Date.now() / 1000;
|
|
38
|
+
return accounts
|
|
39
|
+
.filter(a => a.enabled !== false && (!a.rateLimitedUntil || a.rateLimitedUntil < now))
|
|
40
|
+
.sort((a, b) => a.priority - b.priority);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
runSession(extraArgs = [], initialAccountName = null) {
|
|
44
|
+
const accounts = this.configManager.loadAccounts();
|
|
45
|
+
|
|
46
|
+
let activeAccount = null;
|
|
47
|
+
if (initialAccountName) {
|
|
48
|
+
activeAccount = accounts.find(a => a.name.toLowerCase() === initialAccountName.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!activeAccount) {
|
|
52
|
+
const ready = this.getReadyAccounts(accounts);
|
|
53
|
+
activeAccount = ready[0] || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!activeAccount) {
|
|
57
|
+
console.log(`${colors.red}No ready accounts found in Claude Pool!${colors.reset}`);
|
|
58
|
+
console.log(`Run ${colors.indigo}claude-pool status${colors.reset} or ${colors.indigo}claude-pool setup${colors.reset} to configure accounts.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let resumeSessionId = null;
|
|
63
|
+
for (let i = 0; i < extraArgs.length; i++) {
|
|
64
|
+
if ((extraArgs[i] === '-r' || extraArgs[i] === '--resume') && i + 1 < extraArgs.length) {
|
|
65
|
+
resumeSessionId = extraArgs[i + 1];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
while (activeAccount) {
|
|
70
|
+
const readyAccounts = this.getReadyAccounts(accounts);
|
|
71
|
+
const standbyAccounts = readyAccounts.filter(a => a.name !== activeAccount.name);
|
|
72
|
+
const poolStats = MetricsEngine.getPoolStats(accounts);
|
|
73
|
+
|
|
74
|
+
// Fast check quota cache
|
|
75
|
+
QuotaEngine.fetchAllQuotas(accounts, false);
|
|
76
|
+
|
|
77
|
+
renderSessionHeader(activeAccount, standbyAccounts, poolStats);
|
|
78
|
+
|
|
79
|
+
const cmdArgs = [...extraArgs];
|
|
80
|
+
if (resumeSessionId && !cmdArgs.includes('-r') && !cmdArgs.includes('--resume')) {
|
|
81
|
+
cmdArgs.push('--resume', resumeSessionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const env = this.cleanEnvForAccount(activeAccount);
|
|
85
|
+
|
|
86
|
+
let exitCode = 0;
|
|
87
|
+
try {
|
|
88
|
+
const proc = spawnSync('claude', cmdArgs, {
|
|
89
|
+
env,
|
|
90
|
+
stdio: 'inherit', // Full terminal passthrough
|
|
91
|
+
});
|
|
92
|
+
exitCode = proc.status !== null ? proc.status : 1;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.log(`${colors.red}Error launching claude:${colors.reset} ${err.message}`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (exitCode === 0) {
|
|
99
|
+
console.log(`\n${colors.emerald}✓ Claude session finished normally.${colors.reset}`);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Non-zero exit code: check if rate limited and standby available
|
|
104
|
+
const latestId = SessionSyncEngine.getLatestSessionId(activeAccount);
|
|
105
|
+
if (latestId) {
|
|
106
|
+
resumeSessionId = latestId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (standbyAccounts.length > 0) {
|
|
110
|
+
const nextAccount = standbyAccounts[0];
|
|
111
|
+
|
|
112
|
+
// Apply 1-hour cooldown
|
|
113
|
+
activeAccount.rateLimitedUntil = Date.now() / 1000 + 3600;
|
|
114
|
+
this.configManager.saveAccounts(accounts);
|
|
115
|
+
|
|
116
|
+
let transferredId = null;
|
|
117
|
+
if (resumeSessionId) {
|
|
118
|
+
transferredId = SessionSyncEngine.transferSession(activeAccount, nextAccount, resumeSessionId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
renderFailoverBanner(activeAccount, nextAccount, transferredId || resumeSessionId);
|
|
122
|
+
activeAccount = nextAccount;
|
|
123
|
+
if (transferredId) {
|
|
124
|
+
resumeSessionId = transferredId;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
console.log(`\n${colors.amber}⚠️ All accounts in pool are currently exhausted or unavailable.${colors.reset}`);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function encodeProjectName(cwdPath = null) {
|
|
5
|
+
const p = path.resolve(cwdPath || process.cwd());
|
|
6
|
+
const parsed = path.parse(p);
|
|
7
|
+
|
|
8
|
+
if (parsed.root && parsed.root.includes(':')) {
|
|
9
|
+
// Windows: E:\PP\accs -> E--PP-accs
|
|
10
|
+
const drive = parsed.root.replace(':', '').replace(/[\/\\]/g, '');
|
|
11
|
+
const relativeParts = p.slice(parsed.root.length).split(/[\\/]/).filter(Boolean);
|
|
12
|
+
if (relativeParts.length === 0) return `${drive}--`;
|
|
13
|
+
return `${drive}--` + relativeParts.join('-');
|
|
14
|
+
} else {
|
|
15
|
+
// POSIX (macOS / Linux): /home/user/app -> -home-user-app
|
|
16
|
+
const parts = p.split('/').filter(Boolean);
|
|
17
|
+
return '-' + parts.join('-');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SessionSyncEngine {
|
|
22
|
+
static getLatestSessionId(account, cwdPath = null) {
|
|
23
|
+
const projName = encodeProjectName(cwdPath);
|
|
24
|
+
const projDir = path.join(account.configDir, 'projects', projName);
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(projDir)) return null;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const files = fs
|
|
30
|
+
.readdirSync(projDir)
|
|
31
|
+
.filter(f => f.endsWith('.jsonl') && !f.includes('subagents'))
|
|
32
|
+
.map(f => {
|
|
33
|
+
const fullPath = path.join(projDir, f);
|
|
34
|
+
return { name: f, stem: path.basename(f, '.jsonl'), mtime: fs.statSync(fullPath).mtimeMs };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (files.length === 0) return null;
|
|
38
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
39
|
+
return files[0].stem;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static transferSession(sourceAccount, targetAccount, sessionId = null, cwdPath = null) {
|
|
46
|
+
const projName = encodeProjectName(cwdPath);
|
|
47
|
+
const srcProjDir = path.join(sourceAccount.configDir, 'projects', projName);
|
|
48
|
+
const dstProjDir = path.join(targetAccount.configDir, 'projects', projName);
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(srcProjDir)) return null;
|
|
51
|
+
|
|
52
|
+
const actualSessionId = sessionId || this.getLatestSessionId(sourceAccount, cwdPath);
|
|
53
|
+
if (!actualSessionId) return null;
|
|
54
|
+
|
|
55
|
+
const srcSessionFile = path.join(srcProjDir, `${actualSessionId}.jsonl`);
|
|
56
|
+
if (!fs.existsSync(srcSessionFile)) return null;
|
|
57
|
+
|
|
58
|
+
fs.mkdirSync(dstProjDir, { recursive: true });
|
|
59
|
+
const dstSessionFile = path.join(dstProjDir, `${actualSessionId}.jsonl`);
|
|
60
|
+
|
|
61
|
+
// Copy session transcript
|
|
62
|
+
fs.copyFileSync(srcSessionFile, dstSessionFile);
|
|
63
|
+
|
|
64
|
+
// Copy sidecar folder if present (tool-results, subagents)
|
|
65
|
+
const srcSidecarDir = path.join(srcProjDir, actualSessionId);
|
|
66
|
+
if (fs.existsSync(srcSidecarDir) && fs.statSync(srcSidecarDir).isDirectory()) {
|
|
67
|
+
const dstSidecarDir = path.join(dstProjDir, actualSessionId);
|
|
68
|
+
fs.cpSync(srcSidecarDir, dstSidecarDir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Copy custom-title.json if present
|
|
72
|
+
const titleFile = path.join(srcProjDir, 'custom-title.json');
|
|
73
|
+
if (fs.existsSync(titleFile)) {
|
|
74
|
+
fs.copyFileSync(titleFile, path.join(dstProjDir, 'custom-title.json'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return actualSessionId;
|
|
78
|
+
}
|
|
79
|
+
}
|