fix(tmux): identify agent processes and add Qoder and OMP
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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')), []);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user