diff --git a/public/index.html b/public/index.html index d36f012..e3826fc 100644 --- a/public/index.html +++ b/public/index.html @@ -62,6 +62,8 @@ Claude Codex + QoderCLI + OMP diff --git a/public/styles.css b/public/styles.css index 505eb74..48aa111 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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.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 { display: flex; diff --git a/scripts/start-ttyd.sh b/scripts/start-ttyd.sh index 6f3cd8b..8c0b7ca 100755 --- a/scripts/start-ttyd.sh +++ b/scripts/start-ttyd.sh @@ -15,7 +15,7 @@ exec /usr/bin/ttyd \ -b /term \ -T xterm-256color \ -t titleFixed='Kanban Terminal' \ - -t fontSize=14 \ + -t fontSize=10 \ -t lineHeight=1.2 \ -t 'fontFamily=ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace' \ -t cursorBlink=true \ diff --git a/src/services/tmux.js b/src/services/tmux.js index d080675..60fa599 100644 --- a/src/services/tmux.js +++ b/src/services/tmux.js @@ -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.'); diff --git a/test/tmux.test.js b/test/tmux.test.js index 5c936fc..7e9a8b2 100644 --- a/test/tmux.test.js +++ b/test/tmux.test.js @@ -2,10 +2,10 @@ 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', () => { +test('parseTmuxPanes maps explicit claude and codex 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\t1\tcodex:vllm\t%5\tcodex\t/home/gahow/projects/vllm', 'main\t2\tshell\t%7\tbash\t/home/gahow' ].join('\n'); assert.deepEqual(parseTmuxPanes(output), [ @@ -38,9 +38,9 @@ test('parseTmuxPanes drops unrelated commands', () => { test('parseTmuxPanes hides temporary web terminal sessions', () => { const output = [ - 'main\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban', - 'web-1234-5678\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban', - 'web-development\t2\tcodex:web\t%6\tnode\t/home/gahow/projects/web' + 'main\t1\tcodex:kanban\t%5\tcodex\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\tcodex\t/home/gahow/projects/web' ].join('\n'); assert.deepEqual(parseTmuxPanes(output), [ @@ -71,3 +71,75 @@ test('parseTmuxPanes handles empty output', () => { test('parseTmuxPanes ignores malformed lines', () => { 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')), []); +});