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>
This commit is contained in:
2026-07-28 21:20:26 +08:00
parent bf926e327f
commit f7d6df8756
3 changed files with 143 additions and 2 deletions

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'), []);
});