bingocode 1.1.166 → 1.1.168
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/bin/bingo-win.cjs +215 -147
- package/package.json +4 -7
- package/src/entrypoints/tray-only.ts +151 -0
- package/src/manager/CliMenuManager.tsx +3 -20
- package/src/server/ensureSingletonLocalServer.ts +173 -182
- package/.env.example +0 -53
- package/src/manager/TokenStatsPanel.tsx +0 -260
package/bin/bingo-win.cjs
CHANGED
|
@@ -1,147 +1,215 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const { spawn, spawnSync } = require('node:child_process');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
|
|
8
|
-
process.env.NoDefaultCurrentDirectoryInExePath = '1';
|
|
9
|
-
|
|
10
|
-
// ── 首次部署:将默认 bingo 配置复制到 ~/.claude/bingo/ ──
|
|
11
|
-
/**
|
|
12
|
-
* 更加健壮的根目录定位:
|
|
13
|
-
* 1. 如果 preload.ts 在 ../ (当前 bin/ 目录下运行)
|
|
14
|
-
* 2. 否则查找同级及上级目录中的 package.json
|
|
15
|
-
*/
|
|
16
|
-
function getProjectRoot() {
|
|
17
|
-
let curr = __dirname;
|
|
18
|
-
try {
|
|
19
|
-
while (curr !== path.dirname(curr)) {
|
|
20
|
-
if (fs.existsSync(path.join(curr, 'preload.ts')) || fs.existsSync(path.join(curr, 'package.json'))) {
|
|
21
|
-
return curr;
|
|
22
|
-
}
|
|
23
|
-
const parent = path.dirname(curr);
|
|
24
|
-
if (fs.existsSync(path.join(parent, 'preload.ts'))) return parent;
|
|
25
|
-
curr = parent;
|
|
26
|
-
}
|
|
27
|
-
} catch (err) {
|
|
28
|
-
// 防止权限拒绝等导致挂死
|
|
29
|
-
}
|
|
30
|
-
return path.join(__dirname, '..');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const ROOT_DIR = getProjectRoot();
|
|
34
|
-
|
|
35
|
-
(function deployBingoDefaults() {
|
|
36
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
37
|
-
const bingoDir = path.join(configDir, 'bingo');
|
|
38
|
-
const targetSettings = path.join(bingoDir, 'settings.json');
|
|
39
|
-
|
|
40
|
-
// 只在 settings.json 不存在时才部署
|
|
41
|
-
if (!fs.existsSync(targetSettings)) {
|
|
42
|
-
const defaultsDir = path.join(ROOT_DIR, 'config', 'bingo-defaults');
|
|
43
|
-
const srcSettings = path.join(defaultsDir, 'settings.json');
|
|
44
|
-
|
|
45
|
-
if (fs.existsSync(srcSettings)) {
|
|
46
|
-
try {
|
|
47
|
-
if (!fs.existsSync(bingoDir)) {
|
|
48
|
-
fs.mkdirSync(bingoDir, { recursive: true });
|
|
49
|
-
}
|
|
50
|
-
fs.copyFileSync(srcSettings, targetSettings);
|
|
51
|
-
console.log('[bingo] 首次启动:已部署默认配置到', targetSettings);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.warn('[bingo] 部署默认配置失败:', err.message);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
})();
|
|
58
|
-
|
|
59
|
-
// 自动定位 bun.exe(纯文件系统查找,无子进程,无 DEP0190 警告)
|
|
60
|
-
function resolveBunExe() {
|
|
61
|
-
// 1. 用户指定路径
|
|
62
|
-
if (process.env.BUN_PATH && fs.existsSync(process.env.BUN_PATH)) {
|
|
63
|
-
return process.env.BUN_PATH;
|
|
64
|
-
}
|
|
65
|
-
const home = os.homedir();
|
|
66
|
-
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
67
|
-
const candidates = [
|
|
68
|
-
// npm install -g bun 的真实 exe(最常见)
|
|
69
|
-
path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
|
|
70
|
-
// bun 官方安装脚本位置
|
|
71
|
-
path.join(home, '.bun', 'bin', 'bun.exe'),
|
|
72
|
-
];
|
|
73
|
-
// 遍历 PATH 中每个目录查找 bun.exe
|
|
74
|
-
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
75
|
-
candidates.push(path.join(dir, 'bun.exe'));
|
|
76
|
-
}
|
|
77
|
-
for (const c of candidates) {
|
|
78
|
-
try { if (fs.existsSync(c)) return c; } catch (_) {}
|
|
79
|
-
}
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 检查 bun 是否可用
|
|
84
|
-
function bunExists() {
|
|
85
|
-
return resolveBunExe() !== null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 安装 bun(通过 npm install -g bun)
|
|
89
|
-
function installBun() {
|
|
90
|
-
console.log('[bingocode] bun 未检测到,正在通过 npm install -g bun 安装...');
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const npmResult = spawnSync(
|
|
94
|
-
'npm.cmd',
|
|
95
|
-
['install', '-g', 'bun', '--loglevel', 'error'],
|
|
96
|
-
{ stdio: 'inherit' }
|
|
97
|
-
);
|
|
98
|
-
if (npmResult.status !== 0) {
|
|
99
|
-
throw new Error(`npm install -g bun 失败,exit code ${npmResult.status}`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
console.log('[bingocode] bun 安装完成,正在启动...');
|
|
103
|
-
return true;
|
|
104
|
-
} catch (err) {
|
|
105
|
-
console.error(`[bingocode] bun 自动安装失败: ${err.message}`);
|
|
106
|
-
console.log('[bingocode] 请手动安装 bun: npm install -g bun');
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (!bunExists()) {
|
|
112
|
-
if (!installBun()) {
|
|
113
|
-
process.exit(1);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 安装完成后重新解析 bun 路径
|
|
118
|
-
const bunExe = resolveBunExe();
|
|
119
|
-
if (!bunExe) {
|
|
120
|
-
console.error('[bingocode] 安装后仍找不到 bun.exe,请重新打开终端后再试,或手动安装 bun: npm install -g bun');
|
|
121
|
-
process.exit(1);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Bingo Manager 入口
|
|
125
|
-
const entry = path.join(ROOT_DIR, 'src', 'entrypoints', 'manager.tsx');
|
|
126
|
-
|
|
127
|
-
// preload shim
|
|
128
|
-
const preload = path.join(ROOT_DIR, 'preload.ts');
|
|
129
|
-
if (!fs.existsSync(preload)) {
|
|
130
|
-
console.error('[bingocode] 找不到 preload.ts,MACRO 将无法注入:' + preload);
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 检查 .env
|
|
135
|
-
let envFlag = '';
|
|
136
|
-
const envPath = path.join(ROOT_DIR, '.env');
|
|
137
|
-
if (fs.existsSync(envPath)) {
|
|
138
|
-
envFlag = `--env-file=${envPath}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const extraArgs = process.argv.slice(2);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
process.env.NoDefaultCurrentDirectoryInExePath = '1';
|
|
9
|
+
|
|
10
|
+
// ── 首次部署:将默认 bingo 配置复制到 ~/.claude/bingo/ ──
|
|
11
|
+
/**
|
|
12
|
+
* 更加健壮的根目录定位:
|
|
13
|
+
* 1. 如果 preload.ts 在 ../ (当前 bin/ 目录下运行)
|
|
14
|
+
* 2. 否则查找同级及上级目录中的 package.json
|
|
15
|
+
*/
|
|
16
|
+
function getProjectRoot() {
|
|
17
|
+
let curr = __dirname;
|
|
18
|
+
try {
|
|
19
|
+
while (curr !== path.dirname(curr)) {
|
|
20
|
+
if (fs.existsSync(path.join(curr, 'preload.ts')) || fs.existsSync(path.join(curr, 'package.json'))) {
|
|
21
|
+
return curr;
|
|
22
|
+
}
|
|
23
|
+
const parent = path.dirname(curr);
|
|
24
|
+
if (fs.existsSync(path.join(parent, 'preload.ts'))) return parent;
|
|
25
|
+
curr = parent;
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// 防止权限拒绝等导致挂死
|
|
29
|
+
}
|
|
30
|
+
return path.join(__dirname, '..');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ROOT_DIR = getProjectRoot();
|
|
34
|
+
|
|
35
|
+
(function deployBingoDefaults() {
|
|
36
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
37
|
+
const bingoDir = path.join(configDir, 'bingo');
|
|
38
|
+
const targetSettings = path.join(bingoDir, 'settings.json');
|
|
39
|
+
|
|
40
|
+
// 只在 settings.json 不存在时才部署
|
|
41
|
+
if (!fs.existsSync(targetSettings)) {
|
|
42
|
+
const defaultsDir = path.join(ROOT_DIR, 'config', 'bingo-defaults');
|
|
43
|
+
const srcSettings = path.join(defaultsDir, 'settings.json');
|
|
44
|
+
|
|
45
|
+
if (fs.existsSync(srcSettings)) {
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(bingoDir)) {
|
|
48
|
+
fs.mkdirSync(bingoDir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
fs.copyFileSync(srcSettings, targetSettings);
|
|
51
|
+
console.log('[bingo] 首次启动:已部署默认配置到', targetSettings);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.warn('[bingo] 部署默认配置失败:', err.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
// 自动定位 bun.exe(纯文件系统查找,无子进程,无 DEP0190 警告)
|
|
60
|
+
function resolveBunExe() {
|
|
61
|
+
// 1. 用户指定路径
|
|
62
|
+
if (process.env.BUN_PATH && fs.existsSync(process.env.BUN_PATH)) {
|
|
63
|
+
return process.env.BUN_PATH;
|
|
64
|
+
}
|
|
65
|
+
const home = os.homedir();
|
|
66
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
67
|
+
const candidates = [
|
|
68
|
+
// npm install -g bun 的真实 exe(最常见)
|
|
69
|
+
path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
|
|
70
|
+
// bun 官方安装脚本位置
|
|
71
|
+
path.join(home, '.bun', 'bin', 'bun.exe'),
|
|
72
|
+
];
|
|
73
|
+
// 遍历 PATH 中每个目录查找 bun.exe
|
|
74
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
75
|
+
candidates.push(path.join(dir, 'bun.exe'));
|
|
76
|
+
}
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
try { if (fs.existsSync(c)) return c; } catch (_) {}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 检查 bun 是否可用
|
|
84
|
+
function bunExists() {
|
|
85
|
+
return resolveBunExe() !== null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 安装 bun(通过 npm install -g bun)
|
|
89
|
+
function installBun() {
|
|
90
|
+
console.log('[bingocode] bun 未检测到,正在通过 npm install -g bun 安装...');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const npmResult = spawnSync(
|
|
94
|
+
'npm.cmd',
|
|
95
|
+
['install', '-g', 'bun', '--loglevel', 'error'],
|
|
96
|
+
{ stdio: 'inherit' }
|
|
97
|
+
);
|
|
98
|
+
if (npmResult.status !== 0) {
|
|
99
|
+
throw new Error(`npm install -g bun 失败,exit code ${npmResult.status}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log('[bingocode] bun 安装完成,正在启动...');
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[bingocode] bun 自动安装失败: ${err.message}`);
|
|
106
|
+
console.log('[bingocode] 请手动安装 bun: npm install -g bun');
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!bunExists()) {
|
|
112
|
+
if (!installBun()) {
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 安装完成后重新解析 bun 路径
|
|
118
|
+
const bunExe = resolveBunExe();
|
|
119
|
+
if (!bunExe) {
|
|
120
|
+
console.error('[bingocode] 安装后仍找不到 bun.exe,请重新打开终端后再试,或手动安装 bun: npm install -g bun');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bingo Manager 入口
|
|
125
|
+
const entry = path.join(ROOT_DIR, 'src', 'entrypoints', 'manager.tsx');
|
|
126
|
+
|
|
127
|
+
// preload shim
|
|
128
|
+
const preload = path.join(ROOT_DIR, 'preload.ts');
|
|
129
|
+
if (!fs.existsSync(preload)) {
|
|
130
|
+
console.error('[bingocode] 找不到 preload.ts,MACRO 将无法注入:' + preload);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 检查 .env
|
|
135
|
+
let envFlag = '';
|
|
136
|
+
const envPath = path.join(ROOT_DIR, '.env');
|
|
137
|
+
if (fs.existsSync(envPath)) {
|
|
138
|
+
envFlag = `--env-file=${envPath}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const extraArgs = process.argv.slice(2);
|
|
142
|
+
|
|
143
|
+
// ── Start tray daemon if not already running ────────────────────────────────
|
|
144
|
+
const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
|
|
145
|
+
const DAEMON_LOCK_FILE = path.join(RUNTIME_DIR, 'daemon.lock');
|
|
146
|
+
|
|
147
|
+
function serverHealthy() {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const http = require('http');
|
|
150
|
+
const req = http.get('http://127.0.0.1:3456/health', { timeout: 1000 }, (res) => {
|
|
151
|
+
let data = '';
|
|
152
|
+
res.on('data', (c) => (data += c));
|
|
153
|
+
res.on('end', () => {
|
|
154
|
+
try {
|
|
155
|
+
resolve(res.statusCode === 200 && JSON.parse(data).status === 'ok');
|
|
156
|
+
} catch {
|
|
157
|
+
resolve(false);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
req.on('error', () => resolve(false));
|
|
162
|
+
req.setTimeout(1000, () => { req.destroy(); resolve(false); });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isDaemonAlive() {
|
|
167
|
+
try {
|
|
168
|
+
const pid = parseInt(fs.readFileSync(DAEMON_LOCK_FILE, 'utf-8').trim(), 10);
|
|
169
|
+
process.kill(pid, 0); // signal 0 == probe only
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const CHECK_MS = 3000;
|
|
177
|
+
|
|
178
|
+
async function startTrayDaemonIfNeeded() {
|
|
179
|
+
// Daemon PID lock prevents race: only one daemon runs across multiple bingo launches
|
|
180
|
+
if (isDaemonAlive()) {
|
|
181
|
+
console.log('[bingo] Daemon already running, skipping daemon start');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// stale lock cleanup
|
|
186
|
+
try { fs.unlinkSync(DAEMON_LOCK_FILE); } catch {}
|
|
187
|
+
|
|
188
|
+
console.log('[bingo] Starting tray daemon...');
|
|
189
|
+
const trayEntry = path.join(ROOT_DIR, 'src', 'entrypoints', 'tray-only.ts');
|
|
190
|
+
const daemon = spawn(bunExe, ['--preload=' + preload, trayEntry], {
|
|
191
|
+
detached: true,
|
|
192
|
+
stdio: 'ignore',
|
|
193
|
+
env: { ...process.env },
|
|
194
|
+
});
|
|
195
|
+
daemon.unref();
|
|
196
|
+
|
|
197
|
+
// Wait for health check
|
|
198
|
+
const start = Date.now();
|
|
199
|
+
while (Date.now() - start < CHECK_MS) {
|
|
200
|
+
if (await serverHealthy()) {
|
|
201
|
+
console.log('[bingo] Tray daemon started successfully');
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
await (new Promise((r) => setTimeout(r, 300)));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Start daemon, then launch CLI independently
|
|
209
|
+
startTrayDaemonIfNeeded().catch(() => {});
|
|
210
|
+
|
|
211
|
+
// ── Launch CLI (connects to existing server) ───────────────────────────────
|
|
212
|
+
const args = [`--preload=${preload}`, envFlag, entry, ...extraArgs].filter(Boolean);
|
|
213
|
+
|
|
214
|
+
// 用绝对路径 spawn,不依赖 shell 解析 PATH
|
|
215
|
+
const child = spawn(bunExe, args, { stdio: 'inherit' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bingocode",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.168",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude": "bin/claude-win.cjs",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
".claude/",
|
|
27
27
|
"runtime/",
|
|
28
28
|
"preload.ts",
|
|
29
|
-
".env.example",
|
|
30
29
|
"package.json",
|
|
31
30
|
"tsconfig.json",
|
|
32
31
|
"README.md",
|
|
@@ -80,6 +79,7 @@
|
|
|
80
79
|
"lru-cache": "^11.2.7",
|
|
81
80
|
"marked": "^17.0.5",
|
|
82
81
|
"medium-zoom": "^1.1.0",
|
|
82
|
+
"node-notifier": "^10.0.1",
|
|
83
83
|
"p-map": "^7.0.4",
|
|
84
84
|
"picomatch": "^4.0.4",
|
|
85
85
|
"proper-lockfile": "^4.1.2",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"stack-utils": "^2.0.6",
|
|
95
95
|
"strip-ansi": "^7.2.0",
|
|
96
96
|
"supports-hyperlinks": "^4.4.0",
|
|
97
|
+
"systray2": "^2.1.4",
|
|
97
98
|
"tree-kill": "^1.2.2",
|
|
98
99
|
"ts-node": "^10.9.2",
|
|
99
100
|
"type-fest": "^5.5.0",
|
|
@@ -112,11 +113,7 @@
|
|
|
112
113
|
"devDependencies": {
|
|
113
114
|
"@types/node": "^25.6.0"
|
|
114
115
|
},
|
|
115
|
-
"description": "
|
|
116
|
-
"main": "index.js",
|
|
117
|
-
"directories": {
|
|
118
|
-
"doc": "docs"
|
|
119
|
-
},
|
|
116
|
+
"description": "BingoCode - AI-powered coding assistant CLI built on Claude",
|
|
120
117
|
"keywords": [],
|
|
121
118
|
"author": "Leanchy",
|
|
122
119
|
"license": "MIT"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tray-only.ts — 背景托盘入口(无 UI,系统托盘守护进程)
|
|
3
|
+
*
|
|
4
|
+
* 使用 systray2 渲染原生托盘图标,启动服务器,处理退出请求。
|
|
5
|
+
* 关闭所有 CLI 时,服务器继续运行直到用户右键 Exit 退出。
|
|
6
|
+
*/
|
|
7
|
+
import SysTray from 'systray2';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const ROOT_DIR = path.resolve(__dirname, '../../');
|
|
17
|
+
|
|
18
|
+
const PORT = Number(process.env.BINGO_SERVER_PORT || 3456);
|
|
19
|
+
const HOST = process.env.BINGO_SERVER_HOST || '127.0.0.1';
|
|
20
|
+
|
|
21
|
+
let serverHandle: any = null;
|
|
22
|
+
|
|
23
|
+
// ── Daemon PID lock ──────────────────────────────────────────────────────
|
|
24
|
+
const DAEMON_LOCK_FILE = path.join(os.homedir(), '.claude-cli', 'runtime', 'daemon.lock');
|
|
25
|
+
try { fs.mkdirSync(path.dirname(DAEMON_LOCK_FILE), { recursive: true }); } catch {}
|
|
26
|
+
fs.writeFileSync(DAEMON_LOCK_FILE, String(process.pid), { flag: 'w' });
|
|
27
|
+
process.on('exit', () => {
|
|
28
|
+
try { fs.unlinkSync(DAEMON_LOCK_FILE); } catch {}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Generate green dot icon inline (16x16 PNG, 32-bit RGBA)
|
|
32
|
+
import zlib from 'zlib';
|
|
33
|
+
function buildGreenDotBase64(): string {
|
|
34
|
+
const W = 16, H = 16;
|
|
35
|
+
const raw: number[] = [];
|
|
36
|
+
for (let y = 0; y < H; y++) {
|
|
37
|
+
raw.push(0); // filter none
|
|
38
|
+
for (let x = 0; x < W; x++) {
|
|
39
|
+
const dx = x - 7.5, dy = y - 7.5;
|
|
40
|
+
const d = Math.sqrt(dx*dx + dy*dy);
|
|
41
|
+
if (d < 5.5) {
|
|
42
|
+
raw.push(0x1a, 0xe8, 0x30, 0xff);
|
|
43
|
+
} else {
|
|
44
|
+
raw.push(0, 0, 0, 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const rgbadata = Buffer.from(raw);
|
|
49
|
+
const compressed = zlib.deflateSync(rgbadata);
|
|
50
|
+
const sig = Buffer.from([137,80,78,71,13,10,26,10]);
|
|
51
|
+
const ihdrData = Buffer.alloc(13);
|
|
52
|
+
ihdrData.writeUInt32BE(W, 0);
|
|
53
|
+
ihdrData.writeUInt32BE(H, 4);
|
|
54
|
+
ihdrData.writeUInt8(8, 8);
|
|
55
|
+
ihdrData.writeUInt8(6, 9);
|
|
56
|
+
const ihdr = makePngChunk('IHDR', ihdrData);
|
|
57
|
+
const idat = makePngChunk('IDAT', compressed);
|
|
58
|
+
const iend = makePngChunk('IEND', Buffer.alloc(0));
|
|
59
|
+
return Buffer.concat([sig, ihdr, idat, iend]).toString('base64');
|
|
60
|
+
}
|
|
61
|
+
function makePngChunk(type: string, data: Buffer): Buffer {
|
|
62
|
+
const t = Buffer.from(type, 'ascii');
|
|
63
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
|
64
|
+
const crcInput = Buffer.concat([t, data]);
|
|
65
|
+
const crc = crc32(crcInput);
|
|
66
|
+
const trailer = Buffer.alloc(4); trailer.writeUInt32BE(crc, 0);
|
|
67
|
+
return Buffer.concat([len, t, data, trailer]);
|
|
68
|
+
}
|
|
69
|
+
function crc32(buf: Buffer): number {
|
|
70
|
+
let c = 0xffffffff;
|
|
71
|
+
for (let i = 0; i < buf.length; i++) {
|
|
72
|
+
c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
73
|
+
}
|
|
74
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
75
|
+
}
|
|
76
|
+
const crcTable = (() => {
|
|
77
|
+
const t = new Int32Array(256);
|
|
78
|
+
for (let n = 0; n < 256; n++) {
|
|
79
|
+
let c = n;
|
|
80
|
+
for (let k = 0; k < 8; k++) {
|
|
81
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
82
|
+
}
|
|
83
|
+
t[n] = c;
|
|
84
|
+
}
|
|
85
|
+
return t;
|
|
86
|
+
})();
|
|
87
|
+
|
|
88
|
+
const iconB64 = buildGreenDotBase64();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const systray = new SysTray({
|
|
92
|
+
menu: {
|
|
93
|
+
icon: iconB64,
|
|
94
|
+
title: 'Bingo',
|
|
95
|
+
tooltip: 'Bingo is running',
|
|
96
|
+
items: [
|
|
97
|
+
{
|
|
98
|
+
title: 'Server Running',
|
|
99
|
+
tooltip: 'Bingo is running on port ' + PORT,
|
|
100
|
+
checked: false,
|
|
101
|
+
enabled: false,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
title: 'Exit Bingo',
|
|
105
|
+
tooltip: 'Stop all bingo services',
|
|
106
|
+
checked: false,
|
|
107
|
+
enabled: true,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
debug: false,
|
|
112
|
+
copyDir: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
systray.onClick((action: any) => {
|
|
116
|
+
if (action.seq_id === 1) {
|
|
117
|
+
console.log('[tray] Exiting via tray menu');
|
|
118
|
+
const req = http.request(
|
|
119
|
+
`http://${HOST}:${PORT}/exit`,
|
|
120
|
+
{ method: 'POST', timeout: 5000 },
|
|
121
|
+
() => {},
|
|
122
|
+
);
|
|
123
|
+
req.on('error', () => {
|
|
124
|
+
try {
|
|
125
|
+
systray.kill(false);
|
|
126
|
+
} catch {}
|
|
127
|
+
process.exit(0);
|
|
128
|
+
});
|
|
129
|
+
req.end();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error('[tray] Failed to create systray:', e);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Start the server ─────────────────────────────────────────────────────────
|
|
138
|
+
import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer.js';
|
|
139
|
+
const serverEntry = path.join(ROOT_DIR, 'src', 'server', 'index.ts');
|
|
140
|
+
|
|
141
|
+
ensureSingletonLocalServer({ serverEntry, host: HOST, port: PORT })
|
|
142
|
+
.then((handle: any) => {
|
|
143
|
+
serverHandle = handle;
|
|
144
|
+
console.log('[tray] Server started on http://' + HOST + ':' + PORT);
|
|
145
|
+
})
|
|
146
|
+
.catch((err: any) => {
|
|
147
|
+
console.error('[tray] Failed to start server:', err.message || err);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Keep event loop alive indefinitely
|
|
151
|
+
setInterval(() => {}, 60_000);
|
|
@@ -14,7 +14,6 @@ import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer
|
|
|
14
14
|
import { TopBar, BottomBar, Panel, Hint, Kbd, SecondaryMenu, StateDisplay, ScrollBar, truncate, safePadEnd } from '../manager/CliMenuUi.tsx';
|
|
15
15
|
import { WelcomeV2 } from '../components/LogoV2/WelcomeV2.tsx';
|
|
16
16
|
import { TopToolbar } from '../manager/TopToolbar.tsx';
|
|
17
|
-
import { TokenStatsPanel } from '../manager/TokenStatsPanel.tsx';
|
|
18
17
|
|
|
19
18
|
// Theme switching (Hook)
|
|
20
19
|
import { useTheme } from '../components/design-system/ThemeProvider.js';
|
|
@@ -485,7 +484,7 @@ export const CliMenuManager: React.FC = () => {
|
|
|
485
484
|
const [settingData, setSettingData] = useState<any>(null);
|
|
486
485
|
const [loadingSetting, setLoadingSetting] = useState(false);
|
|
487
486
|
const [setErr, setSetErr] = useState<string | null>(null);
|
|
488
|
-
const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker'
|
|
487
|
+
const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker'>('list');
|
|
489
488
|
const [settingsCursor, setSettingsCursor] = useState(0);
|
|
490
489
|
const [autoModeEnabled, setAutoModeEnabled] = useState(false);
|
|
491
490
|
const [bypassPermsEnabled, setBypassPermsEnabled] = useState(false);
|
|
@@ -764,7 +763,6 @@ export const CliMenuManager: React.FC = () => {
|
|
|
764
763
|
// Settings: langPicker → back to list; list → back to main menu
|
|
765
764
|
if (page === 'settings') {
|
|
766
765
|
if (settingsStage === 'langPicker') { setSettingsStage('list'); return; }
|
|
767
|
-
if (settingsStage === 'tokenStats') { setSettingsStage('list'); return; }
|
|
768
766
|
}
|
|
769
767
|
setPage(null);
|
|
770
768
|
setHistoryMenuStage('list');
|
|
@@ -876,8 +874,8 @@ export const CliMenuManager: React.FC = () => {
|
|
|
876
874
|
// Settings interactions
|
|
877
875
|
if (!showHelp && page === 'settings') {
|
|
878
876
|
if (settingsStage === 'list') {
|
|
879
|
-
//
|
|
880
|
-
const totalRows =
|
|
877
|
+
// +1 for the fixed Language row prepended before settingData entries
|
|
878
|
+
const totalRows = 3 + (settingData && typeof settingData === 'object' ? Object.keys(settingData).length : 0);
|
|
881
879
|
const visible = Math.max(1, MID_H - 2);
|
|
882
880
|
if (key.downArrow || input === 'j') {
|
|
883
881
|
setSettingsCursor(c => Math.min(totalRows - 1, c + 1));
|
|
@@ -925,9 +923,6 @@ export const CliMenuManager: React.FC = () => {
|
|
|
925
923
|
}
|
|
926
924
|
return next;
|
|
927
925
|
});
|
|
928
|
-
} else if (settingsCursor === 3) {
|
|
929
|
-
// Row 3: open Token Stats
|
|
930
|
-
setSettingsStage('tokenStats');
|
|
931
926
|
}
|
|
932
927
|
}
|
|
933
928
|
}
|
|
@@ -1411,24 +1406,12 @@ export const CliMenuManager: React.FC = () => {
|
|
|
1411
1406
|
);
|
|
1412
1407
|
}
|
|
1413
1408
|
|
|
1414
|
-
// --- tokenStats sub-menu ---
|
|
1415
|
-
if (settingsStage === 'tokenStats') {
|
|
1416
|
-
return (
|
|
1417
|
-
<TokenStatsPanel
|
|
1418
|
-
width={VIEW_W}
|
|
1419
|
-
height={MID_H}
|
|
1420
|
-
onBack={() => setSettingsStage('list')}
|
|
1421
|
-
/>
|
|
1422
|
-
);
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
1409
|
// --- settings list ---
|
|
1426
1410
|
type SettingRow = { key: string; label: string; value: string; interactive: boolean };
|
|
1427
1411
|
const fixedRows: SettingRow[] = [
|
|
1428
1412
|
{ key: '__lang', label: tS.langLabel, value: currentLangLabel, interactive: true },
|
|
1429
1413
|
{ key: '__autoMode', label: tS.autoModeLabel, value: autoModeEnabled ? tS.autoModeOn : tS.autoModeOff, interactive: true },
|
|
1430
1414
|
{ key: '__bypassPerms', label: tS.bypassPermsLabel, value: bypassPermsEnabled ? tS.bypassPermsOn : tS.bypassPermsOff, interactive: true },
|
|
1431
|
-
{ key: '__tokenStats', label: 'Token Stats', value: '>', interactive: true },
|
|
1432
1415
|
];
|
|
1433
1416
|
const dataEntries = settingData && typeof settingData === 'object' ? Object.entries(settingData) : [];
|
|
1434
1417
|
const dataRows: SettingRow[] = dataEntries.map(([k, v]) => ({
|
|
@@ -1,182 +1,173 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (healthy && serverPid && isPidAlive(serverPid)) {
|
|
175
|
-
try {
|
|
176
|
-
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(serverPid), '/T', '/F']);
|
|
177
|
-
else process.kill(serverPid, 'SIGTERM');
|
|
178
|
-
} catch {}
|
|
179
|
-
}
|
|
180
|
-
rmSafe(LOCK_JSON);
|
|
181
|
-
};
|
|
182
|
-
}
|
|
1
|
+
// server/ensureSingletonLocalServer.ts
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import fsp from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
type Handle = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
stopIfLast: () => Promise<void>;
|
|
12
|
+
pid?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
|
|
16
|
+
const LOCK_JSON = path.join(RUNTIME_DIR, 'server.lock.json');
|
|
17
|
+
const BOOT_LOCK = path.join(RUNTIME_DIR, 'server.boot.lock');
|
|
18
|
+
const LEASES_DIR = path.join(RUNTIME_DIR, 'leases');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
21
|
+
const DEFAULT_PORT = Number(process.env.SERVER_PORT || 3456);
|
|
22
|
+
const HEALTH_TIMEOUT_MS = Number(process.env.HEALTH_TIMEOUT_MS || 20000);
|
|
23
|
+
const HEALTH_RETRY_MS = 300;
|
|
24
|
+
|
|
25
|
+
function mkdirp(p: string) { fs.mkdirSync(p, { recursive: true }); }
|
|
26
|
+
function atomicCreate(p: string): boolean {
|
|
27
|
+
try { const fd = fs.openSync(p, 'wx'); fs.closeSync(fd); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
function rmSafe(p: string) { try { fs.rmSync(p, { force: true, recursive: true }); } catch {} }
|
|
30
|
+
function readJson<T>(p: string): T | null { try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as T; } catch { return null; } }
|
|
31
|
+
function writeJson(p: string, data: any) { fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); }
|
|
32
|
+
function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
33
|
+
async function waitHealthy(baseUrl: string, timeoutMs: number) {
|
|
34
|
+
const start = Date.now(); let lastErr: any = null;
|
|
35
|
+
while (Date.now() - start < timeoutMs) {
|
|
36
|
+
try {
|
|
37
|
+
const r = await axios.get(baseUrl.replace(/\/+$/, '') + '/health', { timeout: 1500 });
|
|
38
|
+
if (r.status === 200 && r.data?.status === 'ok') return;
|
|
39
|
+
} catch (e) { lastErr = e; }
|
|
40
|
+
await new Promise(r => setTimeout(r, HEALTH_RETRY_MS));
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Health check timeout: ${lastErr?.message || 'unknown'}`);
|
|
43
|
+
}
|
|
44
|
+
function resolveBunPath(): string {
|
|
45
|
+
// 优先环境变量
|
|
46
|
+
const fromEnv = process.env.BUN_PATH;
|
|
47
|
+
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
|
48
|
+
|
|
49
|
+
// Windows:纯文件系统查找 bun.exe,无子进程,无 DEP0190 警告
|
|
50
|
+
if (process.platform === 'win32') {
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
53
|
+
const candidates = [
|
|
54
|
+
path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
|
|
55
|
+
path.join(home, '.bun', 'bin', 'bun.exe'),
|
|
56
|
+
];
|
|
57
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
58
|
+
candidates.push(path.join(dir, 'bun.exe'));
|
|
59
|
+
}
|
|
60
|
+
for (const c of candidates) {
|
|
61
|
+
try { if (fs.existsSync(c)) return c; } catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// Linux/macOS:遍历 PATH 查找 bun
|
|
65
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
66
|
+
const c = path.join(dir, 'bun');
|
|
67
|
+
try { if (fs.existsSync(c)) return c; } catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error('Bun not detected. Please install via: npm install -g bun');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function acquireLease(): Promise<string> {
|
|
75
|
+
mkdirp(LEASES_DIR);
|
|
76
|
+
const lease = path.join(LEASES_DIR, `${process.pid}.json`);
|
|
77
|
+
await fsp.writeFile(lease, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
78
|
+
const cleanup = () => rmSafe(lease);
|
|
79
|
+
process.once('exit', cleanup);
|
|
80
|
+
process.once('SIGINT', () => { cleanup(); process.exit(130); });
|
|
81
|
+
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
82
|
+
return lease;
|
|
83
|
+
}
|
|
84
|
+
function countValidLeases(): number {
|
|
85
|
+
try {
|
|
86
|
+
const files = fs.readdirSync(LEASES_DIR);
|
|
87
|
+
let alive = 0;
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
const p = path.join(LEASES_DIR, f);
|
|
90
|
+
const data = readJson<{pid:number, ts:number}>(p);
|
|
91
|
+
if (data?.pid && isPidAlive(data.pid)) alive++;
|
|
92
|
+
else rmSafe(p);
|
|
93
|
+
}
|
|
94
|
+
return alive;
|
|
95
|
+
} catch { return 0; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function ensureSingletonLocalServer(opts: {
|
|
99
|
+
serverEntry: string;
|
|
100
|
+
host?: string;
|
|
101
|
+
port?: number;
|
|
102
|
+
baseUrlEnv?: string;
|
|
103
|
+
passEnv?: Record<string, string>;
|
|
104
|
+
}): Promise<Handle> {
|
|
105
|
+
const preset = (opts.baseUrlEnv || process.env.BASE_API_URL || '').trim();
|
|
106
|
+
if (preset) {
|
|
107
|
+
try { await waitHealthy(preset, 3000); } catch {}
|
|
108
|
+
await acquireLease();
|
|
109
|
+
return { baseUrl: preset.replace(/\/+$/, ''), stopIfLast: async () => {} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const host = opts.host || DEFAULT_HOST;
|
|
113
|
+
const port = Number(opts.port ?? DEFAULT_PORT);
|
|
114
|
+
const baseUrl = `http://${host}:${port}`;
|
|
115
|
+
mkdirp(RUNTIME_DIR);
|
|
116
|
+
|
|
117
|
+
const lock = readJson<{ pid:number, port:number }>(LOCK_JSON);
|
|
118
|
+
if (lock && lock.port === port) {
|
|
119
|
+
const healthy = await (async () => { try { await waitHealthy(baseUrl, 1200); return true; } catch { return false; } })();
|
|
120
|
+
if (healthy && isPidAlive(lock.pid)) {
|
|
121
|
+
await acquireLease();
|
|
122
|
+
return { baseUrl, stopIfLast: makeStopIfLast(lock.pid, baseUrl), pid: lock.pid };
|
|
123
|
+
}
|
|
124
|
+
rmSafe(LOCK_JSON);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const iAmSpawner = atomicCreate(BOOT_LOCK);
|
|
128
|
+
if (!iAmSpawner) {
|
|
129
|
+
try { await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS); } catch {
|
|
130
|
+
rmSafe(BOOT_LOCK); rmSafe(LOCK_JSON);
|
|
131
|
+
return await ensureSingletonLocalServer(opts);
|
|
132
|
+
}
|
|
133
|
+
await acquireLease();
|
|
134
|
+
const live = readJson<{pid:number}>(LOCK_JSON);
|
|
135
|
+
return { baseUrl, stopIfLast: makeStopIfLast(live?.pid || 0, baseUrl), pid: live?.pid };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let child: any = null;
|
|
139
|
+
try {
|
|
140
|
+
const bun = resolveBunPath();
|
|
141
|
+
child = spawn(
|
|
142
|
+
bun,
|
|
143
|
+
[opts.serverEntry, '--host', host, '--port', String(port)],
|
|
144
|
+
{
|
|
145
|
+
env: { ...process.env, SERVER_AUTH_REQUIRED: '0', ...(opts.passEnv || {}) },
|
|
146
|
+
stdio: 'ignore',
|
|
147
|
+
detached: true,
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
child.unref();
|
|
151
|
+
await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS);
|
|
152
|
+
writeJson(LOCK_JSON, { pid: child.pid, port, startedAt: new Date().toISOString() });
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (child?.pid) {
|
|
155
|
+
try {
|
|
156
|
+
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
|
|
157
|
+
else process.kill(child.pid, 'SIGTERM');
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
throw e;
|
|
161
|
+
} finally {
|
|
162
|
+
rmSafe(BOOT_LOCK);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await acquireLease();
|
|
166
|
+
return { baseUrl, stopIfLast: makeStopIfLast(child.pid, baseUrl), pid: child.pid };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function makeStopIfLast(_serverPid: number, _baseUrl: string) {
|
|
170
|
+
return async () => {
|
|
171
|
+
// no-op: only POST /exit from tray can stop server
|
|
172
|
+
};
|
|
173
|
+
}
|
package/.env.example
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# ============================================================
|
|
2
|
-
# MiniMax(直连 Anthropic 兼容接口)
|
|
3
|
-
# 海外用户: ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
|
4
|
-
# 国内用户: ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
|
|
5
|
-
# 可用模型: MiniMax-M2.7(默认)、MiniMax-M2.7-highspeed(更快)
|
|
6
|
-
# ============================================================
|
|
7
|
-
# ANTHROPIC_AUTH_TOKEN=your_minimax_api_key_here
|
|
8
|
-
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
|
9
|
-
# ANTHROPIC_MODEL=MiniMax-M2.7
|
|
10
|
-
# ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M2.7
|
|
11
|
-
# ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M2.7-highspeed
|
|
12
|
-
# ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M2.7
|
|
13
|
-
# API_TIMEOUT_MS=3000000
|
|
14
|
-
|
|
15
|
-
# ============================================================
|
|
16
|
-
# OpenAI(通过 LiteLLM 代理)
|
|
17
|
-
# 先启动: litellm --config litellm_config.yaml --port 4000
|
|
18
|
-
# ============================================================
|
|
19
|
-
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
|
20
|
-
# ANTHROPIC_BASE_URL=http://localhost:4000
|
|
21
|
-
# ANTHROPIC_MODEL=gpt-4o
|
|
22
|
-
# ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-4o
|
|
23
|
-
# ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-4o
|
|
24
|
-
# ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-4o
|
|
25
|
-
# API_TIMEOUT_MS=3000000
|
|
26
|
-
|
|
27
|
-
# ============================================================
|
|
28
|
-
# DeepSeek(通过 LiteLLM 代理)
|
|
29
|
-
# 先启动: litellm --config litellm_config.yaml --port 4000
|
|
30
|
-
# ============================================================
|
|
31
|
-
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
|
32
|
-
# ANTHROPIC_BASE_URL=http://localhost:4000
|
|
33
|
-
# ANTHROPIC_MODEL=deepseek-chat
|
|
34
|
-
# ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-chat
|
|
35
|
-
# ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-chat
|
|
36
|
-
# ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-chat
|
|
37
|
-
# API_TIMEOUT_MS=3000000
|
|
38
|
-
|
|
39
|
-
# ============================================================
|
|
40
|
-
# OpenRouter(直连 Anthropic 兼容接口)
|
|
41
|
-
# ============================================================
|
|
42
|
-
# ANTHROPIC_AUTH_TOKEN=sk-or-v1-xxx
|
|
43
|
-
# ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
|
44
|
-
# ANTHROPIC_MODEL=openai/gpt-4o
|
|
45
|
-
# ANTHROPIC_DEFAULT_SONNET_MODEL=openai/gpt-4o
|
|
46
|
-
# ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-4o-mini
|
|
47
|
-
# ANTHROPIC_DEFAULT_OPUS_MODEL=openai/gpt-4o
|
|
48
|
-
|
|
49
|
-
# ============================================================
|
|
50
|
-
# 通用设置(建议始终开启)
|
|
51
|
-
# ============================================================
|
|
52
|
-
DISABLE_TELEMETRY=1
|
|
53
|
-
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
-
import { Box, Text, useInput } from 'ink';
|
|
3
|
-
import { StateDisplay, ScrollBar, safePadEnd } from '../manager/CliMenuUi.tsx';
|
|
4
|
-
import { aggregateClaudeCodeStats, type ClaudeCodeStats, type DailyModelTokens } from '../utils/stats.ts';
|
|
5
|
-
import { formatNumber } from '../utils/format.js';
|
|
6
|
-
|
|
7
|
-
// Get ISO week number (Monday-based)
|
|
8
|
-
function getWeekNumber(dateStr: string): string {
|
|
9
|
-
const date = new Date(dateStr);
|
|
10
|
-
date.setHours(0, 0, 0, 0);
|
|
11
|
-
date.setDate(date.getDate() + 4 - (date.getDay() || 7));
|
|
12
|
-
const yearStart = new Date(date.getFullYear(), 0, 1);
|
|
13
|
-
const weekNo = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
|
14
|
-
return `${date.getFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Get month key (YYYY-MM)
|
|
18
|
-
function getMonthKey(dateStr: string): string {
|
|
19
|
-
return dateStr.slice(0, 7);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Aggregate daily tokens by week
|
|
23
|
-
function aggregateByWeek(dailyTokens: DailyModelTokens[]): DailyModelTokens[] {
|
|
24
|
-
const weekMap = new Map<string, { [model: string]: number }>();
|
|
25
|
-
|
|
26
|
-
for (const day of dailyTokens) {
|
|
27
|
-
const weekKey = getWeekNumber(day.date);
|
|
28
|
-
const existing = weekMap.get(weekKey) || {};
|
|
29
|
-
|
|
30
|
-
for (const [model, tokens] of Object.entries(day.tokensByModel)) {
|
|
31
|
-
existing[model] = (existing[model] || 0) + tokens;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
weekMap.set(weekKey, existing);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return Array.from(weekMap.entries())
|
|
38
|
-
.map(([date, tokensByModel]) => ({ date, tokensByModel }))
|
|
39
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Aggregate daily tokens by month
|
|
43
|
-
function aggregateByMonth(dailyTokens: DailyModelTokens[]): DailyModelTokens[] {
|
|
44
|
-
const monthMap = new Map<string, { [model: string]: number }>();
|
|
45
|
-
|
|
46
|
-
for (const day of dailyTokens) {
|
|
47
|
-
const monthKey = getMonthKey(day.date);
|
|
48
|
-
const existing = monthMap.get(monthKey) || {};
|
|
49
|
-
|
|
50
|
-
for (const [model, tokens] of Object.entries(day.tokensByModel)) {
|
|
51
|
-
existing[model] = (existing[model] || 0) + tokens;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
monthMap.set(monthKey, existing);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return Array.from(monthMap.entries())
|
|
58
|
-
.map(([date, tokensByModel]) => ({ date, tokensByModel }))
|
|
59
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Get last N days
|
|
63
|
-
function getLastNDays(dailyTokens: DailyModelTokens[], n: number = 14): DailyModelTokens[] {
|
|
64
|
-
const sorted = [...dailyTokens].sort((a, b) => b.date.localeCompare(a.date));
|
|
65
|
-
return sorted.slice(0, n);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
type TimeRange = 'day' | 'week' | 'month' | 'total';
|
|
69
|
-
|
|
70
|
-
type Props = {
|
|
71
|
-
width: number;
|
|
72
|
-
height: number;
|
|
73
|
-
onBack: () => void;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export const TokenStatsPanel: React.FC<Props> = ({ width, height, onBack }) => {
|
|
77
|
-
const [loading, setLoading] = useState(true);
|
|
78
|
-
const [error, setError] = useState<string | null>(null);
|
|
79
|
-
const [stats, setStats] = useState<ClaudeCodeStats | null>(null);
|
|
80
|
-
const [timeRange, setTimeRange] = useState<TimeRange>('total');
|
|
81
|
-
const [activeButton, setActiveButton] = useState(3); // 0=day, 1=week, 2=month, 3=total
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
let cancelled = false;
|
|
85
|
-
setLoading(true);
|
|
86
|
-
setError(null);
|
|
87
|
-
aggregateClaudeCodeStats()
|
|
88
|
-
.then(data => {
|
|
89
|
-
if (!cancelled) {
|
|
90
|
-
setStats(data);
|
|
91
|
-
setLoading(false);
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
.catch(err => {
|
|
95
|
-
if (!cancelled) {
|
|
96
|
-
setError(err.message || String(err));
|
|
97
|
-
setLoading(false);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
return () => { cancelled = true; };
|
|
101
|
-
}, []);
|
|
102
|
-
|
|
103
|
-
// Handle keyboard input for time range switching
|
|
104
|
-
useInput((input, key) => {
|
|
105
|
-
if (input === '1') {
|
|
106
|
-
setTimeRange('day');
|
|
107
|
-
setActiveButton(0);
|
|
108
|
-
} else if (input === '2') {
|
|
109
|
-
setTimeRange('week');
|
|
110
|
-
setActiveButton(1);
|
|
111
|
-
} else if (input === '3') {
|
|
112
|
-
setTimeRange('month');
|
|
113
|
-
setActiveButton(2);
|
|
114
|
-
} else if (input === '4') {
|
|
115
|
-
setTimeRange('total');
|
|
116
|
-
setActiveButton(3);
|
|
117
|
-
} else if (key.leftArrow && activeButton > 0) {
|
|
118
|
-
const newButton = activeButton - 1;
|
|
119
|
-
setActiveButton(newButton);
|
|
120
|
-
setTimeRange(['day', 'week', 'month', 'total'][newButton] as TimeRange);
|
|
121
|
-
} else if (key.rightArrow && activeButton < 3) {
|
|
122
|
-
const newButton = activeButton + 1;
|
|
123
|
-
setActiveButton(newButton);
|
|
124
|
-
setTimeRange(['day', 'week', 'month', 'total'][newButton] as TimeRange);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
if (loading) {
|
|
129
|
-
return <StateDisplay type="loading" message="Loading token stats..." />;
|
|
130
|
-
}
|
|
131
|
-
if (error) {
|
|
132
|
-
return <StateDisplay type="error" message={`Failed to load: ${error}`} />;
|
|
133
|
-
}
|
|
134
|
-
if (!stats || !stats.modelUsage || Object.keys(stats.modelUsage).length === 0) {
|
|
135
|
-
return <StateDisplay type="empty" message="No token data yet. Run some conversations first!" />;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Calculate column widths
|
|
139
|
-
const MODEL_COL = 28;
|
|
140
|
-
const NUM_COL = 14;
|
|
141
|
-
const DATE_COL = 12;
|
|
142
|
-
const visible = height - 4; // leave room for header row + buttons + bottom hint
|
|
143
|
-
|
|
144
|
-
// Prepare data based on time range
|
|
145
|
-
let isDateView = timeRange !== 'total';
|
|
146
|
-
|
|
147
|
-
const formatCount = (n: number) => formatNumber(n);
|
|
148
|
-
|
|
149
|
-
// Time range buttons
|
|
150
|
-
const timeButtons = [
|
|
151
|
-
{ label: 'Day', key: '1' },
|
|
152
|
-
{ label: 'Week', key: '2' },
|
|
153
|
-
{ label: 'Month', key: '3' },
|
|
154
|
-
{ label: 'Total', key: '4' },
|
|
155
|
-
];
|
|
156
|
-
|
|
157
|
-
return (
|
|
158
|
-
<Box width={width} height={height} flexDirection="column">
|
|
159
|
-
{/* Time range buttons */}
|
|
160
|
-
<Box marginBottom={1}>
|
|
161
|
-
{timeButtons.map((btn, idx) => (
|
|
162
|
-
<Text key={btn.key} color={activeButton === idx ? 'cyan' : 'white'}>
|
|
163
|
-
[{btn.label}] {btn.key}
|
|
164
|
-
{idx < timeButtons.length - 1 ? ' ' : ''}
|
|
165
|
-
</Text>
|
|
166
|
-
))}
|
|
167
|
-
</Box>
|
|
168
|
-
|
|
169
|
-
<Box flexDirection="column" flexGrow={1}>
|
|
170
|
-
{isDateView ? (
|
|
171
|
-
<>
|
|
172
|
-
<Text bold>
|
|
173
|
-
{safePadEnd('Date', DATE_COL)}
|
|
174
|
-
{safePadEnd('Model', MODEL_COL)}
|
|
175
|
-
{safePadEnd('Total Tokens', NUM_COL)}
|
|
176
|
-
</Text>
|
|
177
|
-
<Text dimColor>{'─'.repeat(width - 4)}</Text>
|
|
178
|
-
<Box flexDirection="row" flexGrow={1} position="relative">
|
|
179
|
-
<Box flexDirection="column" flexGrow={1}>
|
|
180
|
-
{(() => {
|
|
181
|
-
// Date-based views - aggregate dailyModelTokens
|
|
182
|
-
let aggregated: DailyModelTokens[];
|
|
183
|
-
if (timeRange === 'day') {
|
|
184
|
-
aggregated = getLastNDays(stats.dailyModelTokens, 14);
|
|
185
|
-
} else if (timeRange === 'week') {
|
|
186
|
-
aggregated = aggregateByWeek(stats.dailyModelTokens);
|
|
187
|
-
} else {
|
|
188
|
-
// month
|
|
189
|
-
aggregated = aggregateByMonth(stats.dailyModelTokens);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return aggregated.flatMap(day =>
|
|
193
|
-
Object.entries(day.tokensByModel).map(([model, tokens]) => {
|
|
194
|
-
const row = [
|
|
195
|
-
safePadEnd(day.date, DATE_COL),
|
|
196
|
-
safePadEnd(model.length > MODEL_COL - 3 ? model.slice(0, MODEL_COL - 4) + '…' : model, MODEL_COL),
|
|
197
|
-
safePadEnd(formatCount(tokens), NUM_COL),
|
|
198
|
-
].join('');
|
|
199
|
-
return <Text key={`${day.date}-${model}`}>{row}</Text>;
|
|
200
|
-
})
|
|
201
|
-
);
|
|
202
|
-
})()}
|
|
203
|
-
</Box>
|
|
204
|
-
<ScrollBar
|
|
205
|
-
total={(() => {
|
|
206
|
-
let aggregated: DailyModelTokens[];
|
|
207
|
-
if (timeRange === 'day') {
|
|
208
|
-
aggregated = getLastNDays(stats.dailyModelTokens, 14);
|
|
209
|
-
} else if (timeRange === 'week') {
|
|
210
|
-
aggregated = aggregateByWeek(stats.dailyModelTokens);
|
|
211
|
-
} else {
|
|
212
|
-
aggregated = aggregateByMonth(stats.dailyModelTokens);
|
|
213
|
-
}
|
|
214
|
-
return aggregated.flatMap(day => Object.keys(day.tokensByModel)).length;
|
|
215
|
-
})()}
|
|
216
|
-
offset={0}
|
|
217
|
-
height={visible - 2}
|
|
218
|
-
/>
|
|
219
|
-
</Box>
|
|
220
|
-
</>
|
|
221
|
-
) : (
|
|
222
|
-
<>
|
|
223
|
-
<Text bold>
|
|
224
|
-
{safePadEnd('Model', MODEL_COL)}
|
|
225
|
-
{safePadEnd('Input Tokens', NUM_COL)}
|
|
226
|
-
{safePadEnd('Output Tokens', NUM_COL)}
|
|
227
|
-
{safePadEnd('Cache Read', NUM_COL)}
|
|
228
|
-
{safePadEnd('Cache Create', NUM_COL)}
|
|
229
|
-
</Text>
|
|
230
|
-
<Text dimColor>{'─'.repeat(width - 4)}</Text>
|
|
231
|
-
<Box flexDirection="row" flexGrow={1} position="relative">
|
|
232
|
-
<Box flexDirection="column" flexGrow={1}>
|
|
233
|
-
{Object.entries(stats.modelUsage).sort(
|
|
234
|
-
(a, b) => (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens)
|
|
235
|
-
).map(([model, usage]) => {
|
|
236
|
-
const row = [
|
|
237
|
-
safePadEnd(model.length > MODEL_COL - 3 ? model.slice(0, MODEL_COL - 4) + '…' : model, MODEL_COL),
|
|
238
|
-
safePadEnd(formatCount(usage.inputTokens), NUM_COL),
|
|
239
|
-
safePadEnd(formatCount(usage.outputTokens), NUM_COL),
|
|
240
|
-
safePadEnd(formatCount(usage.cacheReadInputTokens), NUM_COL),
|
|
241
|
-
safePadEnd(formatCount(usage.cacheCreationInputTokens), NUM_COL),
|
|
242
|
-
].join('');
|
|
243
|
-
return <Text key={model}>{row}</Text>;
|
|
244
|
-
})}
|
|
245
|
-
</Box>
|
|
246
|
-
<ScrollBar
|
|
247
|
-
total={Object.keys(stats.modelUsage).length}
|
|
248
|
-
offset={0}
|
|
249
|
-
height={visible - 2}
|
|
250
|
-
/>
|
|
251
|
-
</Box>
|
|
252
|
-
</>
|
|
253
|
-
)}
|
|
254
|
-
</Box>
|
|
255
|
-
<Text dimColor>ESC back · 1-4 or ←→ to switch view</Text>
|
|
256
|
-
</Box>
|
|
257
|
-
);
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
export default TokenStatsPanel;
|