Support live toggle of agent status hooks with WSL relay gating (#13361)

* fix(agent-hooks): gate WSL relay reattach on agentStatusHooksEnabled

Spawn only ensures the guest relay distro when agent status hooks are
enabled, but reattach called ensureForDistro unconditionally — so a
disabled setting reinstalled guest hooks on every local WSL reattach.
Pass the same isAgentStatusHooksEnabled gate through all three reattach
call sites as a required argument so a new site cannot skip it.

Co-authored-by: Orca <help@stably.ai>

* Gate WSL relay at manager level for live toggle support

- Move agentStatusHooksEnabled check from reattach call sites to centralized isWslHookRelayAllowed gate
- Add non-permanent dispose mode so manager can revive relays when setting is re-enabled
- Watch setting changes and dispose live relays when agent status hooks are disabled mid-session

* Restore WSL relays when re-enabling agent status hooks

Extract guest install logic to `wsl-hook-relay-guest-install.ts` for modularity
and add `resumeStoppedRelays()` to restart relays when hooks are re-enabled.
Track distros stopped during a hooks-off teardown, but skip resuming those the
user has shut down (which would unwantedly boot a stopped distro). Strengthen
the disposed check with state identity to prevent respawning untracked relays.
Abandon in-flight launches when hooks are switched off so no relay exists after
opting out.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-08-09 12:53:13 -07:00 committed by GitHub
parent d3cb02f6a9
commit 2dc172f666
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 254 additions and 63 deletions

View File

@ -364,8 +364,9 @@ async function main() {
const { runtime, getController } = createRuntimeStub()
// Why hooks off: fresh WSL spawn still runs buildPtyHostEnv, which calls ensureForDistro when
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach),
// which does not gate on agentStatusHooksEnabled.
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach);
// the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out
// of the measurement — `manager` below resolves its own (enabled) managed-hook settings.
ptyIpc.registerPtyHandlers(
createRendererWindowStub(),
runtime,

View File

@ -31,7 +31,7 @@ type RemoveOptions = {
}
export function isAgentStatusHooksEnabled(
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
settings: Partial<Pick<GlobalSettings, 'agentStatusHooksEnabled'>> | null | undefined
): boolean {
return settings?.agentStatusHooksEnabled !== false
}

View File

@ -9,7 +9,7 @@ import type { GlobalSettings } from '../../shared/types'
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
export type ManagedHookDetectionSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents' | 'agentStatusHooksEnabled'>
> | null
export function buildManagedHookDetectionCommands(

View File

@ -4,6 +4,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls'
import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
@ -67,6 +68,16 @@ export type WslHookRelayManagerDeps = {
transientRetryDelayMs: number
}
/** Every relay start spawn, PTY reattach, crash recovery funnels through this gate,
* so the user's agent-status-hooks switch is read live instead of at each call site. */
export function isWslHookRelayAllowed(deps: WslHookRelayManagerDeps): boolean {
return (
deps.platform() === 'win32' &&
deps.remoteHooksEnabled() &&
isAgentStatusHooksEnabled(deps.managedHookSettings())
)
}
export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
platform: () => process.platform,
remoteHooksEnabled: () => isRemoteAgentHooksEnabled(),

View File

@ -0,0 +1,77 @@
// The install pass a connected WSL relay runs inside its guest: the managed
// hook installers, the OpenCode plugin overlay, and the interval policy that
// decides when a still-running relay may install again. Kept out of the
// manager so that file stays about relay lifecycle.
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PluginSources } from '../../relay/plugin-overlay'
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
type GuestInstallDeps = {
installHooks: typeof installRemoteManagedAgentHooks
managedHookSettings: () => ManagedHookDetectionSettings
pluginSources: () => PluginSources
warn: (message: string) => void
}
/** Structural slice of the manager's DistroState this pass reads and writes. */
type GuestInstallState = {
distro: string
mux?: SshChannelMultiplexer
guestHome?: string
opencodeOverlayDir?: string
lastInstallAt?: number
}
export async function runWslRelayGuestInstall(
deps: GuestInstallDeps,
state: GuestInstallState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
state.lastInstallAt = Date.now()
await installWslGuestHooks({
mux,
guestHome,
distro: state.distro,
installHooks: deps.installHooks,
settings: deps.managedHookSettings(),
warn: deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
// PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown.
const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro)
if (state.mux === mux && overlay.kind !== 'unavailable') {
// Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir
// present but plugin-less, and advertising it would hide the user's own config.
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
}
}
/** Rate-limited repeat of the (byte-equality idempotent) install pass on a live relay. */
export async function maybeRerunWslRelayGuestInstall(
deps: GuestInstallDeps,
state: GuestInstallState
): Promise<void> {
const mux = state.mux
const guestHome = state.guestHome
if (
!mux ||
!guestHome ||
mux.isDisposed() ||
Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS
) {
return
}
try {
// Why: the pass also re-ships the plugin source, so a mid-session Orca upgrade refreshes it.
await runWslRelayGuestInstall(deps, state, mux, guestHome)
} catch (err) {
const detail = err instanceof Error ? err.message : String(err)
deps.warn(`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${detail}`)
}
}

View File

@ -264,6 +264,11 @@ export async function launchWslRelayWithInstall(options: {
await options.connect(transport, child)
return
} catch (err) {
// Why before the failure triage: once disposed, the guest install and retries below are
// work the caller no longer wants — the "failure" is usually our own teardown kill.
if (options.isDisposed()) {
return
}
const failure = (err as { startup?: WslRelayStartupFailure }).startup
if (!failure) {
throw err

View File

@ -374,14 +374,92 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('is inert off-Windows and when remote hooks are disabled', async () => {
it('is inert off-Windows, when remote hooks are disabled, and when agent status hooks are off', async () => {
const offPlatform = createManager({ platform: () => 'darwin' })
offPlatform.manager.ensureForDistro('Ubuntu')
const disabled = createManager({ remoteHooksEnabled: () => false })
disabled.manager.ensureForDistro('Ubuntu')
const hooksOff = createManager({
managedHookSettings: () => ({ agentStatusHooksEnabled: false })
})
hooksOff.manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled()
expect(disabled.deps.spawnRelay).not.toHaveBeenCalled()
expect(hooksOff.deps.spawnRelay).not.toHaveBeenCalled()
})
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
const settings = { agentStatusHooksEnabled: true }
const { manager, deps } = createManager({ managedHookSettings: () => settings })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
settings.agentStatusHooksEnabled = false
manager.disposeAll({ permanent: false })
// Reattach and crash recovery both re-enter ensureForDistro; neither may reinstall guest hooks now.
manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.installHooks).toHaveBeenCalledTimes(1)
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
// Re-enabling puts the relay back without waiting for the next WSL spawn.
settings.agentStatusHooksEnabled = true
manager.resumeStoppedRelays()
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
manager.disposeAll()
})
it('does not resume a relay whose distro the user shut down while hooks were off', async () => {
const settings = { agentStatusHooksEnabled: true }
const isDistroRunning = vi.fn(async () => true)
const { manager, deps } = createManager({
isDistroRunning,
managedHookSettings: () => settings
})
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1))
settings.agentStatusHooksEnabled = false
manager.disposeAll({ permanent: false })
settings.agentStatusHooksEnabled = true
// Why: resuming through `wsl -d` would boot the VM the user shut down, and no agent inside it
// is waiting on status — the next WSL terminal re-ensures anyway.
isDistroRunning.mockResolvedValue(false)
manager.resumeStoppedRelays()
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// A second resume must not retry a distro already consumed by the first.
isDistroRunning.mockResolvedValue(true)
manager.resumeStoppedRelays()
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
})
it('abandons a launch that was still in flight when hooks were switched off', async () => {
let failSentinel: ((error: unknown) => void) | undefined
const { manager, deps } = createManager({
waitForSentinel: vi.fn(
() =>
new Promise<MultiplexerTransport>((_resolve, reject) => {
failSentinel = reject
})
)
})
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1))
manager.disposeAll({ permanent: false })
// The teardown's child kill reaches the in-flight launch as a startup failure; its retry and
// guest-install paths must not run, or the user would get an untracked relay after opting out.
failSentinel?.(startupError(1))
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.runInstall).not.toHaveBeenCalled()
})
it('requires WSL fs-bridge home coordinates before exposing an endpoint path', () => {

View File

@ -3,14 +3,17 @@
// spawn, forwarding envelopes into ingestRemote and installing guest hooks.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import {
runWslRelayGuestInstall,
maybeRerunWslRelayGuestInstall
} from './wsl-hook-relay-guest-install'
import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch'
import {
defaultWslHookRelayDeps,
isWslHookRelayAllowed,
FAILURE_COOLDOWN_BASE_MS,
FAILURE_COOLDOWN_MAX_MS,
NO_NODE_COOLDOWN_MS,
REINSTALL_MIN_INTERVAL_MS,
REINSTALL_ONE_SHOT_DELAY_MS,
RUNNING_TEARDOWN_COOLDOWN_MS,
STABLE_UPTIME_MS,
@ -19,7 +22,6 @@ import {
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
import {
@ -49,6 +51,8 @@ export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
private stoppedByHooksOff = new Set<string>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
@ -78,13 +82,12 @@ export class WslHookRelayManager {
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
return
}
void this.ensureInternal(distro).catch((err) => {
this.deps.warn(
`[agent-hooks] WSL hook relay ensure failed: ${err instanceof Error ? err.message : String(err)}`
)
const detail = err instanceof Error ? err.message : String(err)
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
})
}
@ -106,16 +109,38 @@ export class WslHookRelayManager {
return this.stateFor(distro)?.opencodeOverlayDir ?? null
}
disposeAll(): void {
this.disposed = true
/** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the
* manager reusable, so re-enabling hooks can start relays again without an app restart. */
disposeAll({ permanent = true }: { permanent?: boolean } = {}): void {
this.disposed ||= permanent
for (const state of this.states.values()) {
this.recovery.clearTimers(state)
state.mux?.dispose()
state.child?.kill()
if (!permanent) {
this.stoppedByHooksOff.add(state.distro)
}
}
this.states.clear()
}
/** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut
* down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */
resumeStoppedRelays(): void {
const distros = [...this.stoppedByHooksOff]
this.stoppedByHooksOff.clear()
for (const distro of distros) {
void this.deps
.isDistroRunning(distro)
.then((running) => {
if (running) {
this.ensureForDistro(distro)
}
})
.catch(() => undefined)
}
}
private async ensureInternal(requestedDistro: string | null): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
@ -125,7 +150,7 @@ export class WslHookRelayManager {
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
void this.maybeReinstallHooks(existing)
void maybeRerunWslRelayGuestInstall(this.deps, existing)
return
}
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
@ -172,7 +197,9 @@ export class WslHookRelayManager {
bundleJsPath: bundle.jsPath,
version: bundle.version,
io: this.deps,
isDisposed: () => this.disposed,
// Why the identity half: a hooks-off teardown drops this state and kills its child, but
// that kill reads as a startup failure and the retry loop would respawn an untracked relay.
isDisposed: () => this.disposed || this.states.get(key) !== state,
onChild: (child) => {
state.child = child
},
@ -252,7 +279,7 @@ export class WslHookRelayManager {
}
state.guestHome = homeResult.home
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
await this.runInstallers(state, mux, homeResult.home)
await runWslRelayGuestInstall(this.deps, state, mux, homeResult.home)
if (state.phase === 'failed' || state.mux !== mux) {
// Child died while installing — already recorded; don't revive.
@ -263,58 +290,13 @@ export class WslHookRelayManager {
// Why: one-shot catch-up so a single-spawn session (no later ensure)
// still writes Codex's deferred trust after the launch path seeds config.toml.
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
void this.maybeReinstallHooks(state)
void maybeRerunWslRelayGuestInstall(this.deps, state)
})
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
// Fresh relays have nothing to replay; tolerate.
})
}
private async runInstallers(
state: DistroState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
state.lastInstallAt = Date.now()
await installWslGuestHooks({
mux,
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
settings: this.deps.managedHookSettings(),
warn: this.deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
// PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown.
const overlay = await requestGuestOpenCodeOverlayDir(mux, this.deps, state.distro)
if (state.mux === mux && overlay.kind !== 'unavailable') {
// Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir
// present but plugin-less, and advertising it would hide the user's own config.
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
}
}
private async maybeReinstallHooks(state: DistroState): Promise<void> {
const mux = state.mux
const guestHome = state.guestHome
if (
!mux ||
!guestHome ||
mux.isDisposed() ||
Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS
) {
return
}
try {
// Why: runInstallers also re-ships the plugin source so a mid-session Orca upgrade refreshes it.
await this.runInstallers(state, mux, guestHome)
} catch (err) {
this.deps.warn(
`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
/** Records + breadcrumbs the failure and always arms the restart timer
* one failed relaunch must not end self-recovery; the timer's
* distro-running probe keeps this from booting stopped distros. */

View File

@ -2136,6 +2136,16 @@ void app.whenReady().then(async () => {
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
}
if ('agentStatusHooksEnabled' in updates) {
// Why both directions: the ensure gate only blocks NEW relays, so off must stop the running
// guest process and timers, and on must restart them — otherwise open WSL panes report no
// status until their next spawn.
if (isAgentStatusHooksEnabled(settings)) {
wslHookRelayManager.resumeStoppedRelays()
} else {
wslHookRelayManager.disposeAll({ permanent: false })
}
}
})
// Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session.
attachClaudeLivePtyPersistence(store)

View File

@ -17124,6 +17124,33 @@ describe('registerPtyHandlers', () => {
])
})
it('refreshes the WSL hook relay for the distro a reattached pane already owns', async () => {
// Why here and not only in the helper's unit test: nothing else catches pty.ts dropping the
// reattach call — the manager owns the hooks/platform gating this spy stands in for.
const ensureForDistro = vi
.spyOn(wslHookRelayManager, 'ensureForDistro')
.mockImplementation(() => {})
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
registerPtyHandlers(mainWindow as never)
try {
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-wsl' })
expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04')
} finally {
ensureForDistro.mockRestore()
}
})
posixOnlyIt(
'does not guess route provenance for a pane-local shell startup CODEX_HOME',
async () => {