Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
af8e492
feat(orchestration): wake an auto-slept pane when mail arrives for it
Sep 4, 2026
89991ff
feat(terminals): list slept panes as resumable and let orchestration …
Sep 4, 2026
1b45ebf
fix: guard synthetic handles, keep session listing off the repo inven…
Sep 4, 2026
24d2ea9
chore: drop the unrelated pnpm-lock churn a local toolchain run intro…
Sep 4, 2026
11feb01
fix(orchestration): wake a slept pane whose handle still resolves
Sep 4, 2026
148b09a
fix(orchestration): wake a slept pane whose tab is still mounted
Sep 4, 2026
959116f
fix(orchestration): harden slept-agent mail wake
Sep 4, 2026
fae2acc
fix(orchestration): preserve slept agent wake recovery
Sep 4, 2026
0013746
fix(orchestration): redrive wakes after graph recovery
Sep 4, 2026
990e8bd
Fix status bar runtime target render loop (#18685)
Jinwoo-H Sep 4, 2026
a793f51
fix(orchestration): deliver mail after Codex wake
Sep 5, 2026
31aeb74
fix(orchestration): redrive mail on Codex reattach
Sep 5, 2026
2122edb
fix(orchestration): redrive mail when Codex identity arrives
Sep 5, 2026
2f0f06f
fix(orchestration): verify Codex pointer submission
Sep 5, 2026
5bd7900
fix(orchestration): deliver wake mail to resumed Codex
Sep 5, 2026
70ea538
fix(orchestration): scope fresh foreground confirmation
Sep 5, 2026
76feffa
fix(orchestration): retry resumed Codex mail delivery
Sep 5, 2026
188b87b
fix(orchestration): avoid retrying accepted Codex prompts
Sep 5, 2026
f529dee
perf(runtime): preserve ordinary prompt scheduling
Sep 5, 2026
e043107
fix(orchestration): redrive resumed run by stable pane
Sep 5, 2026
a78f375
refactor(processes): extract Linux start-time capture
Sep 5, 2026
ec51482
Merge origin/main into impl-agent-sleep-wake
Sep 5, 2026
b27c54b
Merge origin/main into impl-agent-sleep-wake
Sep 5, 2026
c939764
fix(renderer): keep process cadence timing browser-safe
Sep 5, 2026
5db91a4
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
Sep 5, 2026
2293df1
fix orchestration mail wake for resumed Codex
Sep 5, 2026
de55469
fix terminal wake scope for sleeping split panes
Sep 5, 2026
d3662d1
fix: preserve agent authority when moving terminal panes
Sep 5, 2026
9953541
fix(agent-hibernation): restore status after cold resume
Sep 5, 2026
85fccd2
test(terminal): include cold restore scope ref
Sep 5, 2026
9c7672a
Merge origin/main into brennanb2025/impl-agent-sleep-wake
Sep 6, 2026
46ecbb9
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 Sep 21, 2026
c7f0714
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 Sep 21, 2026
8203d18
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 Sep 21, 2026
f322364
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 Sep 21, 2026
7395593
test(orchestration): repair merged sleep-wake fixtures
brennanb2025 Sep 21, 2026
b83c713
Merge remote-tracking branch 'origin/main' into brennanb2025/impl-age…
brennanb2025 Sep 21, 2026
9851cc9
refactor(renderer): drop dead planMobileTerminalTabMount wrapper
brennanb2025 Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/terminal-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function formatTerminalList(
const body = result.terminals
.map(
(terminal) =>
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'}${terminal.resumable ? ' asleep(resumable)' : ''} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.join('\n\n')
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
Expand Down
18 changes: 14 additions & 4 deletions src/main/ipc/pty-runtime-kill-and-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,24 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, installDaemonTestProvider, installObservableDaemonTestProvider } =
setupPtyIpcSuite()

function runtimeControllerFixture<T>(value: unknown): T {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registerPtyHandlers installs this controller before each test reads it, and the fixture names only methods the test invokes.
return value as T
}

it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => {
const confirmForegroundProcess = vi.fn(async () => 'codex')
registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never)
setPtyOwnership('remote-pty', 'ssh-1')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
const controller = runtimeControllerFixture<{
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
supportsForegroundProcessConfirmation: (ptyId: string) => boolean
}>(runtime.setPtyController.mock.calls[0]?.[0])

expect(controller.supportsForegroundProcessConfirmation('remote-pty')).toBe(true)
await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex')
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty')
Expand Down Expand Up @@ -153,10 +160,13 @@ describe('registerPtyHandlers', () => {
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
const controller = runtimeControllerFixture<{
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
supportsForegroundProcessConfirmation: (ptyId: string) => boolean
}>(runtime.setPtyController.mock.calls[0]?.[0])

expect(controller.supportsForegroundProcessConfirmation('unsupported-pty')).toBe(false)
expect(controller.supportsForegroundProcessConfirmation('missing-pty')).toBe(false)
await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull()
await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull()
deletePtyOwnership('unsupported-pty')
Expand Down
3 changes: 3 additions & 0 deletions src/main/ipc/pty/runtime/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
probePtyLivenessFromRuntimeController,
resizePtyFromRuntimeController,
serializeProviderBufferFromRuntimeController,
supportsForegroundProcessConfirmationFromRuntimeController,
waitForRendererSerializerFromRuntimeController,
writePtyAgentSessionProofFromRuntimeController,
writePtyFromRuntimeController
Expand Down Expand Up @@ -64,6 +65,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId),
inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options),
confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId),
supportsForegroundProcessConfirmation: (ptyId) =>
supportsForegroundProcessConfirmationFromRuntimeController(ptyId),
confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId),
getCwd: (ptyId) => getCwdFromRuntimeController(ptyId),
hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId),
Expand Down
8 changes: 8 additions & 0 deletions src/main/ipc/pty/runtime/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin
}
}

