Compare commits

..

4 Commits

Author SHA1 Message Date
998dfc9f8e ops: pin node 24 from nvm in start script
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:21:06 +08:00
e5d9799018 feat(ui): GPU + Live Agents dashboard, dark theme
- drop quota / repos / headless-task panels; page is now GPU board +
  Live Agents (frontend only, headless task API still available)
- Live Agents: grouped by tmux session, launch form (engine/session/cwd/
  window name), open-terminal link per agent
- compact GPU board: multi-column host cards, fixed 4-per-row GPU thumbs
  with status glow, expanded host spans full row
- permanent dark theme tuned for a monitoring dashboard; terminal-styled
  session group headers; refresh now polls three light endpoints instead
  of the full dashboard aggregate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:20:52 +08:00
cd1e9e09e7 ops: ttyd web terminal sidecar behind nginx auth_request
- tmux-web-attach.sh: attach browser to one window via link-window into a
  private temp session (no session-group pollution); window-size smallest
  while attached so the browser never gets cut off; mouse on for natural
  wheel/touch scrolling; status bar off; destroy-unattached cleanup
- start-ttyd.sh: ttyd on 127.0.0.1:7681, base path /term, url-arg enabled,
  Tokyo Night theme, system mono stack, fontSize 14
- systemd user unit + install script registration
- nginx: /term/ proxied with websocket upgrade, gated by auth_request to
  the kanban cookie check; unauthenticated -> 302 /login

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:20:40 +08:00
f7d6df8756 feat: tmux live agent discovery and launch API
- src/services/tmux.js: parse tmux panes (claude/node->codex engine match),
  collect running agents, launch agent in new or existing session via
  new-window/new-session + send-keys (window survives agent exit)
- routes: GET/POST /api/tmux/agents, GET /api/auth/check (for nginx
  auth_request), liveAgents in /api/dashboard aggregate
- tests for the pure parser

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:20:26 +08:00
12 changed files with 602 additions and 525 deletions

View File

