@baize-ai/core 0.3.4 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to baize-core will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.6] - 2026-08-26
9
+
10
+ ### Added
11
+ - web-console 诊断卡(模型设置页):服务状态 / agent 会话(含卡登录提示)/ 运行时与认证 / 最近错误——浏览器内排查,无需 SSH
12
+
13
+ ## [0.3.5] - 2026-08-26
14
+
15
+ ### Fixed
16
+ - web-console 重启 agent 会话按当前 runtime 定位 tmux 会话(codex-main/claude-main)——保存 Codex API key 后自动重启生效,不再因会话名不匹配报"无 tmux session"
17
+
8
18
  ## [0.3.4] - 2026-08-26
9
19
 
10
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
6
6
  "main": "cli/baize.js",
@@ -2297,6 +2297,62 @@ async function submitA2aTaskForm(basePath) {
2297
2297
  }
2298
2298
  }
2299
2299
 
2300
+ /** Diagnostics: service/session/auth/errors rendered from /api/admin/diagnostics. */
2301
+ async function renderDiagnostics(basePath) {
2302
+ const runtimeEl = document.getElementById('diag-runtime');
2303
+ const servicesEl = document.getElementById('diag-services');
2304
+ const sessionsEl = document.getElementById('diag-sessions');
2305
+ const errorsEl = document.getElementById('diag-errors');
2306
+ const stateEl = document.getElementById('diag-runtime-state');
2307
+ if (!servicesEl) return;
2308
+ runtimeEl.textContent = '加载中...';
2309
+ let body;
2310
+ try {
2311
+ const r = await adminFetch(basePath, '/api/admin/diagnostics');
2312
+ if (r.status !== 200) throw new Error(r.body?.error || '加载失败');
2313
+ body = r.body;
2314
+ } catch (err) {
2315
+ runtimeEl.textContent = `加载失败:${escapeHtml(err.message)}`;
2316
+ return;
2317
+ }
2318
+ const rt = body.runtime || {};
2319
+ if (stateEl) stateEl.textContent = `runtime: ${escapeHtml(rt.runtime || '?')}`;
2320
+ // runtime + auth
2321
+ const auth = body.auth || {};
2322
+ const authLine = [];
2323
+ if (auth.claude?.configured) authLine.push(`Claude: ${escapeHtml(auth.claude.method)} (${escapeHtml(auth.claude.masked || '')})`);
2324
+ else authLine.push('Claude: 未配置');
2325
+ if (auth.codex?.configured) authLine.push(`Codex: ${escapeHtml(auth.codex.method)} (${escapeHtml(auth.codex.masked || '')})`);
2326
+ else authLine.push('Codex: 未配置');
2327
+ runtimeEl.innerHTML = `<strong>${escapeHtml(rt.runtime || 'claude')}</strong> · ${authLine.join(' · ')}`;
2328
+ // services
2329
+ const svcs = body.services || [];
2330
+ if (!svcs.length) {
2331
+ servicesEl.textContent = 'PM2 未运行或无服务(容器/服务未启动)';
2332
+ } else {
2333
+ servicesEl.innerHTML = '<table class="tasks-table"><thead><tr><th>服务</th><th>状态</th><th>重启</th><th>运行</th></tr></thead><tbody>'
2334
+ + svcs.map((s) => `<tr><td>${escapeHtml(s.name)}</td><td><span class="channel-state ${s.status === 'online' ? 'ok' : 'warn'}">${escapeHtml(s.status)}</span></td><td>${s.restarts}</td><td>${s.uptime != null ? Math.round(s.uptime / 60) + 'm' : '—'}</td></tr>`).join('')
2335
+ + '</tbody></table>';
2336
+ }
2337
+ // sessions
2338
+ const sessions = body.sessions || [];
2339
+ if (!sessions.length) {
2340
+ sessionsEl.innerHTML = '<span class="cred-state">无 agent 会话(tmux 未运行或未拉起)</span>';
2341
+ } else {
2342
+ sessionsEl.innerHTML = sessions.map((s) => {
2343
+ const stuck = /(sign in|login|authenticate)/i.test(s.tail || '') ? '<span class="channel-state warn">⚠ 可能卡在登录</span>' : '<span class="channel-state ok">运行中</span>';
2344
+ return `<div style="margin-bottom:8px"><strong>${escapeHtml(s.name)}</strong> ${stuck}<pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap">${escapeHtml(s.tail || '(空)')}</pre></div>`;
2345
+ }).join('');
2346
+ }
2347
+ // errors
2348
+ const errors = body.errors || [];
2349
+ if (!errors.length) {
2350
+ errorsEl.textContent = '无错误日志';
2351
+ } else {
2352
+ errorsEl.innerHTML = errors.map((e) => `<div style="margin-bottom:8px"><strong>${escapeHtml(e.service)}</strong><pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap;color:var(--danger,#e5534b)">${escapeHtml(e.tail)}</pre></div>`).join('');
2353
+ }
2354
+ }
2355
+
2300
2356
  function showAppView(basePath, view) {
2301
2357
  const chatView = document.getElementById('chat-view');
2302
2358
  const modelView = document.getElementById('model-view');
@@ -2323,6 +2379,7 @@ function showAppView(basePath, view) {
2323
2379
  a2aView.hidden = true;
2324
2380
  schedulerView.hidden = true;
2325
2381
  modelView.hidden = false;
2382
+ renderDiagnostics(basePath);
2326
2383
  setNav(navModel, [navChat, navChannels, navA2a, navScheduler]);
2327
2384
  renderModelSettings(basePath);
2328
2385
  } else if (view === 'channels') {
@@ -2518,6 +2575,7 @@ function initViews(basePath) {
2518
2575
  document.getElementById('nav-channels').addEventListener('click', () => showAppView(basePath, 'channels'));
2519
2576
  document.getElementById('nav-a2a').addEventListener('click', () => showAppView(basePath, 'a2a'));
2520
2577
  document.getElementById('nav-scheduler').addEventListener('click', () => showAppView(basePath, 'scheduler'));
2578
+ document.getElementById('btn-diag-refresh').addEventListener('click', () => renderDiagnostics(basePath));
2521
2579
 
2522
2580
  // D8 two-column layout: Claude official = setup-token form; custom API =
2523
2581
  // per-column quick forms. The standalone api-key forms were removed.
@@ -231,6 +231,28 @@
231
231
  </div>
232
232
  </section>
233
233
  </div>
234
+ <section class="settings-section">
235
+ <div class="settings-section-head">
236
+ <h2>诊断</h2>
237
+ <p>服务 / 会话 / 认证 / 最近错误——浏览器内排查,无需 SSH。</p>
238
+ </div>
239
+ <div class="settings-card">
240
+ <div class="card-title">运行时与认证 <span class="channel-state" id="diag-runtime-state"></span></div>
241
+ <div class="cred-state" id="diag-runtime" aria-live="polite">加载中...</div>
242
+ </div>
243
+ <div class="settings-card">
244
+ <div class="card-title">服务状态 <button type="button" class="small-btn" id="btn-diag-refresh">刷新</button></div>
245
+ <div class="cred-state" id="diag-services" aria-live="polite">加载中...</div>
246
+ </div>
247
+ <div class="settings-card">
248
+ <div class="card-title">Agent 会话</div>
249
+ <div class="cred-state" id="diag-sessions" aria-live="polite">加载中...</div>
250
+ </div>
251
+ <div class="settings-card">
252
+ <div class="card-title">最近错误</div>
253
+ <div class="cred-state" id="diag-errors" aria-live="polite">加载中...</div>
254
+ </div>
255
+ </section>
234
256
  </main>
235
257
 
236
258
  <main class="settings-view" id="channels-view" hidden>
@@ -37,7 +37,20 @@ export function envFile() { return path.join(baizeDir(), '.env'); }
37
37
  function configFile() { return path.join(baizeDir(), '.baize', 'config.json'); }
38
38
  function claudeSettings() { return path.join(homeDir(), '.claude', 'settings.json'); }
39
39
  function codexAuth() { return path.join(homeDir(), '.codex', 'auth.json'); }
40
- const CLAUDE_SESSION = process.env.CLAUDE_SESSION || 'claude-main';
40
+ // Session name follows the active runtime: codex runs in 'codex-main',
41
+ // claude in 'claude-main'. Hard-coding 'claude-main' made the web-console
42
+ // restart button report "no tmux session" while the codex session was alive
43
+ // (stuck on login) — a dead end for browser-only recovery.
44
+ function agentSessionName() {
45
+ if (process.env.CLAUDE_SESSION) return process.env.CLAUDE_SESSION;
46
+ let runtime = 'claude';
47
+ try {
48
+ const cfg = JSON.parse(require('node:fs').readFileSync(
49
+ require('node:path').join(homeDir(), 'baize', '.baize', 'config.json'), 'utf8'));
50
+ if (cfg && cfg.runtime) runtime = cfg.runtime;
51
+ } catch { /* default claude */ }
52
+ return runtime === 'codex' ? 'codex-main' : 'claude-main';
53
+ }
41
54
  const CLI = process.env.BAIZE_CLI || 'baize';
42
55
 
43
56
  // ── Low-level helpers ────────────────────────────────────────────────────────
@@ -352,14 +365,15 @@ export async function switchRuntime(name) {
352
365
  * @returns {Promise<{success: boolean, restarted: boolean, reason?: string}>}
353
366
  */
354
367
  export async function restartAgentSession() {
368
+ const session = agentSessionName();
355
369
  try {
356
- await execFileAsync('tmux', ['has-session', '-t', CLAUDE_SESSION], { timeout: 5000 });
370
+ await execFileAsync('tmux', ['has-session', '-t', session], { timeout: 5000 });
357
371
  } catch {
358
372
  return { success: true, restarted: false, reason: 'no_session' };
359
373
  }
360
374
  try {
361
- await execFileAsync('tmux', ['send-keys', '-t', CLAUDE_SESSION, '/exit', 'Enter'], { timeout: 5000 });
362
- return { success: true, restarted: true };
375
+ await execFileAsync('tmux', ['send-keys', '-t', session, '/exit', 'Enter'], { timeout: 5000 });
376
+ return { success: true, restarted: true, session };
363
377
  } catch (err) {
364
378
  return { success: false, restarted: false, reason: err.message };
365
379
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Diagnostics for the web console (D26): aggregate service/session/runtime
3
+ * state so operators can troubleshoot from the browser without SSH.
4
+ *
5
+ * readDiagnostics() returns:
6
+ * services[] — PM2 processes (name, status, restarts, uptime)
7
+ * sessions[] — tmux agent sessions (name, exists, last pane lines)
8
+ * runtime — active runtime (claude/codex) + CLI presence
9
+ * auth — claude/codex credential state (masked, never echoed)
10
+ * errors — tail of each service error log (last lines)
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { execFile } from 'node:child_process';
17
+ import { promisify } from 'node:util';
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ function baizeDir() {
22
+ return process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
23
+ }
24
+
25
+ function readJsonSafe(file) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function maskSecret(secret) {
34
+ if (!secret || typeof secret !== 'string') return null;
35
+ if (secret.length <= 8) return '****';
36
+ return `${secret.slice(0, 4)}••••${secret.slice(-4)}`;
37
+ }
38
+
39
+ /** PM2 process table via `pm2 jlist` (tolerant when pm2 is absent). */
40
+ async function readServices() {
41
+ try {
42
+ const { stdout } = await execFileAsync('pm2', ['jlist'], { timeout: 8000, maxBuffer: 4 * 1024 * 1024 });
43
+ const list = JSON.parse(stdout);
44
+ return list.map((p) => ({
45
+ name: p.name,
46
+ status: p.pm2_env?.status || 'unknown',
47
+ restarts: p.pm2_env?.restart_time ?? 0,
48
+ uptime: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : null,
49
+ cpu: p.monit?.cpu ?? null,
50
+ memory: p.monit?.memory ?? null,
51
+ }));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** tmux agent sessions: existence + last pane lines (for stuck-login detection). */
58
+ async function readSessions() {
59
+ const sessions = [];
60
+ try {
61
+ const { stdout } = await execFileAsync('tmux', ['ls'], { timeout: 5000 });
62
+ const names = stdout.split('\n').map((l) => l.split(':')[0]).filter(Boolean);
63
+ for (const name of names) {
64
+ if (!/(main|agent)/.test(name)) continue;
65
+ let tail = '';
66
+ try {
67
+ const pane = await execFileAsync('tmux', ['capture-pane', '-t', name, '-p', '-S', '-12'], { timeout: 5000 });
68
+ tail = pane.stdout.split('\n').filter((l) => l.trim()).slice(-8).join('\n').slice(0, 600);
69
+ } catch { /* pane read failed */ }
70
+ sessions.push({ name, tail });
71
+ }
72
+ } catch {
73
+ /* no tmux or no sessions */
74
+ }
75
+ return sessions;
76
+ }
77
+
78
+ /** Active runtime from ~/baize/.baize/config.json. */
79
+ function readRuntime() {
80
+ const cfg = readJsonSafe(path.join(baizeDir(), '.baize', 'config.json')) || {};
81
+ return { runtime: cfg.runtime || 'claude', configPresent: !!cfg };
82
+ }
83
+
84
+ /** Credential state (masked). Never echoes secrets. */
85
+ function readAuth() {
86
+ const out = { claude: { configured: false, method: null, masked: null }, codex: { configured: false, method: null, masked: null } };
87
+ // Claude: ~/.claude/settings.json (ANTHROPIC_API_KEY) or env
88
+ try {
89
+ const settings = readJsonSafe(path.join(os.homedir(), '.claude', 'settings.json')) || {};
90
+ const key = settings.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
91
+ if (key) { out.claude.configured = true; out.claude.method = 'api-key'; out.claude.masked = maskSecret(key); }
92
+ } catch { /* ignore */ }
93
+ // Codex: ~/.codex/auth.json (auth_mode apikey/chatgpt)
94
+ const codexAuth = readJsonSafe(path.join(os.homedir(), '.codex', 'auth.json'));
95
+ if (codexAuth) {
96
+ if (codexAuth.auth_mode === 'apikey' && codexAuth.OPENAI_API_KEY) {
97
+ out.codex.configured = true; out.codex.method = 'api-key'; out.codex.masked = maskSecret(codexAuth.OPENAI_API_KEY);
98
+ } else if (codexAuth.tokens?.access_token) {
99
+ out.codex.configured = true; out.codex.method = 'chatgpt-oauth';
100
+ }
101
+ }
102
+ const envKey = process.env.OPENAI_API_KEY;
103
+ if (envKey && !out.codex.configured) { out.codex.configured = true; out.codex.method = 'env'; out.codex.masked = maskSecret(envKey); }
104
+ return out;
105
+ }
106
+
107
+ /** Tail of each service error log under ~/.pm2/logs/. */
108
+ function readErrors() {
109
+ const logsDir = path.join(os.homedir(), '.pm2', 'logs');
110
+ const errors = [];
111
+ try {
112
+ for (const file of fs.readdirSync(logsDir)) {
113
+ if (!/error.*\.log$/.test(file)) continue;
114
+ const full = path.join(logsDir, file);
115
+ try {
116
+ const size = fs.statSync(full).size;
117
+ const fd = fs.openSync(full, 'r');
118
+ const buf = Buffer.alloc(Math.min(size, 3000));
119
+ fs.readSync(fd, buf, 0, buf.length, Math.max(0, size - buf.length));
120
+ fs.closeSync(fd);
121
+ const tail = buf.toString('utf8').split('\n').filter((l) => l.trim()).slice(-6).join('\n');
122
+ if (tail) errors.push({ service: file.replace(/-(error|out)\.log$/, ''), tail: tail.slice(0, 500) });
123
+ } catch { /* unreadable log */ }
124
+ }
125
+ } catch { /* no logs dir */ }
126
+ return errors;
127
+ }
128
+
129
+ /** Full diagnostics payload. */
130
+ export async function readDiagnostics() {
131
+ const [services, sessions] = await Promise.all([readServices(), readSessions()]);
132
+ return {
133
+ success: true,
134
+ at: new Date().toISOString(),
135
+ runtime: readRuntime(),
136
+ services,
137
+ sessions,
138
+ auth: readAuth(),
139
+ errors: readErrors(),
140
+ };
141
+ }
@@ -77,6 +77,7 @@ import {
77
77
  getSchedulerTasks,
78
78
  } from './a2a-admin.js';
79
79
  import { listInstalledSkills } from './skill-catalog.js';
80
+ import { readDiagnostics } from './diagnostics.js';
80
81
 
81
82
  const __filename = fileURLToPath(import.meta.url);
82
83
  const __dirname = path.dirname(__filename);
@@ -825,6 +826,14 @@ app.post('/api/admin/codex-key', async (req, res) => {
825
826
  }
826
827
  });
827
828
 
829
+ app.get('/api/admin/diagnostics', async (req, res) => {
830
+ try {
831
+ res.json(await readDiagnostics());
832
+ } catch (err) {
833
+ jsonError(res, err);
834
+ }
835
+ });
836
+
828
837
  app.post('/api/admin/runtime', async (req, res) => {
829
838
  try {
830
839
  const result = await switchRuntime(req.body?.runtime);