Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions server/modules/providers/services/external-cli-sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand All @@ -39,15 +49,15 @@ 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 });
}
}
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/)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -149,7 +159,13 @@ export function classifyExternalSessions(args: {

function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise<string> {
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(() => {
Expand Down
100 changes: 75 additions & 25 deletions server/modules/providers/services/live-sessions.service.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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.
*
* 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/<pid>/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).
*/
Expand Down Expand Up @@ -168,7 +171,15 @@ export function computeLiveSessions(args: {

function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise<string> {
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(() => {
Expand Down Expand Up @@ -196,32 +207,27 @@ async function safeRealpath(target: string): Promise<string | null> {
}
}

/** Reads the parent pid from /proc/<pid>/stat (comm may contain spaces/parens). */
async function readParentPid(pid: number): Promise<number | null> {
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<number, number> {
const parents = new Map<number, number>();
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<number[]> {
export function buildPidChain(pid: number, parents: ReadonlyMap<number, number>): number[] {
const chain: number[] = [];
const seen = new Set<number>();
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;
}
Expand All @@ -230,6 +236,21 @@ async function buildPidChain(pid: number): Promise<number[]> {
return chain;
}

/** Maps pid → cwd from `lsof -a -p <pids> -d cwd -F pn` output (first path wins). */
export function parseCwdByPidFromLsof(output: string): Map<number, string> {
const cwds = new Map<number, string>();
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<string, string> {
const paths = new Map<string, string>();
Expand Down Expand Up @@ -334,7 +355,7 @@ async function readLastModelFromFile(path: string): Promise<string | null> {

/**
* 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<LiveGjcSession[]> {
let tmuxOutput: string;
Expand All @@ -353,16 +374,45 @@ export async function getLiveGjcSessions(): Promise<LiveGjcSession[]> {

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/<pid>/stat does not exist on
// macOS. Best-effort: an empty map only disables lineage, cwd fallback stays.
let parents: Map<number, number> = 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/<pid>/cwd does not exist on
// macOS; one batched lsof -d cwd works on both platforms. Best-effort too.
let cwdByPid = new Map<number, string>();
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,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
36 changes: 36 additions & 0 deletions server/modules/providers/tests/live-sessions.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down