perf(agent-launch): read a local pane's foreground from its own terminal, not the whole process table

The foreground read that gates every launch paste ran the daemon's
inspectProcess capture and then a fresh scan, each a whole-machine ps; the
fresh one also waits for any capture already running before it starts its own.
Measured here at load 5: 1.2 s a read (M4Air QA: 3.4-5.0 s, and worker starts
17.6-32 s against main's 9-12 s at load 25-84).

On a local macOS or Linux host, take the pane's root pid from the provider's
session inventory and run one ps limited to that pane's terminal. Its
foreground process group decides: the launched agent or any non-shell member
is the agent (a wrapper that did not exec its agent leads the group), a group
of shells alone is the shell. Same pane, same verdict: 2.7 ms a read. SSH hosts
keep the relay's observation and name.
This commit is contained in:
Brennan Benson
2026-10-02 07:54:35 -07:00
parent f02309f8c6
commit 4523a4b3c4
8 changed files with 393 additions and 145 deletions
@@ -31,6 +31,8 @@ export type TranscriptPaneOptions = {
onProcessInspection?: () => void
/** What the host's shell-foreground check answers: the spawned shell holds the foreground. */
shellForegroundProven?: boolean
/** The pane's root process the provider reports; absent for a provider without an inventory. */
paneRootPid?: number
onShellForegroundProof?: () => void
}
@@ -81,6 +83,18 @@ export async function createTranscriptPane(
}
}
: {}),
...(options.paneRootPid !== undefined
? {
listProcesses: async () => [
{
id: TRANSCRIPT_PANE_PTY_ID,
rootProcessId: options.paneRootPid,
cwd: '/repo/app',
title: 'Terminal'
}
]
}
: {}),
...(options.shellForegroundProven !== undefined
? {
confirmShellForeground: async () => {
@@ -17,6 +17,17 @@ import {
} from './launched-agent-composer-readiness'
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import type * as TerminalForegroundGroup from './terminal-foreground-group'
// What `ps` limited to the pane's terminal answers; the verdict over it stays the real one.
const paneTerminal = vi.hoisted(() => {
const state: { rows: ProcessTableRow[] | null } = { rows: null }
return state
})
vi.mock('./terminal-foreground-group', async (importOriginal) => ({
...(await importOriginal<typeof TerminalForegroundGroup>()),
readTerminalProcessRows: vi.fn(async () => paneTerminal.rows)
}))
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -140,47 +151,78 @@ describe('what holds a launched agent’s terminal', () => {
Object.defineProperty(process, 'platform', originalPlatform)
})
describe('macOS and Linux: one fresh foreground read decides', () => {
beforeEach(() => setPlatform('darwin'))
it.each([
// For its first 5 s the daemon's cached read names the launch agent while zsh is in front.
['claude', 'zsh', 'shell'],
// A stub agent that crashed: the cached read can still name it after zsh took the terminal.
['python3', 'zsh', 'shell'],
['claude', 'claude', 'agent'],
// A native Claude names itself by its version.
['zsh', '2.1.285', 'agent'],
['zsh', 'node', 'agent'],
['-zsh', '-zsh', 'shell'],
['bash', 'bash', 'shell'],
[null, 'zsh', 'shell'],
['claude', null, 'unknown']
] as const)('cached %s, scan %s: %s', async (foregroundProcess, scanned, found) => {
const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: scanned,
onForegroundScan: scan,
shellForegroundProven: false,
onShellForegroundProof: proof,
launchAgent: 'claude',
data: ''
})
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe(found)
expect(scan).toHaveBeenCalledOnce()
expect(proof).not.toHaveBeenCalled()
describe('macOS and Linux: the pane terminal’s own foreground group decides', () => {
beforeEach(() => {
setPlatform('darwin')
paneTerminal.rows = null
})
it('proves nothing on a controller without a fresh scan', async () => {
// Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS
// pane is, under `login`: zsh holds the terminal in its own group, never the root's.
const login = (tpgid: number): ProcessTableRow => ({
pid: 60404,
ppid: 60394,
pgid: 60404,
tpgid,
stat: 'Ss',
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c'
})
const zshAt = (tpgid: number): ProcessTableRow => ({
pid: 60406,
ppid: 60404,
pgid: 60406,
tpgid,
stat: tpgid === 60406 ? 'S+' : 'S',
command: '-/bin/zsh -f'
})
it.each([
// A crashed stub's name can outlive it in the cached read; the rows show zsh back in front.
['zsh back at its prompt', [login(60406), zshAt(60406)], 'shell'],
[
'claude in front',
[
login(60500),
zshAt(60500),
{ pid: 60500, ppid: 60406, pgid: 60500, tpgid: 60500, stat: 'S+', command: 'claude' }
],
'agent'
]
] as const)(
'%s: answers from the rows, never the cached name or a whole-machine scan',
async (_label, rows, found) => {
paneTerminal.rows = [...rows]
const scan = vi.fn()
const inspection = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'python3',
confirmedForegroundProcess: 'claude',
onForegroundScan: scan,
processInspection: { foregroundProcess: 'claude', hasChildProcesses: true },
onProcessInspection: inspection,
paneRootPid: 60404,
launchAgent: 'claude',
data: ''
})
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe(found)
expect(scan).not.toHaveBeenCalled()
expect(inspection).not.toHaveBeenCalled()
}
)
it.each([
['without the pane’s root process', undefined, [login(60406), zshAt(60406)]],
['when ps cannot read the pane’s terminal', 60404, null]
] as const)('proves nothing %s', async (_label, paneRootPid, rows) => {
paneTerminal.rows = rows ? [...rows] : null
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'claude',
...(paneRootPid ? { paneRootPid } : {}),
launchAgent: 'claude',
data: ''
})
@@ -189,85 +231,13 @@ describe('what holds a launched agent’s terminal', () => {
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe('unknown')
})
})
// Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS
// pane is, under `login`: zsh holds the terminal in its own group, never the root's.
it('reads a login-wrapped pane’s zsh at its prompt as the shell', async () => {
const rows: ProcessTableRow[] = [
{
pid: 60404,
ppid: 60394,
pgid: 60404,
tpgid: 60406,
stat: 'Ss',
tty: 'ttys004',
startTime: 'Fri Oct 2 01:20:11 2026',
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c'
},
{
pid: 60406,
ppid: 60404,
pgid: 60406,
tpgid: 60406,
stat: 'S+',
tty: 'ttys004',
startTime: 'Fri Oct 2 01:20:11 2026',
command: '-/bin/zsh -f'
}
]
const inspection = {
foregroundProcess: 'zsh',
hasChildProcesses: true,
foregroundProcessEvidence: resolveRemoteForegroundEvidence(
{ rootPid: 60404, fallbackProcess: 'zsh' },
{
ptyId: TRANSCRIPT_PANE_PTY_ID,
ptyIncarnationId: 'inc-1',
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: 0,
platform: 'darwin'
},
rows
)
}
// Presence precondition: the fence is live, and its foreground group is not the root's.
expect(inspection.foregroundProcessEvidence).toMatchObject({
verdict: 'live',
fence: { shellPid: 60404, foregroundPgid: 60406 }
})
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'python3',
processInspection: inspection,
confirmedForegroundProcess: '-zsh',
launchAgent: 'claude',
data: ''
})
// A tcsh or nu launch line runs the agent from `/bin/sh '<script>'`, which leads the terminal's
// foreground group with the agent a member of it, so the relay's name is `sh`.
describe('SSH: the relay’s process-group observation, then its name', () => {
beforeEach(() => setPlatform('darwin'))
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe('shell')
})
// A wrapper that does not `exec` an agent the scan cannot recognize keeps its own name in front,
// which reads as a shell: the prompt stays undelivered rather than risk the user's shell.
it('reads a bash wrapper still in front of an unrecognized agent as a shell', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'bash',
confirmedForegroundProcess: 'bash',
launchAgent: 'copilot',
data: ''
})
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot')
).resolves.toBe('shell')
})
// A tcsh or nu launch line runs the agent from `/bin/sh '<script>'`, which leads the terminal's
// foreground group with the agent a member of it, so the scan names `sh`.
const shLeadsClaude = (capturedAgeMs: number, agentCommand: string) => {
const row = (pid: number, ppid: number, stat: string, command: string): ProcessTableRow => ({
pid,
@@ -275,8 +245,8 @@ describe('what holds a launched agent’s terminal', () => {
pgid: 40210,
tpgid: 40210,
stat,
tty: 'ttys007',
startTime: 'Fri Oct 2 09:30:01 2026',
tty: 'pts/7',
startTime: '1790950000',
command
})
return {
@@ -290,7 +260,7 @@ describe('what holds a launched agent’s terminal', () => {
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs,
platform: 'darwin'
platform: 'linux'
},
[
{ ...row(40100, 40090, 'Ss', '-tcsh'), pgid: 40100 },
@@ -308,14 +278,14 @@ describe('what holds a launched agent’s terminal', () => {
'/opt/bin/claude',
'agent'
],
// Older than the quiet a ready signal needs, it may predate the agent's exit.
// Reused from before the read was asked for, it may predate the agent's exit.
[
'takes the fresh read over an observation too old to trust',
'takes the relay’s name over an observation too old to trust',
1_500,
'/opt/bin/claude',
'shell'
],
['takes the fresh read when the group holds another agent', 0, '/opt/bin/codex', 'shell']
['takes the relay’s name when the group holds another agent', 0, '/opt/bin/codex', 'shell']
] as const)('%s', async (_label, capturedAgeMs, agentCommand, found) => {
const inspection = shLeadsClaude(capturedAgeMs, agentCommand)
// Presence precondition: the observation is live and names the agent in the group.
@@ -327,7 +297,7 @@ describe('what holds a launched agent’s terminal', () => {
paneTitle: 'Terminal',
foregroundProcess: 'sh',
processInspection: inspection,
confirmedForegroundProcess: 'sh',
connectionId: 'ssh-1',
launchAgent: 'claude',
data: ''
})
@@ -99,6 +99,7 @@ async function launchStub(stub: keyof typeof STUBS, readyTimeoutMs: number) {
getForegroundProcess: async () => tracker.getForegroundProcess(),
confirmForegroundProcess: () => tracker.confirmForegroundProcess(),
confirmShellForeground: () => tracker.confirmShellForeground(),
listProcesses: async () => [{ id: PTY_ID, rootProcessId: proc.pid, cwd: home, title: 'zsh' }],
inspectProcess: async () => {
const snapshot = await getStrictProcessTableSnapshotWithAge()
return {
@@ -8,10 +8,48 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import { readLaunchedAgentForeground } from './launched-agent-foreground'
import type * as TerminalForegroundGroup from './terminal-foreground-group'
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
const PTY_ID = 'pty-1'
// What `ps` limited to the pane's terminal answers; the verdict over it stays the real one.
const paneTerminal = vi.hoisted(() => {
const state: { rows: ProcessTableRow[] | null } = { rows: null }
return state
})
vi.mock('./terminal-foreground-group', async (importOriginal) => ({
...(await importOriginal<typeof TerminalForegroundGroup>()),
readTerminalProcessRows: vi.fn(async () => paneTerminal.rows)
}))
/** A macOS pane under `login` whose terminal `group` holds, with `jobs` launched from its zsh. */
function loginPane(
group: number,
jobs: { pid: number; command: string }[] = []
): ProcessTableRow[] {
return [
{
pid: 100,
ppid: 1,
pgid: 100,
tpgid: group,
stat: 'Ss',
command: '/usr/bin/login -flpq user'
},
{ pid: 101, ppid: 100, pgid: 101, tpgid: group, stat: 'S', command: '-zsh' },
...jobs.map(({ pid, command }) => ({
pid,
ppid: 101,
pgid: pid,
tpgid: group,
stat: 'S+',
command
}))
]
}
const SHELL_HANDOFF = '\x1b[?2004l'
/** zsh's prompt, the launch line it runs, then its prompt again once that command exited. */
@@ -35,6 +73,8 @@ type HostAnswers = {
host: { remote: boolean; windows: boolean }
cached: string | null
scanned: string | null
/** The pane terminal's processes, for a local macOS or Linux host. */
rows?: ProcessTableRow[]
shellAlone: boolean
}
@@ -74,6 +114,7 @@ const HOSTS: [string, HostAnswers, () => string[]][] = [
host: { remote: false, windows: false },
cached: 'python3',
scanned: 'zsh',
rows: loginPane(101),
shellAlone: false
},
zshRunsLaunchLineThatExits
@@ -81,6 +122,7 @@ const HOSTS: [string, HostAnswers, () => string[]][] = [
]
function launchedPane(answers: HostAnswers) {
paneTerminal.rows = answers.rows ?? null
const listeners = new Set<(data: string) => void>()
const subscribe = (_ptyId: string, listener: (data: string) => void) => {
listeners.add(listener)
@@ -89,7 +131,8 @@ function launchedPane(answers: HostAnswers) {
const controller = {
getForegroundProcess: async () => answers.cached,
confirmForegroundProcess: async () => answers.scanned,
confirmShellForeground: async () => answers.shellAlone
confirmShellForeground: async () => answers.shellAlone,
listProcesses: async () => [{ id: PTY_ID, rootProcessId: 100, cwd: '/repo', title: 'zsh' }]
}
const readForeground = (ptyId: string) =>
readLaunchedAgentForeground(controller, answers.host, ptyId, answers.agent ?? 'claude')
@@ -187,6 +230,7 @@ describe('a launch prompt after the launched agent exits at startup', () => {
host: { remote: false, windows: false },
cached: 'claude',
scanned: 'claude',
rows: loginPane(200, [{ pid: 200, command: '/opt/bin/claude' }]),
shellAlone: false
})
const delivery = deliverTerminalAgentLaunchPrompt({
@@ -1,7 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import type * as TerminalForegroundGroup from './terminal-foreground-group'
import { readLaunchedAgentForeground } from './launched-agent-foreground'
const paneTerminal = vi.hoisted(() => {
const state: { rows: ProcessTableRow[] | null } = { rows: null }
return state
})
vi.mock('./terminal-foreground-group', async (importOriginal) => ({
...(await importOriginal<typeof TerminalForegroundGroup>()),
readTerminalProcessRows: vi.fn(async () => paneTerminal.rows)
}))
/** A live observation naming `claude` in the terminal's foreground group. */
function claudeInGroup(capturedAgeMs: number): RemoteForegroundEvidence {
return {
@@ -22,7 +33,7 @@ function claudeInGroup(capturedAgeMs: number): RemoteForegroundEvidence {
}
}
/** The fresh scan names the `sh` that leads the group, as it does behind a wrapper. */
/** The relay names the `sh` that leads the group, as it does behind a wrapper. */
function controllerAnswering(inspect: () => Promise<RemoteForegroundEvidence>) {
return {
getForegroundProcess: async () => 'sh',
@@ -36,9 +47,9 @@ function controllerAnswering(inspect: () => Promise<RemoteForegroundEvidence>) {
}
}
const POSIX_LOCAL = { remote: false, windows: false }
const POSIX_SSH = { remote: true, windows: false }
describe('the foreground group as proof of a launched agent', () => {
describe('the relay’s foreground group as proof of a launched agent', () => {
beforeEach(() => {
vi.useFakeTimers()
})
@@ -55,15 +66,49 @@ describe('the foreground group as proof of a launched agent', () => {
})
await expect(
readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude')
readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude')
).resolves.toBe('agent')
})
it('takes the fresh read over a capture reused from before the read was asked for', async () => {
it('takes the relay’s name over a capture reused from before the read was asked for', async () => {
const controller = controllerAnswering(async () => claudeInGroup(1_500))
await expect(
readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude')
readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude')
).resolves.toBe('shell')
})
})
// Why: this read gates every guarded paste, so it answers from the pane's own terminal and never
// waits on a whole-machine capture, which took seconds a read on a loaded host.
describe('a local pane’s foreground read', () => {
it('answers from the pane’s own terminal while every whole-machine read is still pending', async () => {
paneTerminal.rows = [
{ pid: 100, ppid: 1, pgid: 100, tpgid: 200, stat: 'Ss', command: '-zsh' },
{ pid: 200, ppid: 100, pgid: 200, tpgid: 200, stat: 'S+', command: '/opt/bin/claude' }
]
const never = <T>(): Promise<T> => new Promise<T>(() => {})
const controller = {
getForegroundProcess: never<string | null>,
confirmForegroundProcess: never<string | null>,
confirmShellForeground: never<boolean>,
inspectProcess: never<never>,
listProcesses: async () => [{ id: 'pty-1', rootProcessId: 100, cwd: '/repo', title: 'zsh' }]
}
const settled = new Promise<string>((resolve) =>
setTimeout(() => resolve('still waiting'), 1_000)
)
await expect(
Promise.race([
readLaunchedAgentForeground(
controller,
{ remote: false, windows: false },
'pty-1',
'claude'
),
settled
])
).resolves.toBe('agent')
})
})
+18 -18
View File
@@ -7,6 +7,7 @@ import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { judgeTerminalForeground, readTerminalProcessRows } from './terminal-foreground-group'
/**
* What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell
@@ -28,18 +29,15 @@ function isLaunchedAgent(processName: string, agent: TuiAgent): boolean {
}
/**
* First the host's process-group observation, which only ever proves the agent: the launched agent
* among the members of the terminal's foreground group finds it behind a wrapper that did not
* `exec` it (a script, or the `/bin/sh` a tcsh or nu launch line runs), whose own name leads the
* group. It counts only from a capture begun after this read was asked for, less the window a
* shared capture is reused across, and never by how long `ps` took on a loaded host. It never
* proves a shell: a macOS pane's shell runs under `login`, so the group's root is not the shell,
* and a capture that ran out of time is no answer.
* On a local macOS or Linux host, one `ps` limited to the pane's own terminal
* (`terminal-foreground-group`): its foreground process group decides, read fresh in a few
* milliseconds. Never the whole-machine capture behind a fresh scan or `inspectProcess`, which
* took seconds a read on a loaded host and gated every paste behind it, and never the cached name a
* tab icon uses, which can still name a process that already exited.
*
* Otherwise one fresh read of the terminal's foreground process: on a local POSIX host the scan
* behind `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the
* terminal when asked. Never the cached name a tab icon uses: it can still name a process that
* already exited.
* On an SSH host, the relay's process-group observation when it names the launched agent in the
* foreground group (a wrapper that did not `exec` it leads the group), then the relay's own name,
* which it reads from the terminal when asked. The observation never proves a shell.
*
* Windows has no foreground process group, and its scan names the pane's shell for an agent it
* cannot recognize, while Git Bash and WSL keep other processes in the shell's job, so nothing
@@ -48,10 +46,7 @@ function isLaunchedAgent(processName: string, agent: TuiAgent): boolean {
export async function readLaunchedAgentForeground(
controller: Pick<
RuntimePtyController,
| 'getForegroundProcess'
| 'confirmForegroundProcess'
| 'confirmShellForeground'
| 'inspectProcess'
'getForegroundProcess' | 'confirmShellForeground' | 'inspectProcess' | 'listProcesses'
> | null,
host: { remote: boolean; windows: boolean },
ptyId: string,
@@ -65,6 +60,13 @@ export async function readLaunchedAgentForeground(
// An SSH pane has no such check, so on a Windows relay nothing proves a shell.
return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown'
}
if (!host.remote) {
const rootPid = (await controller.listProcesses?.(null))?.find(
(pane) => pane.id === ptyId
)?.rootProcessId
const rows = rootPid ? await readTerminalProcessRows(rootPid) : null
return rows && rootPid ? judgeTerminalForeground(rows, rootPid, agent) : 'unknown'
}
const askedAt = Date.now()
const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence
if (
@@ -76,9 +78,7 @@ export async function readLaunchedAgentForeground(
) {
return 'agent'
}
const foreground = host.remote
? await controller.getForegroundProcess(ptyId)
: ((await controller.confirmForegroundProcess?.(ptyId)) ?? null)
const foreground = await controller.getForegroundProcess(ptyId)
if (!foreground) {
return 'unknown'
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import { judgeTerminalForeground } from './terminal-foreground-group'
type Row = Omit<ProcessTableRow, 'tty' | 'startTime'>
/** A macOS pane under `login`, as `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command= -t` reads it. */
function pane(tpgid: number, jobs: Omit<Row, 'tpgid'>[]): Row[] {
return [
{
pid: 100,
ppid: 1,
pgid: 100,
tpgid,
stat: 'Ss',
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c'
},
{ pid: 101, ppid: 100, pgid: 101, tpgid, stat: tpgid === 101 ? 'S+' : 'S', command: '-zsh' },
...jobs.map((job) => ({ ...job, tpgid }))
]
}
describe('who holds a pane’s terminal, by its foreground process group', () => {
it.each([
// The launch line has not run, or the agent it ran exited and handed the terminal back.
['the pane’s shell at its prompt', pane(101, []), 'shell'],
[
'the agent the launch line ran',
pane(200, [{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/opt/bin/claude' }]),
'agent'
],
// A tcsh or nu launch line, or a wrapper script that does not `exec` its agent.
[
'the agent behind the sh that leads its group',
pane(200, [
{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/bin/sh /tmp/orca-launch/run.sh' },
{ pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: '/opt/bin/claude' }
]),
'agent'
],
[
'an agent it cannot recognize behind a bash wrapper',
pane(200, [
{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: 'bash ./start-agent.sh' },
{ pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: 'node /opt/agent/cli.js' }
]),
'agent'
],
// Between its commands a wrapper is only shells, and typing into it could run the text.
[
'a bash wrapper alone',
pane(200, [{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: 'bash ./start-agent.sh' }]),
'shell'
],
[
'an agent stopped in the background',
pane(101, [{ pid: 200, ppid: 101, pgid: 200, stat: 'T', command: '/opt/bin/claude' }]),
'shell'
]
] as const)('%s: %s', (_label, rows, found) => {
expect(judgeTerminalForeground(rows, 100, 'claude')).toBe(found)
})
it.each([
['the root is not on the terminal', pane(101, []).slice(1)],
['the terminal has no foreground group', pane(0, [])],
['no process is in the foreground group', pane(300, [])]
])('proves nothing when %s', (_label, rows) => {
expect(judgeTerminalForeground(rows, 100, 'claude')).toBe('unknown')
})
})
@@ -0,0 +1,103 @@
/**
* Who holds one pane's terminal, from a `ps` limited to that terminal's processes: a few
* milliseconds, where the whole-machine capture behind a fresh scan or an `inspectProcess`
* observation takes seconds on a loaded host and gated every launch paste behind it.
*/
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { runProcess } from '../../shared/child-process/run-process'
import { parseShellForegroundRows, type ProcessTableRow } from '../../shared/process-table-snapshot'
import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
const PS_TIMEOUT_MS = 3_000
const PS_MAX_OUTPUT_BYTES = 256 * 1024
/** Root pid to its terminal: a live process keeps its controlling terminal. */
const ttyByRootPid = new Map<number, string>()
const TTY_CACHE_LIMIT = 256
async function ps(args: readonly string[]): Promise<string | null> {
try {
const result = await runProcess({
program: 'ps',
args,
timeoutMs: PS_TIMEOUT_MS,
maxOutputBytes: PS_MAX_OUTPUT_BYTES
})
return result.code === 0 ? result.stdout : null
} catch {
return null
}
}
async function terminalOf(rootPid: number): Promise<string | null> {
const cached = ttyByRootPid.get(rootPid)
if (cached) {
return cached
}
const tty = (await ps(['-o', 'tty=', '-p', String(rootPid)]))?.trim()
// `??` (macOS) and `?` (Linux): no controlling terminal.
if (!tty || tty.startsWith('?')) {
return null
}
if (ttyByRootPid.size >= TTY_CACHE_LIMIT) {
ttyByRootPid.clear()
}
ttyByRootPid.set(rootPid, tty)
return tty
}
/** Every process on the terminal a pane's root process holds, or null when `ps` cannot say. */
export async function readTerminalProcessRows(rootPid: number): Promise<ProcessTableRow[] | null> {
const tty = await terminalOf(rootPid)
if (!tty) {
return null
}
const stdout = await ps(['-o', 'pid=,ppid=,pgid=,tpgid=,stat=,command=', '-t', tty])
try {
const rows = stdout === null ? null : parseShellForegroundRows(stdout)
// A pid reused by another process on another terminal reads as this pane's root missing.
if (!rows?.some((row) => row.pid === rootPid)) {
ttyByRootPid.delete(rootPid)
return null
}
return rows
} catch {
return null
}
}
function isShellCommand(command: string): boolean {
const executable = command.trim().split(/\s+/, 1)[0]?.replace(/^-/, '') ?? ''
return isShellProcess(executable.split('/').pop() ?? executable)
}
/**
* The terminal's foreground process group decides. The launched agent among its members, or any
* member that is not a shell, is the agent: that finds it behind a wrapper that did not `exec` it,
* whose own shell name leads the group. A group of shells alone is the shell, whether the pane's
* own (the launch line has not run, or the agent exited) or a wrapper between commands. The pane's
* root is never assumed to be the shell: a macOS pane runs its shell under `login`.
*/
export function judgeTerminalForeground(
rows: readonly ProcessTableRow[],
rootPid: number,
agent: TuiAgent
): 'agent' | 'shell' | 'unknown' {
const root = rows.find((row) => row.pid === rootPid)
const foregroundGroup = root?.tpgid
if (foregroundGroup === undefined || foregroundGroup <= 0) {
return 'unknown'
}
const front = rows.filter((row) => row.pgid === foregroundGroup)
if (front.length === 0) {
return 'unknown'
}
return front.some(
(row) =>
recognizeAgentProcessFromCommandLine(row.command)?.agent === agent ||
!isShellCommand(row.command)
)
? 'agent'
: 'shell'
}