diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html
index a8f517e..944d662 100644
--- a/dashboard/dashboard.html
+++ b/dashboard/dashboard.html
@@ -361,6 +361,37 @@
.toast.ok{border-color:#2ecc8a55;background:#0a1a10}.toast.err{border-color:#ff527055;background:#200a10}
@keyframes tin{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
+ /* ══ AGENT STATUS PANEL ══ */
+ .as-panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 18px;margin-bottom:16px}
+ .as-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+ .as-title{font-size:13px;font-weight:700;color:var(--fg)}
+ .as-gw{font-size:11px;padding:3px 10px;border-radius:999px;margin-left:auto}
+ .as-gw.ok{background:#0a2018;border:1px solid #2ecc8a44;color:var(--ok)}
+ .as-gw.err{background:#200a10;border:1px solid #ff527044;color:var(--danger)}
+ .as-gw.warn{background:#201a08;border:1px solid #f5c84244;color:var(--warn)}
+ .as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px}
+ .as-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;position:relative}
+ .as-card:hover{border-color:var(--acc);background:#0a1228}
+ .as-card .as-emoji{font-size:22px;margin-bottom:3px}
+ .as-card .as-label{font-size:12px;font-weight:700;color:var(--fg)}
+ .as-card .as-role{font-size:10px;color:var(--muted)}
+ .as-card .as-status{font-size:10px;margin-top:4px}
+ .as-card .as-time{font-size:9px;color:var(--muted);margin-top:2px}
+ .as-card .as-dot{position:absolute;top:6px;right:6px;width:8px;height:8px;border-radius:50%}
+ .as-dot.running{background:#2ecc8a;box-shadow:0 0 6px #2ecc8a88;animation:pulse 1.5s infinite}
+ .as-dot.idle{background:#4a5568}
+ .as-dot.offline{background:#ff5270;animation:pulse 1.2s infinite}
+ .as-dot.unconfigured{background:#6b7280}
+ .as-wake-btn{font-size:10px;padding:2px 8px;border-radius:6px;border:1px solid var(--acc);color:var(--acc);background:transparent;cursor:pointer;margin-top:6px;transition:background .15s}
+ .as-wake-btn:hover{background:var(--acc);color:#fff}
+ .as-wake-btn:disabled{opacity:.4;cursor:not-allowed}
+ .as-refresh{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--line);color:var(--muted);background:transparent;cursor:pointer;transition:background .15s}
+ .as-refresh:hover{background:var(--panel2);color:var(--fg)}
+ .as-wake-all{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--warn);color:var(--warn);background:transparent;cursor:pointer;transition:background .15s;margin-left:6px}
+ .as-wake-all:hover{background:var(--warn);color:#fff}
+ .as-summary{font-size:11px;color:var(--muted);display:flex;gap:12px;margin-top:10px;padding-top:8px;border-top:1px solid var(--line)}
+ .as-summary span{display:flex;align-items:center;gap:4px}
+
/* ══ OFFICIALS ══ */
/* activity bar */
.off-activity{display:flex;align-items:center;gap:8px;padding:8px 14px;background:#0a1228;border:1px solid #1a2a4a;border-radius:10px;margin-bottom:14px;font-size:12px;flex-wrap:wrap}
@@ -623,6 +654,7 @@
+
各省部当前承接旨意与执行状态 · 每5秒自动刷新
@@ -922,6 +954,96 @@ function renderEdicts(tasks){
}).join('');
}
+/* ══ AGENT STATUS PANEL ══ */
+let _agentsStatusData = null;
+let _agentsStatusLoading = false;
+
+async function loadAgentsStatus(){
+ if(_agentsStatusLoading) return;
+ _agentsStatusLoading = true;
+ try{
+ _agentsStatusData = await fetchJ(API+'/agents-status');
+ renderAgentsStatus(_agentsStatusData);
+ }catch(e){
+ document.getElementById('agent-status-panel').innerHTML=
+ '';
+ }finally{
+ _agentsStatusLoading = false;
+ }
+}
+
+function renderAgentsStatus(data){
+ if(!data||!data.ok) return;
+ const gw = data.gateway||{};
+ const agents = data.agents||[];
+ // 跳过 main(和 taizi 重复)
+ const filtered = agents.filter(a=>a.id!=='main');
+ const running = filtered.filter(a=>a.status==='running').length;
+ const idle = filtered.filter(a=>a.status==='idle').length;
+ const offline = filtered.filter(a=>a.status==='offline').length;
+ const unconf = filtered.filter(a=>a.status==='unconfigured').length;
+ const gwCls = gw.probe?'ok':gw.alive?'warn':'err';
+
+ document.getElementById('agent-status-panel').innerHTML=`
+
+
+
+ ${filtered.map(a=>{
+ const dotCls = a.status;
+ const canWake = a.status!=='running' && a.status!=='unconfigured' && gw.alive;
+ return `
+
+
${a.emoji}
+
${esc(a.label)}
+
${esc(a.role)}
+
${esc(a.statusLabel)}
+ ${a.lastActive?`
⏰ ${esc(a.lastActive)}
`:'
无活动记录
'}
+ ${canWake?`
`:''}
+
`;
+ }).join('')}
+
+
+ ${running} 运行中
+ ${idle} 待命
+ ${offline?` ${offline} 离线`:''}
+ ${unconf?` ${unconf} 未配置`:''}
+ 检测于 ${(data.checkedAt||'').substring(11,19)}
+
+
`;
+}
+
+async function wakeAgent(agentId, btn){
+ if(btn){ btn.disabled=true; btn.textContent='⏳ 唤醒中...'; }
+ try{
+ const r = await postJ(API+'/agent-wake', {agentId});
+ toast(r.message||'唤醒指令已发出','ok');
+ // 30秒后自动刷新状态
+ setTimeout(()=>loadAgentsStatus(), 30000);
+ }catch(e){
+ toast('唤醒失败: '+e.message,'err');
+ if(btn){ btn.disabled=false; btn.textContent='⚡ 唤醒'; }
+ }
+}
+
+async function wakeAllAgents(){
+ if(!_agentsStatusData) return;
+ const toWake = (_agentsStatusData.agents||[]).filter(a=>
+ a.id!=='main' && a.status!=='running' && a.status!=='unconfigured'
+ );
+ if(!toWake.length){ toast('所有 Agent 均已在线','ok'); return; }
+ toast(`正在唤醒 ${toWake.length} 个 Agent...`,'ok',5000);
+ for(const a of toWake){
+ try{ await postJ(API+'/agent-wake', {agentId: a.id}); }catch(e){}
+ }
+ toast(`${toWake.length} 个唤醒指令已发出,30秒后刷新状态`,'ok',5000);
+ setTimeout(()=>loadAgentsStatus(), 30000);
+}
+
/* ══ DEPT MONITOR ══ */
function renderMonitor(tasks){
const DEPTS = [
@@ -2261,6 +2383,7 @@ document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',()=>{
document.getElementById('panel-'+activeTab).classList.add('active');
if(['models','skills'].includes(activeTab))loadAgentConfig();
if(activeTab==='officials')loadOfficials();
+ if(activeTab==='monitor')loadAgentsStatus();
if(activeTab==='morning')loadMorning();
if(activeTab==='sessions')renderSessions(liveStatus?liveStatus.tasks:[]);
if(activeTab==='memorials')renderMemorials();
@@ -2617,6 +2740,16 @@ async function executeTemplate(e, tplId){
const cmd = buildCommand(tplId);
if(!cmd.trim()){ toast('请填写必填参数','err'); return; }
const t = TEMPLATES.find(x=>x.id===tplId);
+
+ // 前置校验:检测 Gateway 和关键 Agent 是否在线
+ try{
+ const st = await fetchJ(API+'/agents-status');
+ if(st.ok && st.gateway && !st.gateway.alive){
+ toast('⚠️ Gateway 未启动,任务将无法派发!请先运行 openclaw gateway start','err',6000);
+ if(!confirm('Gateway 未启动,任务创建后将无法自动派发。是否仍然继续?')) return;
+ }
+ }catch(ex){/* 检测失败不阻塞创建 */}
+
// Show the generated command in a confirmation
if(!confirm(`确认下旨?\n\n${cmd.substring(0,200)}${cmd.length>200?'…':''}`)) return;
// 通过 API 创建真实任务
diff --git a/dashboard/server.py b/dashboard/server.py
index c0cee64..3c67868 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -325,6 +325,10 @@ def handle_create_task(title, org='中书省', official='中书令', priority='n
# 发送给 main (太子) 而不是 zhongshu,让太子走正常流程分拣→中书省
def dispatch_to_agent():
try:
+ # 前置检查 Gateway 是否在线
+ if not _check_gateway_alive():
+ log.warning(f'⚠️ {task_id} 派发跳过: Gateway 未启动')
+ return
msg = (
f'📜 皇上新旨意(已录入看板,请直接处理)\n'
f'任务ID: {task_id}\n'
@@ -390,6 +394,204 @@ def handle_review_action(task_id, action, comment=''):
return {'ok': True, 'message': f'{task_id} {label}'}
+# ══ Agent 在线状态检测 ══
+
+_AGENT_DEPTS = [
+ {'id':'main', 'label':'太子', 'emoji':'🤴', 'role':'太子', 'rank':'储君'},
+ {'id':'taizi', 'label':'太子', 'emoji':'🤴', 'role':'太子', 'rank':'储君'},
+ {'id':'zhongshu','label':'中书省','emoji':'📜', 'role':'中书令', 'rank':'正一品'},
+ {'id':'menxia', 'label':'门下省','emoji':'🔍', 'role':'侍中', 'rank':'正一品'},
+ {'id':'shangshu','label':'尚书省','emoji':'📮', 'role':'尚书令', 'rank':'正一品'},
+ {'id':'hubu', 'label':'户部', 'emoji':'💰', 'role':'户部尚书', 'rank':'正二品'},
+ {'id':'libu', 'label':'礼部', 'emoji':'📝', 'role':'礼部尚书', 'rank':'正二品'},
+ {'id':'bingbu', 'label':'兵部', 'emoji':'⚔️', 'role':'兵部尚书', 'rank':'正二品'},
+ {'id':'xingbu', 'label':'刑部', 'emoji':'⚖️', 'role':'刑部尚书', 'rank':'正二品'},
+ {'id':'gongbu', 'label':'工部', 'emoji':'🔧', 'role':'工部尚书', 'rank':'正二品'},
+ {'id':'libu_hr', 'label':'吏部', 'emoji':'👔', 'role':'吏部尚书', 'rank':'正二品'},
+ {'id':'zaochao', 'label':'钦天监','emoji':'📰', 'role':'朝报官', 'rank':'正三品'},
+]
+
+
+def _check_gateway_alive():
+ """检测 Gateway 进程是否在运行。"""
+ try:
+ result = subprocess.run(['pgrep', '-f', 'openclaw-gateway'],
+ capture_output=True, text=True, timeout=5)
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+def _check_gateway_probe():
+ """通过 HTTP probe 检测 Gateway 是否响应。"""
+ try:
+ from urllib.request import urlopen
+ resp = urlopen('http://127.0.0.1:18789/', timeout=3)
+ return resp.status == 200
+ except Exception:
+ return False
+
+
+def _get_agent_session_status(agent_id):
+ """读取 Agent 的 sessions.json 获取活跃状态。
+ 返回: (last_active_ts_ms, session_count, is_busy)
+ """
+ sessions_file = OCLAW_HOME / 'agents' / agent_id / 'sessions' / 'sessions.json'
+ if not sessions_file.exists() and agent_id == 'taizi':
+ sessions_file = OCLAW_HOME / 'agents' / 'main' / 'sessions' / 'sessions.json'
+ if not sessions_file.exists():
+ return 0, 0, False
+ try:
+ data = json.loads(sessions_file.read_text())
+ if not isinstance(data, dict):
+ return 0, 0, False
+ session_count = len(data)
+ last_ts = 0
+ for v in data.values():
+ ts = v.get('updatedAt', 0)
+ if isinstance(ts, (int, float)) and ts > last_ts:
+ last_ts = ts
+ now_ms = int(datetime.datetime.now().timestamp() * 1000)
+ age_ms = now_ms - last_ts if last_ts else 9999999999
+ is_busy = age_ms <= 2 * 60 * 1000 # 2分钟内视为正在工作
+ return last_ts, session_count, is_busy
+ except Exception:
+ return 0, 0, False
+
+
+def _check_agent_process(agent_id):
+ """检测是否有该 Agent 的 openclaw-agent 进程正在运行。"""
+ try:
+ result = subprocess.run(
+ ['pgrep', '-f', f'openclaw.*--agent.*{agent_id}'],
+ capture_output=True, text=True, timeout=5
+ )
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+def _check_agent_workspace(agent_id):
+ """检查 Agent 工作空间是否存在。"""
+ ws = OCLAW_HOME / f'workspace-{agent_id}'
+ return ws.is_dir()
+
+
+def get_agents_status():
+ """获取所有 Agent 的在线状态。
+ 返回各 Agent 的:
+ - status: 'running' | 'idle' | 'offline' | 'unconfigured'
+ - lastActive: 最后活跃时间
+ - sessions: 会话数
+ - hasWorkspace: 工作空间是否存在
+ - processAlive: 是否有进程在运行
+ """
+ gateway_alive = _check_gateway_alive()
+ gateway_probe = _check_gateway_probe() if gateway_alive else False
+
+ agents = []
+ seen_ids = set()
+ for dept in _AGENT_DEPTS:
+ aid = dept['id']
+ if aid in seen_ids:
+ continue
+ seen_ids.add(aid)
+
+ has_workspace = _check_agent_workspace(aid)
+ last_ts, sess_count, is_busy = _get_agent_session_status(aid)
+ process_alive = _check_agent_process(aid)
+
+ # 状态判定
+ if not has_workspace:
+ status = 'unconfigured'
+ status_label = '❌ 未配置'
+ elif not gateway_alive:
+ status = 'offline'
+ status_label = '🔴 Gateway 离线'
+ elif process_alive or is_busy:
+ status = 'running'
+ status_label = '🟢 运行中'
+ elif last_ts > 0:
+ now_ms = int(datetime.datetime.now().timestamp() * 1000)
+ age_ms = now_ms - last_ts
+ if age_ms <= 10 * 60 * 1000: # 10分钟内
+ status = 'idle'
+ status_label = '🟡 待命'
+ elif age_ms <= 3600 * 1000: # 1小时内
+ status = 'idle'
+ status_label = '⚪ 空闲'
+ else:
+ status = 'idle'
+ status_label = '⚪ 休眠'
+ else:
+ status = 'idle'
+ status_label = '⚪ 无记录'
+
+ # 格式化最后活跃时间
+ last_active_str = None
+ if last_ts > 0:
+ try:
+ last_active_str = datetime.datetime.fromtimestamp(
+ last_ts / 1000
+ ).strftime('%m-%d %H:%M')
+ except Exception:
+ pass
+
+ agents.append({
+ 'id': aid,
+ 'label': dept['label'],
+ 'emoji': dept['emoji'],
+ 'role': dept['role'],
+ 'status': status,
+ 'statusLabel': status_label,
+ 'lastActive': last_active_str,
+ 'lastActiveTs': last_ts,
+ 'sessions': sess_count,
+ 'hasWorkspace': has_workspace,
+ 'processAlive': process_alive,
+ })
+
+ return {
+ 'ok': True,
+ 'gateway': {
+ 'alive': gateway_alive,
+ 'probe': gateway_probe,
+ 'status': '🟢 运行中' if gateway_probe else ('🟡 进程在但无响应' if gateway_alive else '🔴 未启动'),
+ },
+ 'agents': agents,
+ 'checkedAt': now_iso(),
+ }
+
+
+def wake_agent(agent_id, message=''):
+ """唤醒指定 Agent,发送一条心跳/唤醒消息。"""
+ if not _SAFE_NAME_RE.match(agent_id):
+ return {'ok': False, 'error': f'agent_id 非法: {agent_id}'}
+ if not _check_agent_workspace(agent_id):
+ return {'ok': False, 'error': f'{agent_id} 工作空间不存在,请先配置'}
+ if not _check_gateway_alive():
+ return {'ok': False, 'error': 'Gateway 未启动,请先运行 openclaw gateway start'}
+
+ # 确定实际 agent id(taizi 用 main)
+ runtime_id = 'main' if agent_id == 'taizi' else agent_id
+ msg = message or f'🔔 系统心跳检测 — 请回复 OK 确认在线。当前时间: {now_iso()}'
+
+ def do_wake():
+ try:
+ cmd = ['openclaw', 'agent', '--agent', runtime_id, '-m', msg, '--timeout', '120']
+ log.info(f'🔔 唤醒 {agent_id} ({runtime_id})...')
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=130)
+ if result.returncode == 0:
+ log.info(f'✅ {agent_id} 已唤醒')
+ else:
+ log.warning(f'⚠️ {agent_id} 唤醒失败: {result.stderr[:200]}')
+ except Exception as e:
+ log.warning(f'⚠️ {agent_id} 唤醒异常: {e}')
+ threading.Thread(target=do_wake, daemon=True).start()
+
+ return {'ok': True, 'message': f'{agent_id} 唤醒指令已发出,约10-30秒后生效'}
+
+
# ══ Agent 实时活动读取 ══
# 状态 → agent_id 映射
@@ -1201,6 +1403,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({'ok': False, 'error': 'task_id required'}, 400)
else:
self.send_json(get_task_activity(task_id))
+ elif p == '/api/agents-status':
+ self.send_json(get_agents_status())
elif p.startswith('/api/agent-activity/'):
agent_id = p.replace('/api/agent-activity/', '')
if not agent_id or not _SAFE_NAME_RE.match(agent_id):
@@ -1355,6 +1559,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(result)
return
+ if p == '/api/agent-wake':
+ agent_id = body.get('agentId', '').strip()
+ message = body.get('message', '').strip()
+ if not agent_id:
+ self.send_json({'ok': False, 'error': 'agentId required'}, 400)
+ return
+ result = wake_agent(agent_id, message)
+ self.send_json(result)
+ return
+
if p == '/api/set-model':
agent_id = body.get('agentId', '').strip()
model = body.get('model', '').strip()