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

@@ -62,6 +62,8 @@
<select id="liveAgentEngine"> <select id="liveAgentEngine">
<option value="claude">Claude</option> <option value="claude">Claude</option>
<option value="codex">Codex</option> <option value="codex">Codex</option>
<option value="qoder">QoderCLI</option>
<option value="omp">OMP</option>
</select> </select>
</label> </label>
<label class="select-field"> <label class="select-field">

View File

@@ -1280,6 +1280,8 @@ details.task-card[open] > summary .task-summary-chevron {
.agent-chip.claude { color: #fbbf24; background: color-mix(in srgb, #f59e0b 14%, var(--chip)); } .agent-chip.claude { color: #fbbf24; background: color-mix(in srgb, #f59e0b 14%, var(--chip)); }
.agent-chip.codex { color: #93c5fd; background: color-mix(in srgb, #3b82f6 14%, var(--chip)); } .agent-chip.codex { color: #93c5fd; background: color-mix(in srgb, #3b82f6 14%, var(--chip)); }
.agent-chip.qoder { color: #c4b5fd; background: color-mix(in srgb, #8b5cf6 14%, var(--chip)); }
.agent-chip.omp { color: #6ee7b7; background: color-mix(in srgb, #10b981 14%, var(--chip)); }
.agent-card-main { .agent-card-main {
display: flex; display: flex;

View File

@@ -15,7 +15,7 @@ exec /usr/bin/ttyd \
-b /term \ -b /term \
-T xterm-256color \ -T xterm-256color \
-t titleFixed='Kanban Terminal' \ -t titleFixed='Kanban Terminal' \
-t fontSize=14 \ -t fontSize=10 \
-t lineHeight=1.2 \ -t lineHeight=1.2 \
-t 'fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace' \ -t 'fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace' \
-t cursorBlink=true \ -t cursorBlink=true \

View File

@@ -1,27 +1,99 @@
import { existsSync, statSync } from 'node:fs'; import { existsSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { runProcess } from '../process.js'; 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 WEB_TERMINAL_SESSION_PATTERN = /^web-\d+-\d+$/;
const ENGINE_BY_COMMAND = {
claude: 'claude',
node: 'codex'
};
const ENGINE_COMMANDS = { const ENGINE_COMMANDS = {
claude: 'claude', 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 || '') return String(output || '')
.split('\n') .split('\n')
.filter(Boolean) .filter(Boolean)
.map((line) => { .map((line) => {
const [session, windowIndex, windowName, paneId, command, cwd] = line.split('\t'); const [session, windowIndex, windowName, paneId, command, cwd, panePid] = line.split('\t');
const engine = ENGINE_BY_COMMAND[command]; const engine = processOutput === null
? engineForExecutable(command)
: engineForPane(Number(panePid), processes);
// Web terminal sessions only link an existing pane into a temporary view. // 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. // They are not additional agents and must not be enumerated in Live Agents.
if (!engine || !paneId || WEB_TERMINAL_SESSION_PATTERN.test(session)) return null; if (!engine || !paneId || WEB_TERMINAL_SESSION_PATTERN.test(session)) return null;
@@ -32,11 +104,30 @@ export function parseTmuxPanes(output) {
export async function collectLiveAgents() { export async function collectLiveAgents() {
const result = await runProcess('tmux', ['list-panes', '-a', '-F', PANE_FORMAT], { timeoutMs: 5000 }); 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. // 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: [] };
} }
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) { function badRequest(message) {
@@ -66,7 +157,9 @@ async function tmux(args, timeoutMs = 5000) {
export async function launchAgent(payload) { export async function launchAgent(payload) {
const engine = String(payload?.engine || ''); const engine = String(payload?.engine || '');
const command = ENGINE_COMMANDS[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 || ''); const cwd = String(payload?.cwd || '');
if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) { if (!cwd || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw badRequest('cwd must be an existing directory.'); throw badRequest('cwd must be an existing directory.');

View File

@@ -2,10 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseTmuxPanes } from '../src/services/tmux.js'; import { parseTmuxPanes } from '../src/services/tmux.js';
test('parseTmuxPanes maps claude and node panes to engines', () => { test('parseTmuxPanes maps explicit claude and codex panes to engines', () => {
const output = [ const output = [
'main\t0\tclaude:kanban\t%3\tclaude\t/home/gahow/projects/kanban', '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\t1\tcodex:vllm\t%5\tcodex\t/home/gahow/projects/vllm',
'main\t2\tshell\t%7\tbash\t/home/gahow' 'main\t2\tshell\t%7\tbash\t/home/gahow'
].join('\n'); ].join('\n');
assert.deepEqual(parseTmuxPanes(output), [ assert.deepEqual(parseTmuxPanes(output), [
@@ -38,9 +38,9 @@ test('parseTmuxPanes drops unrelated commands', () => {
test('parseTmuxPanes hides temporary web terminal sessions', () => { test('parseTmuxPanes hides temporary web terminal sessions', () => {
const output = [ const output = [
'main\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban', 'main\t1\tcodex:kanban\t%5\tcodex\t/home/gahow/projects/kanban',
'web-1234-5678\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban', 'web-1234-5678\t1\tcodex:kanban\t%5\tcodex\t/home/gahow/projects/kanban',
'web-development\t2\tcodex:web\t%6\tnode\t/home/gahow/projects/web' 'web-development\t2\tcodex:web\t%6\tcodex\t/home/gahow/projects/web'
].join('\n'); ].join('\n');
assert.deepEqual(parseTmuxPanes(output), [ assert.deepEqual(parseTmuxPanes(output), [
@@ -71,3 +71,75 @@ test('parseTmuxPanes handles empty output', () => {
test('parseTmuxPanes ignores malformed lines', () => { test('parseTmuxPanes ignores malformed lines', () => {
assert.deepEqual(parseTmuxPanes('garbage-without-tabs\n\n'), []); assert.deepEqual(parseTmuxPanes('garbage-without-tabs\n\n'), []);
}); });
function pane(command = 'node', pid = 100, name = 'renamed') {
return `main\t0\t${name}\t%1\t${command}\t/tmp\t${pid}`;
}
function processTable(command, { foreground = 101, background = '', child = '' } = {}) {
return `100 1 100 ${foreground} /bin/bash\n101 100 101 ${foreground} ${command}\n${background}\n${child}`;
}
test('ordinary Node servers and agent names in arguments/window names are not agents', () => {
for (const command of [
'node /srv/server.js codex claude omp',
'node /srv/codex/server.js',
'node -e "codex"',
'node --title codex /srv/server.js',
'node -ecodex codex',
'/bin/bash -c codex',
'bun run /srv/server.ts omp'
]) {
assert.deepEqual(parseTmuxPanes(pane('node', 100, 'codex:server'), processTable(command)), []);
}
assert.deepEqual(parseTmuxPanes(pane('node')), []);
});
test('recognizes native and runtime agent entrypoints regardless of window name', () => {
for (const [command, engine] of [
['/usr/local/bin/codex --model example', 'codex'],
['node /opt/node_modules/@openai/codex/bin/codex.js', 'codex'],
['node /home/user/.nvm/versions/node/v24.13.1/bin/codex', 'codex'],
['node --require preload.js /opt/node_modules/@anthropic-ai/claude-code/cli.js', 'claude'],
['/usr/local/bin/claude', 'claude'],
['/home/user/.local/share/claude/versions/2.1.241', 'claude'],
['/home/user/.qoder/bin/qodercli/qodercli-1.1.65', 'qoder'],
['qodercli', 'qoder'],
['/usr/local/bin/omp', 'omp'],
['bun /opt/node_modules/@oh-my-pi/pi-coding-agent/src/cli.ts', 'omp'],
['node /opt/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js', 'omp']
]) {
const agents = parseTmuxPanes(pane(), processTable(command));
assert.equal(agents.length, 1, command);
assert.equal(agents[0].engine, engine, command);
assert.equal(agents[0].windowName, 'renamed');
}
});
test('only inspects this pane and its foreground job, ignoring stopped/background agents', () => {
const output = processTable('node /srv/server.js', {
background: '102 100 102 101 codex\n200 1 200 201 /bin/bash\n201 200 201 201 claude'
});
assert.deepEqual(parseTmuxPanes(pane(), output), []);
assert.deepEqual(parseTmuxPanes(pane(), processTable('codex', { foreground: 100 })), []);
});
test('keeps the parent agent visible while it runs a foreground tool', () => {
const output = processTable('codex', {
foreground: 102,
child: '102 101 102 102 node /srv/server.js'
});
assert.equal(parseTmuxPanes(pane('node'), output)[0].engine, 'codex');
});
test('uses exact Linux argv to distinguish runtime entrypoint from prompt text', () => {
const argv = new Map([[101, ['node', '-e', 'console.log("hello")', 'codex']]]);
assert.deepEqual(parseTmuxPanes(pane(), processTable('node -e console.log("hello") codex'), argv), []);
argv.set(101, ['bun', '/opt/space dir/node_modules/@oh-my-pi/pi-coding-agent/src/cli.ts']);
assert.equal(parseTmuxPanes(pane(), processTable('bun /opt/space dir/...'), argv)[0].engine, 'omp');
});
test('expired pane PIDs and empty process snapshots do not reuse stale command names', () => {
assert.deepEqual(parseTmuxPanes(pane('codex'), ''), []);
assert.deepEqual(parseTmuxPanes(pane('codex', 999), processTable('codex')), []);
});