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:
@@ -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
86
src/services/tmux.js
Normal 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
46
test/tmux.test.js
Normal 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'), []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user