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 { listBranches, listRepos } from './services/repos.js';
import { listAgentProfiles, listTasks, startAgentTask } from './services/agents.js';
import { collectLiveAgents, launchAgent } from './services/tmux.js';
import { getSettings, updateGpuHosts } from './services/settings.js';
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 });
}
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/gpu-hosts' && req.method === 'PUT') {
const payload = await readJson(req);
@@ -133,9 +140,10 @@ async function route(req, res) {
collectQuotaStatus(config),
listRepos(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 value = (result, fallback, key) => {
if (result.status === 'fulfilled') return result.value;
@@ -149,6 +157,7 @@ async function route(req, res) {
repos: value(repos, [], 'repos'),
agentProfiles: value(agentProfiles, [], 'agentProfiles'),
tasks: value(tasks, [], 'tasks'),
liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'),
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 };
}