fix(tmux): identify agent processes and add Qoder and OMP

This commit is contained in:
2026-10-03 15:48:53 +08:00
parent d125a8d6ec
commit 298a3c8a58
5 changed files with 188 additions and 19 deletions

View File

@@ -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.');