diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index d70d8025fb..7ecdb42fb1 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -25,6 +25,16 @@ export type ExternalCliSession = { tmuxName: string; kind: ExternalCliKind }; /** Matches the tower/live-send tmux-name discipline; also safe to embed in a shell command. */ export const EXTERNAL_TMUX_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/; +/** + * macOS `ps -eo comm` prints the executable's FULL PATH (e.g. + * /Applications/ChatGPT.app/Contents/Resources/codex) while Linux prints the + * bare name — classification compares bare names, so normalize to basename. + */ +function commBasename(comm: string): string { + const slash = comm.lastIndexOf('/'); + return slash < 0 ? comm : comm.slice(slash + 1); +} + /** Parses `#{session_name}\t#{pane_pid}\t#{pane_current_command}` lines. */ export function parseExternalPanes(output: string): Array<{ name: string; pid: number; command: string }> { const panes: Array<{ name: string; pid: number; command: string }> = []; @@ -39,7 +49,7 @@ export function parseExternalPanes(output: string): Array<{ name: string; pid: n } const name = raw.slice(0, first).trim(); const pid = Number.parseInt(raw.slice(first + 1, second).trim(), 10); - const command = raw.slice(second + 1).trim(); + const command = commBasename(raw.slice(second + 1).trim()); if (name && Number.isFinite(pid)) { panes.push({ name, pid, command }); } @@ -47,7 +57,7 @@ export function parseExternalPanes(output: string): Array<{ name: string; pid: n return panes; } -/** Parses `ps -eo pid,ppid,comm` output into {pid, ppid, comm} rows (header tolerated). */ +/** Parses `ps -eo pid,ppid,comm` into {pid, ppid, comm} rows (header tolerated, comm → basename). */ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; comm: string }> { const rows: Array<{ pid: number; ppid: number; comm: string }> = []; for (const raw of output.split(/\r?\n/)) { @@ -59,7 +69,7 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number; if (!match) { continue; // header or malformed line } - rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: match[3].trim() }); + rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: commBasename(match[3].trim()) }); } return rows; } @@ -149,7 +159,13 @@ export function classifyExternalSessions(args: { function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, cmdArgs, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const child = spawn(command, cmdArgs, { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // Service managers ship no locale; non-UTF-8 tmux output sanitizes \t + // separators to `_` (see live-sessions.service.ts). Force UTF-8. + env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' }, + }); let stdout = ''; let settled = false; const timer = setTimeout(() => { diff --git a/server/modules/providers/services/live-sessions.service.ts b/server/modules/providers/services/live-sessions.service.ts index 93c0db656d..24a13cb0de 100644 --- a/server/modules/providers/services/live-sessions.service.ts +++ b/server/modules/providers/services/live-sessions.service.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { open, readFile, realpath, stat } from 'node:fs/promises'; +import { open, realpath, stat } from 'node:fs/promises'; /** * Live gjc session detection + tmux-session naming. @@ -7,14 +7,17 @@ import { open, readFile, realpath, stat } from 'node:fs/promises'; * A gjc session is "live" when a running gjc process has its transcript file open. * For the "작동 중" fleet view we also map each live session id → the tmux session * NAME it runs in (omg / stock / flask / …), by PROCESS LINEAGE: - * - lsof (-c gjc -F pn) → {session-id uuid, holder pid} for open session files - * - /proc//stat → the holder's ancestor pid chain + * - lsof (-c gjc/bun/node -F pn) → {session-id uuid, holder pid} for open session + * files (macOS: gjc runs under its runtime wrapper, so comm is `bun`/`node` — + * `-c gjc` alone finds nothing there; the session-file path is the real filter) + * - ps -eo pid=,ppid= → one snapshot for the holder's ancestor pid chain + * (portable: macOS has no /proc) * - tmux list-panes → {session_name, pane_pid, pane cwd (realpath)} * - a pane_pid found in the holder's ancestor chain → that pane's tmux name (0 ambiguity) * - cwd equality is a FALLBACK only (many-to-many when panes share a cwd) * * Matching is PATH-AGNOSTIC (uuid + realpath'd cwds), so production cloudcli's - * decoy HOME (whose `.gjc` is a symlink) does not break it. tmux/lsof/proc access + * decoy HOME (whose `.gjc` is a symlink) does not break it. tmux/lsof/ps access * is ISOLATED here and fails closed to [] (or tmuxName:null on a miss — the UI * falls back to the conversation title). */ @@ -168,7 +171,15 @@ export function computeLiveSessions(args: { function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, cmdArgs, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const child = spawn(command, cmdArgs, { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // Service managers (launchd/systemd) ship no locale. In a non-UTF-8 + // locale tmux SANITIZES its output — the \t field separators come back + // as `_` and non-ASCII paths get escaped — which silently unparses every + // pane row (실측 macOS launchd: 모든 세션 tmuxName null). Force UTF-8. + env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' }, + }); let stdout = ''; let settled = false; const timer = setTimeout(() => { @@ -196,32 +207,27 @@ async function safeRealpath(target: string): Promise { } } -/** Reads the parent pid from /proc//stat (comm may contain spaces/parens). */ -async function readParentPid(pid: number): Promise { - try { - const content = await readFile(`/proc/${pid}/stat`, 'utf8'); - const rparen = content.lastIndexOf(')'); - if (rparen < 0) { - return null; +/** Parses `ps -eo pid=,ppid=` output into a child pid → parent pid map. */ +export function parsePidParents(output: string): Map { + const parents = new Map(); + for (const raw of output.split(/\r?\n/)) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(raw); + if (match) { + parents.set(Number.parseInt(match[1], 10), Number.parseInt(match[2], 10)); } - // After "pid (comm)" the fields are: state ppid pgrp … → index 1 is ppid. - const fields = content.slice(rparen + 2).trim().split(/\s+/); - const ppid = Number.parseInt(fields[1] ?? '', 10); - return Number.isFinite(ppid) ? ppid : null; - } catch { - return null; } + return parents; } /** Walks the ancestor pid chain [pid, ppid, …] toward init (depth/cycle guarded). */ -async function buildPidChain(pid: number): Promise { +export function buildPidChain(pid: number, parents: ReadonlyMap): number[] { const chain: number[] = []; const seen = new Set(); let cur = pid; for (let i = 0; i < 64 && cur > 1 && !seen.has(cur); i += 1) { chain.push(cur); seen.add(cur); - const parent = await readParentPid(cur); + const parent = parents.get(cur); if (parent == null) { break; } @@ -230,6 +236,21 @@ async function buildPidChain(pid: number): Promise { return chain; } +/** Maps pid → cwd from `lsof -a -p -d cwd -F pn` output (first path wins). */ +export function parseCwdByPidFromLsof(output: string): Map { + const cwds = new Map(); + let pid: number | null = null; + for (const raw of output.split(/\r?\n/)) { + if (raw.startsWith('p')) { + const parsed = Number.parseInt(raw.slice(1), 10); + pid = Number.isFinite(parsed) ? parsed : null; + } else if (raw.startsWith('n') && pid != null && !cwds.has(pid)) { + cwds.set(pid, raw.slice(1)); + } + } + return cwds; +} + /** Maps session id → transcript path from lsof `n` lines (first path wins). */ export function extractSessionPathsFromLsof(output: string): Map { const paths = new Map(); @@ -334,7 +355,7 @@ async function readLastModelFromFile(path: string): Promise { /** * Returns live gjc sessions with their tmux session name. Empty on any failure - * (no tmux/lsof, spawn error) — tmux/lsof/proc dependence is confined here. + * (no tmux/lsof, spawn error) — tmux/lsof/ps dependence is confined here. */ export async function getLiveGjcSessions(): Promise { let tmuxOutput: string; @@ -353,16 +374,45 @@ export async function getLiveGjcSessions(): Promise { let lsofOutput: string; try { - lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-F', 'pn']); + // -c matches the process COMM: a Linux gjc binary is `gjc`, but a script + // install runs under its runtime (macOS 실측: comm은 `bun`) — cover both. + // SESSION_FILE_RE below is the authoritative filter; -c only bounds cost. + lsofOutput = await runCommand('lsof', ['-c', 'gjc', '-c', 'bun', '-c', 'node', '-F', 'pn']); } catch { return []; } + const holders = parseLsofPidSessions(lsofOutput); + + // One ps snapshot for ancestor chains — /proc//stat does not exist on + // macOS. Best-effort: an empty map only disables lineage, cwd fallback stays. + let parents: Map = new Map(); + try { + parents = parsePidParents(await runCommand('ps', ['-eo', 'pid=,ppid='])); + } catch { + // fall through with an empty map + } + + // Holder cwds for the label-only fallback — /proc//cwd does not exist on + // macOS; one batched lsof -d cwd works on both platforms. Best-effort too. + let cwdByPid = new Map(); + const holderPids = [...new Set(holders.map((holder) => holder.pid))]; + if (holderPids.length > 0) { + try { + cwdByPid = parseCwdByPidFromLsof( + await runCommand('lsof', ['-a', '-p', holderPids.join(','), '-d', 'cwd', '-F', 'pn']), + ); + } catch { + // fall through with an empty map + } + } + const sessions: Array<{ id: string; pidChain: number[]; cwd: string | null }> = []; - for (const { id, pid } of parseLsofPidSessions(lsofOutput)) { + for (const { id, pid } of holders) { + const rawCwd = cwdByPid.get(pid); sessions.push({ id, - pidChain: await buildPidChain(pid), - cwd: await safeRealpath(`/proc/${pid}/cwd`), + pidChain: buildPidChain(pid, parents), + cwd: rawCwd ? await safeRealpath(rawCwd) : null, }); } diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index 8a18420e9b..77922455ac 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -25,6 +25,29 @@ test('parsePsTree parses pid,ppid,comm rows and tolerates the header', () => { ]); }); +test('parsePsTree normalizes macOS full-path comm to basename (실측 shape)', () => { + // macOS `ps -eo comm` prints executable paths (may contain spaces); Linux prints bare names. + const out = parsePsTree([ + ' PID PPID COMM', + '21852 21706 /Applications/ChatGPT.app/Contents/Resources/codex', + '21995 21706 /Users/dev/.codex/computer-use/Codex Computer Use.app/Contents/MacOS/SkyComputerUseService', + '89726 89725 bun', + ].join('\n')); + assert.deepEqual(out, [ + { pid: 21852, ppid: 21706, comm: 'codex' }, + { pid: 21995, ppid: 21706, comm: 'SkyComputerUseService' }, + { pid: 89726, ppid: 89725, comm: 'bun' }, + ]); +}); + +test('classifyExternalSessions: macOS full-path codex descendant still classifies via basename', () => { + const result = classifyExternalSessions({ + panes: parseExternalPanes('gpt\t21706\tzsh\n'), + procs: parsePsTree('21706 1 zsh\n21852 21706 /Applications/ChatGPT.app/Contents/Resources/codex\n'), + }); + assert.deepEqual(result, [{ tmuxName: 'gpt', kind: 'codex' }]); +}); + test('classifyExternalSessions: claude pane by pane_current_command (실측 shape)', () => { const result = classifyExternalSessions({ panes: [{ name: 'patina', pid: 113501, command: 'claude' }], diff --git a/server/modules/providers/tests/live-sessions.service.test.ts b/server/modules/providers/tests/live-sessions.service.test.ts index bf86572b44..3e07627b20 100644 --- a/server/modules/providers/tests/live-sessions.service.test.ts +++ b/server/modules/providers/tests/live-sessions.service.test.ts @@ -2,10 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildPidChain, computeLiveSessions, extractSessionPathsFromLsof, + parseCwdByPidFromLsof, parseLastModelChange, parseLsofPidSessions, + parsePidParents, parseTmuxPanes, tmuxHasPanes, } from '@/modules/providers/services/live-sessions.service.js'; @@ -39,6 +42,39 @@ test('parseLsofPidSessions pairs uuid with holder pid, path-agnostic (decoy-HOME ]); }); +test('parsePidParents parses headerless `ps -eo pid=,ppid=` output (BSD right-aligned padding)', () => { + // macOS(BSD) ps pads columns with leading spaces; Linux(procps) output parses identically. + const parents = parsePidParents(' 1 0\n89726 89725\n93770 93769\n\nnot a row\n'); + assert.deepEqual([...parents], [[1, 0], [89726, 89725], [93770, 93769]]); +}); + +test('buildPidChain walks [pid, ppid, …] toward init from a ps snapshot', () => { + // 실측 macOS shape: bun(gjc) → zsh -c wrapper → tmux pane pid. + const parents = new Map([[93770, 93769], [93769, 93768], [93768, 1]]); + assert.deepEqual(buildPidChain(93770, parents), [93770, 93769, 93768]); + // unknown pid: chain is just the pid itself (lineage miss, not a crash) + assert.deepEqual(buildPidChain(555, new Map()), [555]); +}); + +test('buildPidChain is cycle-guarded (corrupt/racing ps snapshot cannot loop)', () => { + const parents = new Map([[10, 20], [20, 10]]); + assert.deepEqual(buildPidChain(10, parents), [10, 20]); +}); + +test('parseCwdByPidFromLsof maps pid → cwd (first path wins, spaces preserved)', () => { + const lsof = [ + 'p31394', + 'n/Volumes/Data/Dev Workspace/lazy-dev-cli', + 'p89726', + 'n/tmp', + 'nphantom-second-path', + ].join('\n'); + assert.deepEqual([...parseCwdByPidFromLsof(lsof)], [ + [31394, '/Volumes/Data/Dev Workspace/lazy-dev-cli'], + [89726, '/tmp'], + ]); +}); + test('computeLiveSessions maps each live session to its tmux name by pid lineage', () => { const result = computeLiveSessions({ tmuxPresent: true,