From f7d6df8756c2429a58379112aa2a68b91f834a9a Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 28 Jul 2026 21:20:26 +0800 Subject: [PATCH] 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 --- src/server.js | 13 +++++-- src/services/tmux.js | 86 ++++++++++++++++++++++++++++++++++++++++++++ test/tmux.test.js | 46 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/services/tmux.js create mode 100644 test/tmux.test.js diff --git a/src/server.js b/src/server.js index a3c0cf9..c770999 100644 --- a/src/server.js +++ b/src/server.js @@ -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 }); } diff --git a/src/services/tmux.js b/src/services/tmux.js new file mode 100644 index 0000000..6ed7ebf --- /dev/null +++ b/src/services/tmux.js @@ -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 }; +} diff --git a/test/tmux.test.js b/test/tmux.test.js new file mode 100644 index 0000000..815dafd --- /dev/null +++ b/test/tmux.test.js @@ -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'), []); +});