@@ -45,6 +45,31 @@ http {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
} }
location = /api/auth/check {
internal;
proxy_pass http://127.0.0.1:5180;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-Proto https;
}
location /term/ {
auth_request /api/auth/check;
error_page 401 = @term_login;
proxy_pass http://127.0.0.1:7681;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 6h;
proxy_send_timeout 6h;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location @term_login {
return 302 /login;
}
location / { location / {
proxy_pass http://127.0.0.1:5180; proxy_pass http://127.0.0.1:5180;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Local Kanban ttyd web terminal (tmux attach)
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gahow/projects/kanban
ExecStart=/home/gahow/projects/kanban/scripts/start-ttyd.sh
Restart=always
RestartSec=3
[Install]
WantedBy=default.target

View File

@@ -1,15 +1,7 @@
let state = { let state = {
gpus: [], gpus: [],
quotas: [], liveAgents: { ok: false, agents: [] },
repos: [],
branches: [],
agentProfiles: [],
tasks: [],
selectedRepoId: null,
selectedBranch: null,
expandedGpuHosts: new Set(), expandedGpuHosts: new Set(),
expandedTasks: new Set(),
expandedTerminals: new Set(),
settings: { gpuHosts: [] }, settings: { gpuHosts: [] },
errors: {} errors: {}
}; };
@@ -31,47 +23,6 @@ function escapeHtml(value) {
}[char])); }[char]));
} }
function formatDate(value) {
if (!value) return 'N/A';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'N/A';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function formatDuration(start, end) {
const from = new Date(start).getTime();
const to = new Date(end).getTime();
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return '';
const seconds = Math.max(1, Math.round((to - from) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours}h ${rest}m` : `${hours}h`;
}
function statusLabel(status) {
return {
running: '运行中',
completed: '已完成',
failed: '失败'
}[status] || status || '未知';
}
function canResumeTask(task) {
return Boolean(task.sessionId && task.status !== 'running');
}
function shortSession(sessionId) {
return sessionId ? sessionId.slice(0, 8) : '无 Session';
}
function isGpuIdle(gpu) { function isGpuIdle(gpu) {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5; return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5;
@@ -113,16 +64,13 @@ async function request(path, options) {
const ERROR_LABELS = { const ERROR_LABELS = {
settings: '设置', settings: '设置',
gpus: 'GPU', gpus: 'GPU',
quotas: '额度', liveAgents: 'Live Agents'
repos: '项目列表',
agentProfiles: 'Agent 配置',
tasks: '任务历史'
}; };
function renderErrorBanner() { function renderErrorBanner() {
const banner = $('errorBanner'); const banner = $('errorBanner');
if (!banner) return; if (!banner) return;
const entries = Object.entries(state.errors || {}); const entries = Object.entries(state.errors || {}).filter(([key]) => ERROR_LABELS[key]);
if (!entries.length) { if (!entries.length) {
banner.hidden = true; banner.hidden = true;
banner.innerHTML = ''; banner.innerHTML = '';
@@ -208,246 +156,44 @@ function renderGpus() {
}).join('') : '<div class="empty-state">还没有配置 GPU 机器</div>'; }).join('') : '<div class="empty-state">还没有配置 GPU 机器</div>';
} }
function renderQuotas() { function renderLiveAgents() {
const okSources = state.quotas.filter((quota) => quota.ok).length; const agents = state.liveAgents?.agents || [];
$('quotaSummary').textContent = state.quotas.length ? `${okSources}/${state.quotas.length} 可用` : '未配置'; $('liveAgentSummary').textContent = agents.length ? `${agents.length} 个运行中` : '无运行中';
$('quotaBoard').innerHTML = state.quotas.length ? state.quotas.map((quota) => { if (!agents.length) {
const summary = quota.summary || {}; $('liveAgentBoard').innerHTML = '<div class="empty-state">tmux 中没有正在运行的 agent</div>';
const limit = summary.limit ?? null; return;
const used = summary.used ?? null; }
const remaining = summary.remaining ?? null; const groups = new Map();
const usedPercent = limit && used !== null ? percent(used, limit) : null; for (const agent of agents) {
return `<article class="quota-card"> if (!groups.has(agent.session)) groups.set(agent.session, []);
<div class="host-title"> groups.get(agent.session).push(agent);
<span>${escapeHtml(quota.label)}</span> }
<span class="${quota.ok ? 'status-ok' : 'status-warn'}">${quota.ok ? '已更新' : '未配置'}</span> $('liveAgentBoard').innerHTML = [...groups.entries()].map(([session, members]) => {
</div> const rows = members.map((agent) => {
${quota.ok ? ` const termUrl = `/term/?arg=${encodeURIComponent(agent.session)}&arg=${encodeURIComponent(agent.windowIndex)}`;
<div class="metric-row"><span class="muted">剩余</span><strong>${remaining ?? 'N/A'}</strong></div> return `<article class="agent-card">
<div class="metric-row"><span class="muted">已用</span><strong>${used ?? 'N/A'}</strong></div> <span class="agent-chip ${escapeHtml(agent.engine)}">${escapeHtml(agent.engine)}</span>
${usedPercent === null ? '' : `<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${usedPercent}"></meter>`} <span class="agent-card-main">
` : `<div class="repo-meta">${escapeHtml(quota.error || '尚未配置')}</div>`} <strong>:${agent.windowIndex}</strong>
<span class="muted">${escapeHtml(agent.windowName)}</span>
</span>
<span class="agent-card-cwd muted" title="${escapeHtml(agent.cwd)}">${escapeHtml(agent.cwd)}</span>
<a class="secondary-button small" href="${escapeHtml(termUrl)}" target="_blank" rel="noopener">打开终端</a>
</article>`; </article>`;
}).join('') : '<div class="empty-state">还没有配置额度数据源</div>'; }).join('');
} return `<section class="agent-group">
<div class="agent-group-heading">
function renderRepos() { <strong>${escapeHtml(session)}</strong>
const selectedRepo = state.repos.find((repo) => repo.id === state.selectedRepoId);
$('repoSummary').textContent = state.repos.length
? (selectedRepo ? `${state.repos.length} 个 · 当前 ${selectedRepo.fullName}` : `${state.repos.length}`)
: '暂无';
$('repoBoard').innerHTML = state.repos.length ? state.repos.map((repo) => {
const selected = repo.id === state.selectedRepoId ? ' selected' : '';
return `<article class="repo-card${selected}" data-id="${escapeHtml(repo.id)}">
<div class="repo-title">
<span>${escapeHtml(repo.fullName)}</span>
<span class="chip">${escapeHtml(repo.defaultBranch)}</span>
</div> </div>
${repo.description ? `<div class="repo-meta">${escapeHtml(repo.description)}</div>` : ''} <div class="agent-group-list">${rows}</div>
<div class="repo-meta">${escapeHtml(repo.cloneUrl)}</div>
</article>`;
}).join('') : '<div class="empty-state">无法读取项目,请检查 Gitea 配置</div>';
renderAgentContext();
}
function renderAgentContext() {
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
const repoEl = $('agentContextRepo');
const branchEl = $('agentContextBranch');
if (!repoEl || !branchEl) return;
if (repo) {
repoEl.textContent = repo.fullName;
repoEl.classList.remove('empty');
} else {
repoEl.textContent = '从左侧选择一个项目';
repoEl.classList.add('empty');
}
if (state.selectedBranch) {
branchEl.textContent = state.selectedBranch;
branchEl.hidden = false;
} else {
branchEl.hidden = true;
branchEl.textContent = '';
}
}
function renderBranches() {
$('branchSelect').innerHTML = state.branches.length ? state.branches.map((branch) => {
const selected = branch.name === state.selectedBranch ? 'selected' : '';
return `<option value="${escapeHtml(branch.name)}" ${selected}>${escapeHtml(branch.name)}</option>`;
}).join('') : '<option value="">无可用 Branch</option>';
renderAgentContext();
}
function renderProfiles() {
$('profileSelect').innerHTML = state.agentProfiles.map((profile) => (
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.label || profile.id)}</option>`
)).join('');
}
function taskMatchesSelection(task) {
return task.repoFullName === state.selectedRepoId && task.branch === state.selectedBranch && canResumeTask(task);
}
function renderResumeTasks() {
const options = state.tasks.filter(taskMatchesSelection);
const prev = $('resumeTaskSelect').value;
$('resumeTaskSelect').innerHTML = [
'<option value="">新 Session</option>',
...options.map((task) => {
const prompt = task.prompt ? ` · ${task.prompt.slice(0, 40)}` : '';
const label = `${formatDate(task.finishedAt || task.createdAt)} · ${statusLabel(task.status)} · ${shortSession(task.sessionId)}${prompt}`;
return `<option value="${escapeHtml(task.id)}">${escapeHtml(label)}</option>`;
})
].join('');
if (prev && options.some((task) => task.id === prev)) {
$('resumeTaskSelect').value = prev;
}
$('resumeTaskButton').disabled = options.length === 0;
}
function renderTerminal(label, content, tone = '', expanded = true, terminalKey = '') {
if (!content) return '';
const lineCount = content.split('\n').length;
const body = `<pre class="terminal-body" tabindex="0">${escapeHtml(content)}</pre>`;
if (!expanded) {
const openAttr = terminalKey && state.expandedTerminals.has(terminalKey) ? ' open' : '';
return `<details class="terminal ${tone}" data-terminal-key="${escapeHtml(terminalKey)}"${openAttr}>
<summary class="terminal-head">
<span>${label}</span>
<span>${lineCount} lines</span>
</summary>
${body}
</details>`;
}
return `<section class="terminal ${tone}">
<div class="terminal-head">
<span>${label}</span>
<span>${lineCount} lines</span>
</div>
${body}
</section>`; </section>`;
} }).join('');
function taskFullBody(task, { expandedTerminals }) {
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
return `
<div class="task-head">
<div>
<strong>${escapeHtml(task.profileLabel)}</strong>
<div class="task-meta">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</div>
</div>
<div class="task-actions">
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
</div>
</div>
<div class="task-facts">
<span>${formatDate(task.createdAt)}</span>
${task.finishedAt ? `<span>完成 ${formatDate(task.finishedAt)}</span>` : ''}
${duration ? `<span>耗时 ${escapeHtml(duration)}</span>` : ''}
${Number.isInteger(task.exitCode) ? `<span>Exit ${task.exitCode}</span>` : ''}
${task.sessionId ? `<span class="session-id">Session ${escapeHtml(shortSession(task.sessionId))}</span>` : ''}
${task.parentTaskId ? `<span>续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}</span>` : ''}
</div>
<div class="task-meta workspace-path">${escapeHtml(task.workspace)}</div>
<div class="task-prompt">${escapeHtml(task.prompt)}</div>
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals, `${task.id}:stderr`)}
${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)}
`;
}
function renderActiveTaskCard(task) {
return `<article class="task-card active-task status-${escapeHtml(task.status)}">
${taskFullBody(task, { expandedTerminals: true })}
</article>`;
}
function renderHistoryTaskCard(task) {
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
const promptPreview = (task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 100);
const resumeButton = canResumeTask(task)
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
: '';
const openAttr = state.expandedTasks.has(task.id) ? ' open' : '';
return `<details class="task-card history-task status-${escapeHtml(task.status)}" data-task-id="${escapeHtml(task.id)}"${openAttr}>
<summary class="task-summary">
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
<span class="task-summary-main">
<span class="task-summary-title">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</span>
${promptPreview ? `<span class="task-summary-prompt muted">${escapeHtml(promptPreview)}</span>` : ''}
</span>
<span class="task-summary-resume">${resumeButton}</span>
<span class="task-summary-meta">
<span class="muted task-summary-date">${formatDate(task.finishedAt || task.createdAt)}</span>
<span class="muted task-summary-duration">${duration ? escapeHtml(duration) : ''}</span>
</span>
<span class="task-summary-chevron" aria-hidden="true"></span>
</summary>
<div class="task-body">
${taskFullBody(task, { expandedTerminals: false })}
</div>
</details>`;
}
function renderTaskCard(task) {
return task.status === 'running' ? renderActiveTaskCard(task) : renderHistoryTaskCard(task);
}
function renderTasks() {
const running = state.tasks.filter((task) => task.status === 'running').length;
const completed = state.tasks.filter((task) => task.status === 'completed').length;
const failed = state.tasks.filter((task) => task.status === 'failed').length;
$('taskSummary').textContent = `${running} 运行中 · ${completed} 已完成 · ${failed} 失败`;
const activeTasks = state.tasks.filter((task) => task.status === 'running');
const historyTasks = state.tasks.filter((task) => task.status !== 'running');
const sections = [];
if (activeTasks.length) {
sections.push(`<section class="task-section">
<div class="task-section-heading"><h3>运行中</h3><span class="muted">${activeTasks.length} 个</span></div>
<div class="task-list">${activeTasks.map(renderTaskCard).join('')}</div>
</section>`);
}
if (historyTasks.length) {
sections.push(`<section class="task-section">
<div class="task-section-heading"><h3>历史 Session</h3><span class="muted">${historyTasks.length} 个</span></div>
<div class="task-list history-list">${historyTasks.map(renderTaskCard).join('')}</div>
</section>`);
}
$('taskBoard').innerHTML = sections.join('') || '<div class="empty-state">暂无 Session</div>';
} }
function render() { function render() {
if (!state.selectedRepoId && state.repos[0]) state.selectedRepoId = state.repos[0].id;
renderErrorBanner(); renderErrorBanner();
renderGpus(); renderGpus();
renderQuotas(); renderLiveAgents();
renderRepos();
renderBranches();
renderProfiles();
renderResumeTasks();
renderTasks();
}
async function loadBranches() {
if (!state.selectedRepoId) {
state.branches = [];
state.selectedBranch = null;
renderBranches();
return;
}
try {
state.branches = await request(`/api/branches?repo=${encodeURIComponent(state.selectedRepoId)}`);
} catch (error) {
state.branches = [];
toast(`读取 branches 失败: ${error.message}`, 'error');
}
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
if (!state.branches.some((branch) => branch.name === state.selectedBranch)) {
state.selectedBranch = state.branches.find((branch) => branch.name === repo?.defaultBranch)?.name || state.branches[0]?.name || null;
}
renderBranches();
renderResumeTasks();
} }
async function refresh() { async function refresh() {
@@ -455,10 +201,25 @@ async function refresh() {
button.disabled = true; button.disabled = true;
button.classList.add('loading'); button.classList.add('loading');
try { try {
const data = await request('/api/dashboard'); const [settings, gpus, liveAgents] = await Promise.allSettled([
state = { ...state, ...data, errors: data.errors || {} }; request('/api/settings'),
request('/api/gpus'),
request('/api/tmux/agents')
]);
const errors = {};
const value = (result, fallback, key) => {
if (result.status === 'fulfilled') return result.value;
errors[key] = String(result.reason?.message || result.reason || 'unknown error');
return fallback;
};
state = {
...state,
settings: value(settings, state.settings, 'settings'),
gpus: value(gpus, [], 'gpus'),
liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'),
errors
};
render(); render();
await loadBranches();
} catch (error) { } catch (error) {
toast(`刷新失败: ${error.message}`, 'error'); toast(`刷新失败: ${error.message}`, 'error');
} finally { } finally {
@@ -551,105 +312,35 @@ $('gpuHostsChips').addEventListener('click', async (event) => {
await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`); await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`);
}); });
$('repoBoard').addEventListener('click', (event) => { $('liveAgentForm').addEventListener('submit', async (event) => {
const card = event.target.closest('.repo-card');
if (!card) return;
state.selectedRepoId = card.dataset.id;
state.selectedBranch = null;
renderRepos();
loadBranches();
});
$('branchSelect').addEventListener('change', (event) => {
state.selectedBranch = event.target.value;
renderResumeTasks();
renderAgentContext();
});
async function selectResumeTask(taskId) {
const task = state.tasks.find((item) => item.id === taskId);
if (!task) return;
state.selectedRepoId = task.repoFullName;
state.selectedBranch = task.branch;
renderRepos();
await loadBranches();
$('resumeTaskSelect').value = task.id;
$('taskForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('promptInput').focus();
}
$('taskBoard').addEventListener('click', (event) => {
const button = event.target.closest('[data-resume-task]');
if (!button) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation();
selectResumeTask(button.dataset.resumeTask);
});
$('taskBoard').addEventListener('toggle', (event) => {
const target = event.target;
if (!(target instanceof HTMLDetailsElement)) return;
if (target.matches('details.history-task')) {
const id = target.dataset.taskId;
if (!id) return;
if (target.open) state.expandedTasks.add(id);
else state.expandedTasks.delete(id);
return;
}
if (target.matches('details.terminal')) {
const key = target.dataset.terminalKey;
if (!key) return;
if (target.open) state.expandedTerminals.add(key);
else state.expandedTerminals.delete(key);
}
}, true);
async function submitTask(resume) {
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
const prompt = $('promptInput').value.trim();
if (!repo) {
toast('请先从左侧选择一个项目', 'warn');
return;
}
if (!prompt) {
toast('请输入命令', 'warn');
return;
}
const payload = { const payload = {
repo, engine: $('liveAgentEngine').value,
branch: $('branchSelect').value, session: $('liveAgentSession').value.trim(),
profileId: $('profileSelect').value, cwd: $('liveAgentCwd').value.trim(),
prompt, windowName: $('liveAgentWindowName').value.trim()
resumeTaskId: resume ? $('resumeTaskSelect').value : ''
}; };
if (resume && !payload.resumeTaskId) { if (!payload.session || !payload.cwd) {
toast('没有可继续的 Session', 'warn'); toast('请填写 session 和工作目录', 'warn');
return; return;
} }
const submitButtons = [$('startTaskButton'), $('resumeTaskButton')]; const button = $('liveAgentLaunchButton');
submitButtons.forEach((btn) => { if (btn) btn.disabled = true; }); button.disabled = true;
try { try {
await request('/api/tasks', { const result = await request('/api/tmux/agents', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
$('promptInput').value = ''; toast(`已在 ${result.session} 启动 ${result.engine}`, 'success');
toast(resume ? 'Session 已继续' : '任务已启动', 'success'); $('liveAgentWindowName').value = '';
await refresh(); await refresh();
} catch (error) { } catch (error) {
toast(`启动任务失败: ${error.message}`, 'error'); toast(`启动 agent 失败: ${error.message}`, 'error');
} finally { } finally {
submitButtons.forEach((btn) => { if (btn) btn.disabled = false; }); button.disabled = false;
} }
}
$('taskForm').addEventListener('submit', async (event) => {
event.preventDefault();
await submitTask(false);
}); });
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
refresh(); refresh();
setInterval(refresh, 60000); setInterval(refresh, 60000);

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local Kanban</title> <title>Local Kanban</title>
<link rel="stylesheet" href="/styles.css?v=20260514"> <link rel="stylesheet" href="/styles.css?v=20260728d">
</head> </head>
<body> <body>
<div class="bg-glow" aria-hidden="true"></div> <div class="bg-glow" aria-hidden="true"></div>
@@ -14,7 +14,7 @@
<div class="brand-mark" aria-hidden="true">LK</div> <div class="brand-mark" aria-hidden="true">LK</div>
<div> <div>
<h1>Local Kanban</h1> <h1>Local Kanban</h1>
<p id="subtitle" class="muted">GPU · 额度 · 项目 · Agent</p> <p id="subtitle" class="muted">GPU · Live Agents</p>
</div> </div>
</div> </div>
<div class="top-actions"> <div class="top-actions">
@@ -27,8 +27,7 @@
<div id="errorBanner" class="error-banner" hidden></div> <div id="errorBanner" class="error-banner" hidden></div>
<section class="grid dashboard-grid"> <section class="panel">
<section class="panel span-2">
<div class="panel-heading"> <div class="panel-heading">
<div class="panel-title"> <div class="panel-title">
<h2>GPU</h2> <h2>GPU</h2>
@@ -50,69 +49,39 @@
<div id="gpuBoard" class="gpu-board"></div> <div id="gpuBoard" class="gpu-board"></div>
</section> </section>
<section class="panel"> <section class="panel live-agent-panel">
<div class="panel-heading"> <div class="panel-heading">
<div class="panel-title"> <div class="panel-title">
<h2>AI 额度</h2> <h2>Live Agents</h2>
<span id="quotaSummary" class="badge"></span> <span id="liveAgentSummary" class="badge"></span>
</div> </div>
</div> </div>
<div id="quotaBoard" class="quota-board"></div> <form id="liveAgentForm" class="live-agent-form">
</section>
</section>
<section class="grid work-grid">
<section class="panel project-panel">
<div class="panel-heading">
<div class="panel-title">
<h2>项目</h2>
<span id="repoSummary" class="badge"></span>
</div>
</div>
<div id="repoBoard" class="repo-board"></div>
</section>
<section class="panel agent-panel">
<div class="panel-heading">
<div class="panel-title">
<h2>Agent</h2>
<span id="taskSummary" class="badge"></span>
</div>
</div>
<div id="agentContext" class="agent-context">
<span class="agent-context-label">当前项目</span>
<span id="agentContextRepo" class="agent-context-repo">未选择</span>
<span id="agentContextBranch" class="chip" hidden></span>
</div>
<form id="taskForm" class="task-form">
<div class="task-controls">
<label class="select-field"> <label class="select-field">
<span class="field-label">Branch</span> <span class="field-label">Engine</span>
<select id="branchSelect" required></select> <select id="liveAgentEngine">
<option value="claude">Claude</option>
<option value="codex">Codex</option>
</select>
</label> </label>
<label class="select-field"> <label class="select-field">
<span class="field-label">AI 配置</span> <span class="field-label">Session</span>
<select id="profileSelect" required></select> <input id="liveAgentSession" type="text" placeholder="tmux session 名" autocomplete="off" spellcheck="false" required>
</label>
<label class="select-field live-agent-cwd">
<span class="field-label">工作目录</span>
<input id="liveAgentCwd" type="text" placeholder="/home/gahow/..." autocomplete="off" spellcheck="false" required>
</label> </label>
<label class="select-field"> <label class="select-field">
<span class="field-label">历史 Session</span> <span class="field-label">Window 名 <span class="muted">(可选)</span></span>
<select id="resumeTaskSelect"></select> <input id="liveAgentWindowName" type="text" placeholder="默认 engine:目录名" autocomplete="off" spellcheck="false">
</label> </label>
</div> <button id="liveAgentLaunchButton" type="submit" class="primary-button">启动 Agent</button>
<label class="prompt-field">
<span class="field-label">命令</span>
<textarea id="promptInput" rows="5" required placeholder="输入要交给 agent 的任务"></textarea>
</label>
<div class="form-actions">
<button id="startTaskButton" type="submit" class="primary-button">启动任务</button>
<button id="resumeTaskButton" type="button" class="secondary-button">继续 Session</button>
</div>
</form> </form>
<div id="taskBoard" class="task-board"></div> <div id="liveAgentBoard" class="live-agent-board"></div>
</section>
</section> </section>
</main> </main>
<div id="toastHost" class="toast-host" aria-live="polite"></div> <div id="toastHost" class="toast-host" aria-live="polite"></div>
<script type="module" src="/app.js?v=20260514"></script> <script type="module" src="/app.js?v=20260728d"></script>
</body> </body>
</html> </html>

View File

@@ -1,47 +1,17 @@
:root { :root {
color-scheme: light dark; color-scheme: dark;
--bg: #f4f6fb; --bg: #0a0e1a;
--bg-elev: #ffffff; --bg-elev: #111827;
--panel: #ffffff; --panel: #101626;
--panel-soft: #f8fafc; --panel-soft: #151d31;
--ink: #0f172a; --ink: #e5eaf3;
--ink-soft: #334155; --ink-soft: #c3cbdc;
--muted: #64748b; --muted: #8b96ad;
--line: #e2e8f0; --line: #1f2940;
--line-strong: #cbd5e1; --line-strong: #2c3854;
--accent: #6366f1; --accent: #7c8cf8;
--accent-strong: #4f46e5; --accent-strong: #a0adfc;
--accent-soft: #eef2ff; --accent-soft: rgba(124, 140, 248, 0.13);
--accent-ink: #3730a3;
--good: #059669;
--good-soft: #d1fae5;
--warn: #d97706;
--warn-soft: #fef3c7;
--bad: #dc2626;
--bad-soft: #fee2e2;
--chip: #f1f5f9;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.04);
--shadow-md: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 16px 48px rgba(15, 23, 42, 0.06);
--radius: 14px;
--radius-sm: 10px;
--radius-pill: 999px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1020;
--bg-elev: #131a2e;
--panel: #161e36;
--panel-soft: #1b2342;
--ink: #e2e8f0;
--ink-soft: #cbd5e1;
--muted: #94a3b8;
--line: #232c4a;
--line-strong: #2d3760;
--accent: #818cf8;
--accent-strong: #a5b4fc;
--accent-soft: rgba(129, 140, 248, 0.12);
--accent-ink: #c7d2fe; --accent-ink: #c7d2fe;
--good: #34d399; --good: #34d399;
--good-soft: rgba(52, 211, 153, 0.14); --good-soft: rgba(52, 211, 153, 0.14);
@@ -49,11 +19,13 @@
--warn-soft: rgba(251, 191, 36, 0.14); --warn-soft: rgba(251, 191, 36, 0.14);
--bad: #f87171; --bad: #f87171;
--bad-soft: rgba(248, 113, 113, 0.14); --bad-soft: rgba(248, 113, 113, 0.14);
--chip: #1e2742; --chip: #1c2540;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35); --shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45); --shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45);
} --radius: 14px;
--radius-sm: 10px;
--radius-pill: 999px;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -77,13 +49,13 @@ body {
.bg-glow { .bg-glow {
position: fixed; position: fixed;
inset: -10% -10% auto -10%; inset: -10% -10% auto -10%;
height: 60vh; height: 70vh;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(40% 60% at 15% 20%, rgba(99, 102, 241, 0.18), transparent 70%), radial-gradient(45% 65% at 12% 15%, rgba(99, 102, 241, 0.14), transparent 70%),
radial-gradient(35% 50% at 85% 10%, rgba(16, 185, 129, 0.14), transparent 70%), radial-gradient(38% 55% at 88% 8%, rgba(16, 185, 129, 0.10), transparent 70%),
radial-gradient(50% 60% at 50% 0%, rgba(56, 189, 248, 0.10), transparent 70%); radial-gradient(55% 65% at 50% 0%, rgba(56, 189, 248, 0.07), transparent 70%);
filter: blur(8px); filter: blur(10px);
z-index: 0; z-index: 0;
} }
@@ -227,7 +199,7 @@ h3 {
/* Panels */ /* Panels */
.panel { .panel {
background: var(--panel); background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, #2a3554), var(--panel) 120px);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
padding: 18px 18px 20px; padding: 18px 18px 20px;
@@ -235,6 +207,8 @@ h3 {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.panel + .panel { margin-top: 18px; }
.panel-heading { .panel-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -329,7 +303,7 @@ h3 {
.gpu-thumbs { .gpu-thumbs {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(18px, 1fr)); grid-template-columns: repeat(4, 1fr);
gap: 4px; gap: 4px;
} }
@@ -721,7 +695,7 @@ select {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
padding-right: 36px; padding-right: 36px;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2364748b' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>"); background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 12px center; background-position: right 12px center;
background-size: 14px; background-size: 14px;
@@ -741,11 +715,6 @@ select option {
color: var(--ink); color: var(--ink);
} }
@media (prefers-color-scheme: dark) {
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>");
}
}
.select-field { position: relative; } .select-field { position: relative; }
@@ -1216,3 +1185,185 @@ details.task-card[open] > summary .task-summary-chevron {
.inline-form button { justify-self: stretch; } .inline-form button { justify-self: stretch; }
.agent-context { flex-wrap: wrap; } .agent-context { flex-wrap: wrap; }
} }
.live-agent-panel { margin-top: 18px; }
/* status-dot glow for GPU thumbs */
.gpu-thumb.idle { box-shadow: 0 0 6px color-mix(in srgb, var(--good) 30%, transparent); }
.gpu-thumb.busy { box-shadow: 0 0 6px color-mix(in srgb, var(--bad) 30%, transparent); }
.gpu-thumb.abnormal { box-shadow: 0 0 6px color-mix(in srgb, var(--warn) 30%, transparent); }
/* Compact GPU board: hosts flow into columns, expanded host spans the row */
.gpu-board {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 8px;
}
.gpu-board .host-card { padding: 8px 12px; }
.gpu-board .host-card.expanded { grid-column: 1 / -1; }
.gpu-board .host-summary {
grid-template-columns: minmax(110px, auto) minmax(80px, 1fr) auto;
gap: 10px;
}
.gpu-board .host-summary > span:first-child {
display: flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.gpu-board .gpu-thumb { height: 12px; border-radius: 4px; }
.gpu-board .gpu-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
margin-top: 10px;
}
.live-agent-form {
display: grid;
grid-template-columns: minmax(110px, 0.6fr) minmax(150px, 0.9fr) minmax(240px, 1.8fr) minmax(150px, 1fr) auto;
gap: 12px;
align-items: end;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.live-agent-form label {
display: grid;
gap: 6px;
}
.live-agent-form .primary-button { white-space: nowrap; }
.live-agent-board {
display: flex;
flex-direction: column;
gap: 10px;
}
.agent-card {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.agent-card:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
}
.agent-chip {
display: inline-flex;
align-items: center;
padding: 2px 10px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--chip);
color: var(--ink-soft);
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
flex-shrink: 0;
}
.agent-chip.claude { color: #fbbf24; background: color-mix(in srgb, #f59e0b 14%, var(--chip)); }
.agent-chip.codex { color: #93c5fd; background: color-mix(in srgb, #3b82f6 14%, var(--chip)); }
.agent-card-main {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex-shrink: 0;
}
.agent-card-cwd {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.agent-card .secondary-button {
flex-shrink: 0;
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
color: var(--accent-ink);
}
.agent-card .secondary-button:hover {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-strong);
}
.agent-group {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel);
overflow: hidden;
}
.agent-group-heading {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--panel-soft)), var(--panel-soft));
border-bottom: 1px solid var(--line);
}
.agent-group-heading::before {
content: '';
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
}
.agent-group-heading strong {
font-weight: 650;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
letter-spacing: 0.01em;
}
.agent-group-heading .muted { font-size: 12px; }
.agent-group-list { display: flex; flex-direction: column; }
.agent-group-list .agent-card {
border: 0;
border-radius: 0;
background: transparent;
}
.agent-group-list .agent-card + .agent-card { border-top: 1px solid var(--line); }
.agent-group-list .agent-card:hover {
box-shadow: none;
background: var(--panel-soft);
}
@media (max-width: 1024px) {
.live-agent-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.live-agent-form { grid-template-columns: 1fr; }
.agent-card { flex-wrap: wrap; }
.agent-card-cwd { flex-basis: 100%; }
}

View File

@@ -7,11 +7,14 @@ USER_UNIT_DIR="${HOME}/.config/systemd/user"
mkdir -p "${USER_UNIT_DIR}" mkdir -p "${USER_UNIT_DIR}"
install -m 0644 "${ROOT_DIR}/deploy/systemd/local-kanban.service" "${USER_UNIT_DIR}/local-kanban.service" install -m 0644 "${ROOT_DIR}/deploy/systemd/local-kanban.service" "${USER_UNIT_DIR}/local-kanban.service"
install -m 0644 "${ROOT_DIR}/deploy/systemd/local-kanban-nginx-8443.service" "${USER_UNIT_DIR}/local-kanban-nginx-8443.service" install -m 0644 "${ROOT_DIR}/deploy/systemd/local-kanban-nginx-8443.service" "${USER_UNIT_DIR}/local-kanban-nginx-8443.service"
install -m 0644 "${ROOT_DIR}/deploy/systemd/local-kanban-ttyd.service" "${USER_UNIT_DIR}/local-kanban-ttyd.service"
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now local-kanban.service systemctl --user enable --now local-kanban.service
systemctl --user enable --now local-kanban-nginx-8443.service systemctl --user enable --now local-kanban-nginx-8443.service
systemctl --user enable --now local-kanban-ttyd.service
echo "Local Kanban is managed by user systemd:" echo "Local Kanban is managed by user systemd:"
echo " systemctl --user status local-kanban.service" echo " systemctl --user status local-kanban.service"
echo " systemctl --user status local-kanban-nginx-8443.service" echo " systemctl --user status local-kanban-nginx-8443.service"
echo " systemctl --user status local-kanban-ttyd.service"

View File

@@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}" cd "${ROOT_DIR}"
export PATH="/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
export KANBAN_HOST="${KANBAN_HOST:-127.0.0.1}" export KANBAN_HOST="${KANBAN_HOST:-127.0.0.1}"
export KANBAN_PORT="${KANBAN_PORT:-5180}" export KANBAN_PORT="${KANBAN_PORT:-5180}"
export KANBAN_AUTH_SECURE_COOKIE="${KANBAN_AUTH_SECURE_COOKIE:-1}" export KANBAN_AUTH_SECURE_COOKIE="${KANBAN_AUTH_SECURE_COOKIE:-1}"

23
scripts/start-ttyd.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# ttyd web terminal for tmux attach.
# Theme: Tokyo Night; font: system monospace stack (fonts render client-side,
# so we rely on whatever mono font the viewing device ships).
# xterm.js client options: https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec /usr/bin/ttyd \
-p 7681 \
-i 127.0.0.1 \
-W \
-a \
-b /term \
-T xterm-256color \
-t titleFixed='Kanban Terminal' \
-t fontSize=14 \
-t lineHeight=1.2 \
-t 'fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace' \
-t cursorBlink=true \
-t 'theme={"background":"#1a1b26","foreground":"#c0caf5","cursor":"#c0caf5","cursorAccent":"#1a1b26","selectionBackground":"#283457","black":"#15161e","red":"#f7768e","green":"#9ece6a","yellow":"#e0af68","blue":"#7aa2f7","magenta":"#bb9af7","cyan":"#7dcfff","white":"#a9b1d6","brightBlack":"#414868","brightRed":"#f7768e","brightGreen":"#9ece6a","brightYellow":"#e0af68","brightBlue":"#7aa2f7","brightMagenta":"#bb9af7","brightCyan":"#7dcfff","brightWhite":"#c0caf5"}' \
"${ROOT_DIR}/scripts/tmux-web-attach.sh"

60
scripts/tmux-web-attach.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Attach a browser (via ttyd) to a single tmux window by linking it into a
# private temporary session. Unlike grouped sessions, link-window leaves no
# permanent group label on the source session, and each browser tab gets an
# independent view (own active window, own status setting).
# Usage: tmux-web-attach.sh <session> [window-index]
set -euo pipefail
sess="${1:-}"
win="${2:-}"
if [ -z "$sess" ]; then
echo "missing session. open this page from the kanban Live Agents panel."
sleep 2
exit 1
fi
# Only accept existing session names; never interpret arbitrary input.
if ! tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -Fxq -- "$sess"; then
echo "unknown session: $sess"
sleep 2
exit 1
fi
# Default to the session's active window; validate the index either way.
if [ -z "$win" ]; then
win="$(tmux display-message -p -t "$sess" '#{window_index}')"
fi
if ! tmux list-windows -t "$sess" -F '#{window_index}' | grep -Fxq -- "$win"; then
echo "unknown window: $sess:$win"
sleep 2
exit 1
fi
src="$sess:$win"
tmp="web-$$-$RANDOM"
tmux new-session -d -s "$tmp" -x 220 -y 60
tmux set-option -t "$tmp" status off
# mouse on (session-scoped, local clients unaffected): wheel / touch scrolling
# from the browser enters copy-mode and scrolls history naturally, no prefix keys.
tmux set-option -t "$tmp" mouse on
# Replace the placeholder window with a link to the target window (-k kills the placeholder).
first="$(tmux display-message -p -t "$tmp" '#{window_index}')"
tmux link-window -k -s "$src" -t "$tmp:$first"
# A tmux window has a single size shared by every viewer. With the global
# "latest" policy a bigger local client cuts the browser view off, so pin this
# window to "smallest" while a web viewer is attached: the browser always sees
# the full content; a larger local client sees dotted padding instead.
tmux set-window-option -t "$src" window-size smallest
cleanup() {
tmux set-window-option -u -t "$src" window-size 2>/dev/null || true
tmux kill-session -t "$tmp" 2>/dev/null || true
}
trap cleanup EXIT HUP TERM INT
# destroy-unattached must be set only after attaching (it kills detached sessions instantly).
tmux attach -t "$tmp" \; set-option destroy-unattached on

View File

@@ -17,6 +17,7 @@ import { collectGpuStatus } from './services/gpu.js';
import { collectQuotaStatus } from './services/quota.js'; import { collectQuotaStatus } from './services/quota.js';
import { listBranches, listRepos } from './services/repos.js'; import { listBranches, listRepos } from './services/repos.js';
import { listAgentProfiles, listTasks, startAgentTask } from './services/agents.js'; import { listAgentProfiles, listTasks, startAgentTask } from './services/agents.js';
import { collectLiveAgents, launchAgent } from './services/tmux.js';
import { getSettings, updateGpuHosts } from './services/settings.js'; import { getSettings, updateGpuHosts } from './services/settings.js';
const root = join(fileURLToPath(new URL('..', import.meta.url)), 'public'); const root = join(fileURLToPath(new URL('..', import.meta.url)), 'public');
@@ -111,6 +112,12 @@ async function route(req, res) {
return sendJsonWithHeaders(res, 200, logoutHeaders(), { ok: true }); return sendJsonWithHeaders(res, 200, logoutHeaders(), { ok: true });
} }
if (url.pathname === '/api/health') return sendJson(res, 200, { ok: true }); if (url.pathname === '/api/health') return sendJson(res, 200, { ok: true });
if (url.pathname === '/api/auth/check') return sendJson(res, 200, { ok: true });
if (url.pathname === '/api/tmux/agents' && req.method === 'GET') return sendJson(res, 200, await collectLiveAgents());
if (url.pathname === '/api/tmux/agents' && req.method === 'POST') {
const payload = await readJson(req);
return sendJson(res, 202, await launchAgent(payload));
}
if (url.pathname === '/api/settings' && req.method === 'GET') return sendJson(res, 200, getSettings(config)); if (url.pathname === '/api/settings' && req.method === 'GET') return sendJson(res, 200, getSettings(config));
if (url.pathname === '/api/settings/gpu-hosts' && req.method === 'PUT') { if (url.pathname === '/api/settings/gpu-hosts' && req.method === 'PUT') {
const payload = await readJson(req); const payload = await readJson(req);
@@ -133,9 +140,10 @@ async function route(req, res) {
collectQuotaStatus(config), collectQuotaStatus(config),
listRepos(config), listRepos(config),
Promise.resolve(listAgentProfiles(config)), Promise.resolve(listAgentProfiles(config)),
Promise.resolve(listTasks(config)) Promise.resolve(listTasks(config)),
collectLiveAgents()
]); ]);
const [settings, gpus, quotas, repos, agentProfiles, tasks] = sections; const [settings, gpus, quotas, repos, agentProfiles, tasks, liveAgents] = sections;
const errors = {}; const errors = {};
const value = (result, fallback, key) => { const value = (result, fallback, key) => {
if (result.status === 'fulfilled') return result.value; if (result.status === 'fulfilled') return result.value;
@@ -149,6 +157,7 @@ async function route(req, res) {
repos: value(repos, [], 'repos'), repos: value(repos, [], 'repos'),
agentProfiles: value(agentProfiles, [], 'agentProfiles'), agentProfiles: value(agentProfiles, [], 'agentProfiles'),
tasks: value(tasks, [], 'tasks'), tasks: value(tasks, [], 'tasks'),
liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'),
errors errors
}); });
} }

86
src/services/tmux.js Normal file
View File

@@ -0,0 +1,86 @@
import { existsSync, statSync } from 'node:fs';
import { basename } from 'node:path';
import { runProcess } from '../process.js';
const PANE_FORMAT = '#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_current_command}\t#{pane_current_path}';
const ENGINE_BY_COMMAND = {
claude: 'claude',
node: 'codex'
};
const ENGINE_COMMANDS = {
claude: 'claude',
codex: 'codex'
};
export function parseTmuxPanes(output) {
return String(output || '')
.split('\n')
.filter(Boolean)
.map((line) => {
const [session, windowIndex, windowName, paneId, command, cwd] = line.split('\t');
const engine = ENGINE_BY_COMMAND[command];
if (!engine || !paneId) return null;
return { engine, session, windowIndex: Number(windowIndex), windowName, paneId, cwd };
})
.filter(Boolean);
}
export async function collectLiveAgents() {
const result = await runProcess('tmux', ['list-panes', '-a', '-F', PANE_FORMAT], { timeoutMs: 5000 });
if (!result.ok) {
// tmux server not running is a normal state, not an error.
return { ok: true, checkedAt: new Date().toISOString(), agents: [] };
}
return { ok: true, checkedAt: new Date().toISOString(), agents: parseTmuxPanes(result.stdout) };
}
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function safeSessionName(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80);
}
function safeWindowName(value) {
return String(value || '').replaceAll('\t', ' ').trim().slice(0, 80);
}
async function tmux(args, timeoutMs = 5000) {
const result = await runProcess('tmux', args, { timeoutMs });
if (!result.ok) throw new Error(`tmux ${args[0]} failed: ${result.stderr || result.stdout}`);
return result;
}
export async function launchAgent(payload) {
const engine = String(payload?.engine || '');
const command = ENGINE_COMMANDS[engine];
if (!command) throw badRequest('engine must be one of: claude, codex.');
const cwd = String(payload?.cwd || '');
if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw badRequest('cwd must be an existing directory.');
}
const session = safeSessionName(payload?.session);
if (!session) throw badRequest('session is required.');
const windowName = safeWindowName(payload?.windowName) || `${engine}:${basename(cwd)}`;
const sessions = await runProcess('tmux', ['list-sessions', '-F', '#{session_name}'], { timeoutMs: 5000 });
const exists = sessions.ok && sessions.stdout.split('\n').includes(session);
const created = exists
? await tmux(['new-window', '-t', `${session}:`, '-n', windowName, '-c', cwd, '-P', '-F', '#{pane_id}'])
: await tmux(['new-session', '-d', '-s', session, '-n', windowName, '-c', cwd, '-P', '-F', '#{pane_id}']);
const paneId = created.stdout.trim();
// Launch through the shell so the window survives agent exit (output stays visible, restart in place).
await tmux(['send-keys', '-t', paneId, command, 'Enter']);
return { paneId, session, windowName, engine, cwd };
}

46
test/tmux.test.js Normal file
View File

@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTmuxPanes } from '../src/services/tmux.js';
test('parseTmuxPanes maps claude and node panes to engines', () => {
const output = [
'main\t0\tclaude:kanban\t%3\tclaude\t/home/gahow/projects/kanban',
'main\t1\tcodex:vllm\t%5\tnode\t/home/gahow/projects/vllm',
'main\t2\tshell\t%7\tbash\t/home/gahow'
].join('\n');
assert.deepEqual(parseTmuxPanes(output), [
{
engine: 'claude',
session: 'main',
windowIndex: 0,
windowName: 'claude:kanban',
paneId: '%3',
cwd: '/home/gahow/projects/kanban'
},
{
engine: 'codex',
session: 'main',
windowIndex: 1,
windowName: 'codex:vllm',
paneId: '%5',
cwd: '/home/gahow/projects/vllm'
}
]);
});
test('parseTmuxPanes drops unrelated commands', () => {
const output = [
'dev\t0\teditor\t%1\tvim\t/home/gahow',
'dev\t1\tmonitor\t%2\thtop\t/home/gahow'
].join('\n');
assert.deepEqual(parseTmuxPanes(output), []);
});
test('parseTmuxPanes handles empty output', () => {
assert.deepEqual(parseTmuxPanes(''), []);
assert.deepEqual(parseTmuxPanes(null), []);
});
test('parseTmuxPanes ignores malformed lines', () => {
assert.deepEqual(parseTmuxPanes('garbage-without-tabs\n\n'), []);
});