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 { 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
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