diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 8a373616d81..35f330078cc 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import type * as SnapshotReader from '../../shared/process-table-snapshot-reader' import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import { inspectSpawnFileChildProcessesFromRows } from './pty-subprocess/spawn-file-child-processes' import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process' import { inspectTerminalHostProcess } from './terminal-host-process-inspection' import { Session } from './session' @@ -226,6 +227,35 @@ it('ignores stopped/background children and another terminal beneath the same ro }) }) +// ps spells "no controlling terminal" as `?` on Linux, `??` on macOS and `-` on some builds. +describe.each(['??', '?', '-'] as const)('root on no controlling terminal (tty=%s)', (tty) => { + const detachedRoot: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 100, + tty, + startTime: 'Sat Sep 26 13:44:29 2026', + stat: 'S', + command: '/bin/zsh' + } + const detachedTree: ProcessTableRow[] = [ + detachedRoot, + { ...detachedRoot, pid: 101, ppid: 100, pgid: 100, command: 'claude' } + ] + + it('declines to name the descendant that shares the group but holds no terminal', () => { + expect(resolveSpawnFileForegroundFromRows(detachedTree, 100)).toEqual({ + available: false, + processName: null + }) + }) + + it('reports child evidence as unverifiable rather than reading an unfenceable table', () => { + expect(inspectSpawnFileChildProcessesFromRows(detachedTree, 100, 'zsh')).toBe('unverifiable') + }) +}) + it('uses Windows job membership and the native process table for ordinary children', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') resolveWindows.mockResolvedValue({ available: true, processName: null }) diff --git a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts index c07e3f77e7a..76d7767b56b 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts @@ -7,6 +7,7 @@ import { collectDescendantsFromIndex, getProcessTableIndex } from '../../../shared/process-table-index' +import { hasControllingTty } from '../../../shared/posix-controlling-terminal' import type { ProcessTableRow } from '../../../shared/process-table-snapshot' import type { PtyChildProcessVerdict } from '../../../shared/terminal-process-inspection' import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' @@ -29,7 +30,7 @@ export function inspectSpawnFileChildProcessesFromRows( ): PtyChildProcessVerdict { const index = getProcessTableIndex(rows) const root = index.byPid.get(rootPid) - if (!root || !shellName || !root.tty || root.tty === '?') { + if (!root || !shellName || !hasControllingTty(root.tty)) { return 'unverifiable' } const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts index b5763f238b1..46c2d5f4696 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -8,6 +8,7 @@ import { collectDescendantsFromIndex, getProcessTableIndex } from '../../../shared/process-table-index' +import { hasControllingTty } from '../../../shared/posix-controlling-terminal' import type { ProcessTableRow } from '../../../shared/process-table-snapshot' import { getFreshProcessTableSnapshot, @@ -52,7 +53,7 @@ export function resolveSpawnFileForegroundFromRows( ): AgentForegroundProcessResolution { const index = getProcessTableIndex(rows) const root = index.byPid.get(rootPid) - if (!root || !root.tpgid || root.tpgid < 0 || !root.tty || root.tty === '?') { + if (!root || !root.tpgid || root.tpgid < 0 || !hasControllingTty(root.tty)) { return { available: false, processName: null } } const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] diff --git a/src/shared/tmux-client-attachment.ts b/src/shared/tmux-client-attachment.ts index 6184ebfc247..653994f618b 100644 --- a/src/shared/tmux-client-attachment.ts +++ b/src/shared/tmux-client-attachment.ts @@ -1,3 +1,4 @@ +import { hasControllingTty } from './posix-controlling-terminal' import type { ProcessTableRow } from './process-table-snapshot' export type TmuxHookPane = { socket: string; pane: string } @@ -51,7 +52,7 @@ export function resolveTmuxClientAttachment( ): TmuxAttachedClient | null { const byPid = new Map(rows.map((row) => [row.pid, row])) const root = byPid.get(rootPid) - if (!root?.startTime || !root.tty || root.tty === '?' || !root.tpgid || root.tpgid <= 0) { + if (!root?.startTime || !hasControllingTty(root.tty) || !root.tpgid || root.tpgid <= 0) { return null } const attached = clients.filter((client) => {