export function supportsForegroundProcessConfirmationFromRuntimeController(ptyId: string): boolean {
try {
return getProviderForPty(ptyId).confirmForegroundProcess !== undefined
} catch {
return false
}
}

export async function confirmShellForegroundFromRuntimeController(ptyId: string) {
try {
return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false
Expand Down
282 changes: 282 additions & 0 deletions src/main/providers/agent-foreground-process-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn()
}))

vi.mock('child_process', () => ({
execFile: execFileMock
}))

const getAllProcessesMock = vi.fn()

import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader'
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import {
confirmShellForegroundProcess,
resolveAgentForegroundProcess,
resolveAgentForegroundProcessWithAvailability
} from './agent-foreground-process'

describe('agent foreground confirmation', () => {
let platform: PropertyDescriptor | undefined

beforeEach(() => {
execFileMock.mockReset()
getAllProcessesMock.mockReset()
resetProcessTableSnapshotForTests()
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
getAllProcesses: getAllProcessesMock
}))
platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})

afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
})

function mockPs(stdout: string): void {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, { stdout, stderr: '' })
}
)
}

it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock here (101 999 S+ node …) is not a well-formed PTY-scoped ps row, so parseStrictProcessTableRows throws malformed_row before the root = rootRows.find(row => row.pid === rootPid) lookup ever runs. The assertion therefore passes via the malformed-row rejection (already covered by the rejects a malformed row case) rather than the missing-root fallback this test's name promises. Feed the -p capture a valid row for a different pid — or a root row whose tty is ?/- — so the !root?.tty whole-host fallback branch is actually exercised.

mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex')

await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
})

it('fresh-confirms Codex from its PTY when a whole-host scan would time out', async () => {
execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
if (args[0] === '-p') {
callback(null, {
stdout: '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n',
stderr: ''
})
return
}
if (args[0] === '-t') {
callback(null, {
stdout: [
'100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l',
'101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 node /opt/homebrew/bin/codex',
'102 101 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 /opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/bin/codex'
].join('\n'),
stderr: ''
})
return
}
callback(new Error('whole-host ps timed out'), { stdout: '', stderr: '' })
})

await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: true, processName: 'codex' })
expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t'])
})

it('rejects a PTY capture when its root process changes between samples', async () => {
execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout:
args[0] === '-p'
? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n'
: [
'100 99 100 101 S ttys060 Fri Sep 4 15:53:35 2026 -/bin/zsh -l',
'101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex'
].join('\n'),
stderr: ''
})
})

await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t'])
})

it('rejects a malformed row in a PTY-scoped confirmation capture', async () => {
execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(null, {
stdout:
args[0] === '-p'
? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n'
: [
'100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l',
'malformed foreground row',
'101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex'
].join('\n'),
stderr: ''
})
})

await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
})

it('treats failed POSIX scans as unavailable', async () => {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
}
)

await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({
available: false,
processName: 'zsh'
})
await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh')
})

it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => {
mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n'))

await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true)
})

it('uses spawned-shell identity instead of a lagging foreground child label', async () => {
mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n'))

await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
})

it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => {
mockPs(
[
'100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh',
'101 100 101 101 S+ -zsh',
'102 101 101 101 S+ (zsh)',
'103 102 101 101 S+ (sed)',
'104 102 101 101 R+ (git)'
].join('\n')
)

await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
})

it('rejects a foreground nested shell while the spawned shell remains suspended', async () => {
mockPs(
[
'100 99 100 102 Ss /usr/bin/login -pfl developer /bin/zsh',
'101 100 101 102 S -zsh',
'102 101 102 102 S+ agent-tui',
'103 102 102 102 S+ /bin/zsh -i'
].join('\n')
)

await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false)
})

it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => {
mockPs(
[
'100 99 100 101 Ss /bin/zsh -l',
'101 100 101 101 S+ /usr/local/bin/agent-tui',
'102 101 101 101 S+ /bin/bash -i'
].join('\n')
)

await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})

it('rejects shell ownership while a stopped TUI remains resumable', async () => {
mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n'))

await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})

it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => {
// Why pinned: the WSL job object holds only wsl.exe, so a looser
// isShellProcess would confirm ownership regardless of distro-side state.
await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false)
expect(execFileMock).not.toHaveBeenCalled()
})

it('fails open when fresh shell ownership inspection is unavailable', async () => {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
}
)

await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})

it('confirms a Windows shell from fresh root-only ConPTY membership', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100]))

await expect(
confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds })
).resolves.toBe(true)
expect(getAllProcessesMock).not.toHaveBeenCalled()
})

it('rejects Windows shell ownership with a child or unavailable membership', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })

await expect(
confirmShellForegroundProcess(100, 'powershell.exe', {
readWindowsPtyJobProcessIds: async () => new Set([100, 101])
})
).resolves.toBe(false)
await expect(
confirmShellForegroundProcess(100, 'powershell.exe', {
readWindowsPtyJobProcessIds: async () => null
})
).resolves.toBe(false)
})

it('does not report Claude print-mode hook descendants as foreground agents', async () => {
mockPs(
[
'100 99 Ss bash -i',
'101 100 S+ claude --print --model haiku Analyze this conversation and determine next work'
].join('\n')
)

await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
})

it('does not report a stopped agent after the shell regains foreground', async () => {
mockPs(
['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join(
'\n'
)
)

await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
})

it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => {
// No '+' marker at all (e.g. a detached/daemon descendant tree) — the
// recognized agent may still be the best available signal.
mockPs(
['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join(
'\n'
)
)

await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex')
})
})
Loading
Loading