mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +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
|
onProcessInspection?: () => void
|
||||||
/** What the host's shell-foreground check answers: the spawned shell holds the foreground. */
|
/** What the host's shell-foreground check answers: the spawned shell holds the foreground. */
|
||||||
shellForegroundProven?: boolean
|
shellForegroundProven?: boolean
|
||||||
|
/** The pane's root process the provider reports; absent for a provider without an inventory. */
|
||||||
|
paneRootPid?: number
|
||||||
onShellForegroundProof?: () => void
|
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
|
...(options.shellForegroundProven !== undefined
|
||||||
? {
|
? {
|
||||||
confirmShellForeground: async () => {
|
confirmShellForeground: async () => {
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ import {
|
|||||||
} from './launched-agent-composer-readiness'
|
} from './launched-agent-composer-readiness'
|
||||||
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
|
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
|
||||||
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
|
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', () => ({
|
vi.mock('electron', () => ({
|
||||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||||
@@ -140,47 +151,78 @@ describe('what holds a launched agent’s terminal', () => {
|
|||||||
Object.defineProperty(process, 'platform', originalPlatform)
|
Object.defineProperty(process, 'platform', originalPlatform)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('macOS and Linux: one fresh foreground read decides', () => {
|
describe('macOS and Linux: the pane terminal’s own foreground group decides', () => {
|
||||||
beforeEach(() => setPlatform('darwin'))
|
beforeEach(() => {
|
||||||
|
setPlatform('darwin')
|
||||||
it.each([
|
paneTerminal.rows = null
|
||||||
// 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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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({
|
const { runtime } = await createTranscriptPane({
|
||||||
paneTitle: 'Claude Code',
|
paneTitle: 'Claude Code',
|
||||||
foregroundProcess: 'claude',
|
foregroundProcess: 'claude',
|
||||||
|
...(paneRootPid ? { paneRootPid } : {}),
|
||||||
launchAgent: 'claude',
|
launchAgent: 'claude',
|
||||||
data: ''
|
data: ''
|
||||||
})
|
})
|
||||||
@@ -189,85 +231,13 @@ describe('what holds a launched agent’s terminal', () => {
|
|||||||
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
|
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
|
||||||
).resolves.toBe('unknown')
|
).resolves.toBe('unknown')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS
|
// A tcsh or nu launch line runs the agent from `/bin/sh '<script>'`, which leads the terminal's
|
||||||
// pane is, under `login`: zsh holds the terminal in its own group, never the root's.
|
// foreground group with the agent a member of it, so the relay's name is `sh`.
|
||||||
it('reads a login-wrapped pane’s zsh at its prompt as the shell', async () => {
|
describe('SSH: the relay’s process-group observation, then its name', () => {
|
||||||
const rows: ProcessTableRow[] = [
|
beforeEach(() => setPlatform('darwin'))
|
||||||
{
|
|
||||||
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: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
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 shLeadsClaude = (capturedAgeMs: number, agentCommand: string) => {
|
||||||
const row = (pid: number, ppid: number, stat: string, command: string): ProcessTableRow => ({
|
const row = (pid: number, ppid: number, stat: string, command: string): ProcessTableRow => ({
|
||||||
pid,
|
pid,
|
||||||
@@ -275,8 +245,8 @@ describe('what holds a launched agent’s terminal', () => {
|
|||||||
pgid: 40210,
|
pgid: 40210,
|
||||||
tpgid: 40210,
|
tpgid: 40210,
|
||||||
stat,
|
stat,
|
||||||
tty: 'ttys007',
|
tty: 'pts/7',
|
||||||
startTime: 'Fri Oct 2 09:30:01 2026',
|
startTime: '1790950000',
|
||||||
command
|
command
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -290,7 +260,7 @@ describe('what holds a launched agent’s terminal', () => {
|
|||||||
authorityGeneration: 'gen-1',
|
authorityGeneration: 'gen-1',
|
||||||
observationEpoch: 1,
|
observationEpoch: 1,
|
||||||
capturedAgeMs,
|
capturedAgeMs,
|
||||||
platform: 'darwin'
|
platform: 'linux'
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{ ...row(40100, 40090, 'Ss', '-tcsh'), pgid: 40100 },
|
{ ...row(40100, 40090, 'Ss', '-tcsh'), pgid: 40100 },
|
||||||
@@ -308,14 +278,14 @@ describe('what holds a launched agent’s terminal', () => {
|
|||||||
'/opt/bin/claude',
|
'/opt/bin/claude',
|
||||||
'agent'
|
'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,
|
1_500,
|
||||||
'/opt/bin/claude',
|
'/opt/bin/claude',
|
||||||
'shell'
|
'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) => {
|
] as const)('%s', async (_label, capturedAgeMs, agentCommand, found) => {
|
||||||
const inspection = shLeadsClaude(capturedAgeMs, agentCommand)
|
const inspection = shLeadsClaude(capturedAgeMs, agentCommand)
|
||||||
// Presence precondition: the observation is live and names the agent in the group.
|
// 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',
|
paneTitle: 'Terminal',
|
||||||
foregroundProcess: 'sh',
|
foregroundProcess: 'sh',
|
||||||
processInspection: inspection,
|
processInspection: inspection,
|
||||||
confirmedForegroundProcess: 'sh',
|
connectionId: 'ssh-1',
|
||||||
launchAgent: 'claude',
|
launchAgent: 'claude',
|
||||||
data: ''
|
data: ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async function launchStub(stub: keyof typeof STUBS, readyTimeoutMs: number) {
|
|||||||
getForegroundProcess: async () => tracker.getForegroundProcess(),
|
getForegroundProcess: async () => tracker.getForegroundProcess(),
|
||||||
confirmForegroundProcess: () => tracker.confirmForegroundProcess(),
|
confirmForegroundProcess: () => tracker.confirmForegroundProcess(),
|
||||||
confirmShellForeground: () => tracker.confirmShellForeground(),
|
confirmShellForeground: () => tracker.confirmShellForeground(),
|
||||||
|
listProcesses: async () => [{ id: PTY_ID, rootProcessId: proc.pid, cwd: home, title: 'zsh' }],
|
||||||
inspectProcess: async () => {
|
inspectProcess: async () => {
|
||||||
const snapshot = await getStrictProcessTableSnapshotWithAge()
|
const snapshot = await getStrictProcessTableSnapshotWithAge()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,10 +8,48 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt'
|
import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt'
|
||||||
|
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
|
||||||
import { readLaunchedAgentForeground } from './launched-agent-foreground'
|
import { readLaunchedAgentForeground } from './launched-agent-foreground'
|
||||||
|
import type * as TerminalForegroundGroup from './terminal-foreground-group'
|
||||||
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
|
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
|
||||||
|
|
||||||
const PTY_ID = 'pty-1'
|
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'
|
const SHELL_HANDOFF = '\x1b[?2004l'
|
||||||
|
|
||||||
/** zsh's prompt, the launch line it runs, then its prompt again once that command exited. */
|
/** 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 }
|
host: { remote: boolean; windows: boolean }
|
||||||
cached: string | null
|
cached: string | null
|
||||||
scanned: string | null
|
scanned: string | null
|
||||||
|
/** The pane terminal's processes, for a local macOS or Linux host. */
|
||||||
|
rows?: ProcessTableRow[]
|
||||||
shellAlone: boolean
|
shellAlone: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +114,7 @@ const HOSTS: [string, HostAnswers, () => string[]][] = [
|
|||||||
host: { remote: false, windows: false },
|
host: { remote: false, windows: false },
|
||||||
cached: 'python3',
|
cached: 'python3',
|
||||||
scanned: 'zsh',
|
scanned: 'zsh',
|
||||||
|
rows: loginPane(101),
|
||||||
shellAlone: false
|
shellAlone: false
|
||||||
},
|
},
|
||||||
zshRunsLaunchLineThatExits
|
zshRunsLaunchLineThatExits
|
||||||
@@ -81,6 +122,7 @@ const HOSTS: [string, HostAnswers, () => string[]][] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function launchedPane(answers: HostAnswers) {
|
function launchedPane(answers: HostAnswers) {
|
||||||
|
paneTerminal.rows = answers.rows ?? null
|
||||||
const listeners = new Set<(data: string) => void>()
|
const listeners = new Set<(data: string) => void>()
|
||||||
const subscribe = (_ptyId: string, listener: (data: string) => void) => {
|
const subscribe = (_ptyId: string, listener: (data: string) => void) => {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
@@ -89,7 +131,8 @@ function launchedPane(answers: HostAnswers) {
|
|||||||
const controller = {
|
const controller = {
|
||||||
getForegroundProcess: async () => answers.cached,
|
getForegroundProcess: async () => answers.cached,
|
||||||
confirmForegroundProcess: async () => answers.scanned,
|
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) =>
|
const readForeground = (ptyId: string) =>
|
||||||
readLaunchedAgentForeground(controller, answers.host, ptyId, answers.agent ?? 'claude')
|
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 },
|
host: { remote: false, windows: false },
|
||||||
cached: 'claude',
|
cached: 'claude',
|
||||||
scanned: 'claude',
|
scanned: 'claude',
|
||||||
|
rows: loginPane(200, [{ pid: 200, command: '/opt/bin/claude' }]),
|
||||||
shellAlone: false
|
shellAlone: false
|
||||||
})
|
})
|
||||||
const delivery = deliverTerminalAgentLaunchPrompt({
|
const delivery = deliverTerminalAgentLaunchPrompt({
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
|
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'
|
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. */
|
/** A live observation naming `claude` in the terminal's foreground group. */
|
||||||
function claudeInGroup(capturedAgeMs: number): RemoteForegroundEvidence {
|
function claudeInGroup(capturedAgeMs: number): RemoteForegroundEvidence {
|
||||||
return {
|
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>) {
|
function controllerAnswering(inspect: () => Promise<RemoteForegroundEvidence>) {
|
||||||
return {
|
return {
|
||||||
getForegroundProcess: async () => 'sh',
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
})
|
})
|
||||||
@@ -55,15 +66,49 @@ describe('the foreground group as proof of a launched agent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude')
|
readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude')
|
||||||
).resolves.toBe('agent')
|
).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))
|
const controller = controllerAnswering(async () => claudeInGroup(1_500))
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
readLaunchedAgentForeground(controller, POSIX_LOCAL, 'pty-1', 'claude')
|
readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude')
|
||||||
).resolves.toBe('shell')
|
).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 type { TuiAgent } from '../../shared/tui-agent'
|
||||||
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||||
import type { RuntimePtyController } from './runtime-pty-controller-contract'
|
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
|
* 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
|
* On a local macOS or Linux host, one `ps` limited to the pane's own terminal
|
||||||
* among the members of the terminal's foreground group finds it behind a wrapper that did not
|
* (`terminal-foreground-group`): its foreground process group decides, read fresh in a few
|
||||||
* `exec` it (a script, or the `/bin/sh` a tcsh or nu launch line runs), whose own name leads the
|
* milliseconds. Never the whole-machine capture behind a fresh scan or `inspectProcess`, which
|
||||||
* group. It counts only from a capture begun after this read was asked for, less the window a
|
* took seconds a read on a loaded host and gated every paste behind it, and never the cached name a
|
||||||
* shared capture is reused across, and never by how long `ps` took on a loaded host. It never
|
* tab icon uses, which can still name a process that already exited.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Otherwise one fresh read of the terminal's foreground process: on a local POSIX host the scan
|
* On an SSH host, the relay's process-group observation when it names the launched agent in the
|
||||||
* behind `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the
|
* foreground group (a wrapper that did not `exec` it leads the group), then the relay's own name,
|
||||||
* terminal when asked. Never the cached name a tab icon uses: it can still name a process that
|
* which it reads from the terminal when asked. The observation never proves a shell.
|
||||||
* already exited.
|
|
||||||
*
|
*
|
||||||
* Windows has no foreground process group, and its scan names the pane's shell for an agent it
|
* 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
|
* 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(
|
export async function readLaunchedAgentForeground(
|
||||||
controller: Pick<
|
controller: Pick<
|
||||||
RuntimePtyController,
|
RuntimePtyController,
|
||||||
| 'getForegroundProcess'
|
'getForegroundProcess' | 'confirmShellForeground' | 'inspectProcess' | 'listProcesses'
|
||||||
| 'confirmForegroundProcess'
|
|
||||||
| 'confirmShellForeground'
|
|
||||||
| 'inspectProcess'
|
|
||||||
> | null,
|
> | null,
|
||||||
host: { remote: boolean; windows: boolean },
|
host: { remote: boolean; windows: boolean },
|
||||||
ptyId: string,
|
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.
|
// An SSH pane has no such check, so on a Windows relay nothing proves a shell.
|
||||||
return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown'
|
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 askedAt = Date.now()
|
||||||
const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence
|
const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence
|
||||||
if (
|
if (
|
||||||
@@ -76,9 +78,7 @@ export async function readLaunchedAgentForeground(
|
|||||||
) {
|
) {
|
||||||
return 'agent'
|
return 'agent'
|
||||||
}
|
}
|
||||||
const foreground = host.remote
|
const foreground = await controller.getForegroundProcess(ptyId)
|
||||||
? await controller.getForegroundProcess(ptyId)
|
|
||||||
: ((await controller.confirmForegroundProcess?.(ptyId)) ?? null)
|
|
||||||
if (!foreground) {
|
if (!foreground) {
|
||||||
return 'unknown'
|
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