mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user