fix(tmux): identify agent processes and add Qoder and OMP
This commit is contained in:
@@ -1,27 +1,99 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
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 PANE_FORMAT = '#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_current_command}\t#{pane_current_path}\t#{pane_pid}';
|
||||
const WEB_TERMINAL_SESSION_PATTERN = /^web-\d+-\d+$/;
|
||||
|
||||
const ENGINE_BY_COMMAND = {
|
||||
claude: 'claude',
|
||||
node: 'codex'
|
||||
};
|
||||
|
||||
const ENGINE_COMMANDS = {
|
||||
claude: 'claude',
|
||||
codex: 'codex'
|
||||
codex: 'codex',
|
||||
qoder: 'qodercli',
|
||||
omp: 'omp'
|
||||
};
|
||||
|
||||
export function parseTmuxPanes(output) {
|
||||
function engineForExecutable(path) {
|
||||
const name = basename(path || '');
|
||||
return Object.keys(ENGINE_COMMANDS).find((engine) => ENGINE_COMMANDS[engine] === name)
|
||||
|| (/^qodercli-\d+\.\d+\.\d+(?:[-.][\w.-]+)?$/.test(name) ? 'qoder' : null)
|
||||
|| (/\/\.local\/share\/claude\/versions\/\d+\.\d+\.\d+$/.test(path || '') ? 'claude' : null);
|
||||
}
|
||||
|
||||
function engineForArgv(argv) {
|
||||
const direct = engineForExecutable(argv[0]);
|
||||
if (direct) return direct;
|
||||
if (!/^(node|nodejs|bun)$/.test(basename(argv[0] || ''))) return null;
|
||||
let index = 1;
|
||||
if (basename(argv[0]) === 'bun' && argv[index] === 'run') index++;
|
||||
while (argv[index]?.startsWith('-')) {
|
||||
const option = argv[index++];
|
||||
if (option === '--') break;
|
||||
// Inline code and preloaded modules are not an agent entrypoint.
|
||||
if (/^(?:--eval|--print)(?:=|$)|^-[ep]/.test(option)) return null;
|
||||
if (['--require', '-r', '--import', '--loader', '--experimental-loader', '--title', '--conditions', '-C'].includes(option)) {
|
||||
index++;
|
||||
} else if (!option.includes('=') && !['--inspect', '--inspect-brk', '--enable-source-maps', '--no-warnings', '--experimental-strip-types'].includes(option)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const script = argv[index] || '';
|
||||
return engineForExecutable(script)
|
||||
|| (/\/@openai\/codex\/bin\/codex\.js$/.test(script) ? 'codex' : null)
|
||||
|| (/\/@anthropic-ai\/claude-code\/cli\.js$/.test(script) ? 'claude' : null)
|
||||
|| (/\/@oh-my-pi\/pi-coding-agent\/(?:src\/cli\.ts|dist\/cli\.js)$/.test(script) ? 'omp' : null);
|
||||
}
|
||||
|
||||
function parseProcesses(output, argvByPid) {
|
||||
const processes = new Map();
|
||||
for (const line of String(output).split('\n')) {
|
||||
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(.+)$/);
|
||||
if (!match) continue;
|
||||
const [, pid, ppid, pgid, tpgid, commandLine] = match;
|
||||
const argv = argvByPid.get(Number(pid))
|
||||
|| (commandLine.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((arg) => arg.replace(/^(["'])(.*)\1$/, '$2'));
|
||||
processes.set(Number(pid), { pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), tpgid: Number(tpgid), argv });
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
function processChain(process, panePid, processes) {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
while (process && !seen.has(process.pid)) {
|
||||
chain.push(process);
|
||||
if (process.pid === panePid) return chain;
|
||||
seen.add(process.pid);
|
||||
process = processes.get(process.ppid);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function engineForPane(panePid, processes) {
|
||||
const pane = processes.get(panePid);
|
||||
if (!pane || pane.tpgid <= 0) return null;
|
||||
for (const process of processes.values()) {
|
||||
if (process.pgid !== pane.tpgid) continue;
|
||||
const chain = processChain(process, panePid, processes);
|
||||
// Keep the owning agent visible while it runs a foreground tool.
|
||||
for (const ancestor of chain.reverse()) {
|
||||
const engine = engineForArgv(ancestor.argv);
|
||||
if (engine) return engine;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseTmuxPanes(output, processOutput = null, argvByPid = new Map()) {
|
||||
const processes = parseProcesses(processOutput || '', argvByPid);
|
||||
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];
|
||||
const [session, windowIndex, windowName, paneId, command, cwd, panePid] = line.split('\t');
|
||||
const engine = processOutput === null
|
||||
? engineForExecutable(command)
|
||||
: engineForPane(Number(panePid), processes);
|
||||
// Web terminal sessions only link an existing pane into a temporary view.
|
||||
// They are not additional agents and must not be enumerated in Live Agents.
|
||||
if (!engine || !paneId || WEB_TERMINAL_SESSION_PATTERN.test(session)) return null;
|
||||
@@ -32,11 +104,30 @@ export function parseTmuxPanes(output) {
|
||||
|
||||
export async function collectLiveAgents() {
|
||||
const result = await runProcess('tmux', ['list-panes', '-a', '-F', PANE_FORMAT], { timeoutMs: 5000 });
|
||||
if (!result.ok) {
|
||||
if (!result.ok || !result.stdout) {
|
||||
// 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) };
|
||||
const snapshot = await runProcess('ps', ['-ww', '-eo', 'pid=,ppid=,pgid=,tpgid=,args='], { timeoutMs: 5000 });
|
||||
if (!snapshot.ok) throw new Error(`Cannot inspect tmux processes: ${snapshot.stderr}`);
|
||||
const argvByPid = new Map();
|
||||
if (process.platform === 'linux') {
|
||||
const processes = parseProcesses(snapshot.stdout, argvByPid);
|
||||
const panePids = result.stdout.split('\n').map((line) => Number(line.split('\t')[6]));
|
||||
await Promise.all([...processes.values()].filter((process) =>
|
||||
/^(node|nodejs|bun)$/.test(basename(process.argv[0] || ''))
|
||||
&& panePids.some((pid) => processChain(process, pid, processes).length)
|
||||
).map(async ({ pid }) => {
|
||||
try {
|
||||
const commandLine = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||
argvByPid.set(pid, commandLine.split('\0').filter(Boolean));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ESRCH') throw error;
|
||||
argvByPid.set(pid, []);
|
||||
}
|
||||
}));
|
||||
}
|
||||
return { ok: true, checkedAt: new Date().toISOString(), agents: parseTmuxPanes(result.stdout, snapshot.stdout, argvByPid) };
|
||||
}
|
||||
|
||||
function badRequest(message) {
|
||||
@@ -66,7 +157,9 @@ async function tmux(args, timeoutMs = 5000) {
|
||||
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.');
|
||||
if (!Object.hasOwn(ENGINE_COMMANDS, engine)) {
|
||||
throw badRequest(`engine must be one of: ${Object.keys(ENGINE_COMMANDS).join(', ')}.`);
|
||||
}
|
||||
const cwd = String(payload?.cwd || '');
|
||||
if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||
throw badRequest('cwd must be an existing directory.');
|
||||
|
||||
Reference in New Issue
Block a user