fix(agent-hooks): refresh existing Orca launchers when agent CLIs are unavailable (#13378)

* fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected

A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide
config invoking Orca's launcher script under ~/.orca/agent-hooks, but the
presence gate skips install() with no removal — freezing the script at whatever
Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking
.cmd forever, because no launcher script is ever deleted and Windows startup
deliberately skips shell PATH hydration.

Reconcile before gating: every existing shared launcher/statusline script is
rewritten to the current template on each install pass. Creating scripts stays
behind the presence gate — an existing file is proof of a prior install; a
missing one means the gate did its job. Amp and Hermes are deliberately absent:
they write provider-native plugin code with its own install lifecycle, not
shared launchers.

- refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists)
- refreshManagedScripts() on the 11 launcher-writing services (openclaude via
  the shared Claude class)
- reconcile pass in installManagedAgentHooks before presence detection,
  filtered by the agents option, best-effort per agent
- coverage gate: a launcher written to ~/.orca/agent-hooks without a matching
  refresher entry fails the suite, in both directions

* perf(agent-hooks): refresh launchers off the main thread

* test(agent-hooks): keep refresh mode assertion POSIX-only
This commit is contained in:
Brennan Benson
2026-08-10 16:34:15 -07:00
committed by GitHub
parent a70291a918
commit 2ee43bfc0d
18 changed files with 592 additions and 1 deletions
+1
View File
@@ -12,6 +12,7 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({
removeClaude: vi.fn(),
removeCodex: vi.fn(),
statusClaude: vi.fn(),
statusCodex: vi.fn()
statusCodex: vi.fn(),
refreshClaude: vi.fn(),
refreshCodex: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
@@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_STATUS_READERS: [
['claude', mocks.statusClaude],
['codex', mocks.statusCodex]
],
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [
['claude', mocks.refreshClaude],
['codex', mocks.refreshCodex]
]
}))
@@ -51,6 +57,8 @@ describe('managed agent hook controls', () => {
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
mocks.refreshClaude.mockResolvedValue(undefined)
mocks.refreshCodex.mockResolvedValue(undefined)
})
it('installs only agents with positively detected CLIs', async () => {
@@ -73,6 +81,78 @@ describe('managed agent hook controls', () => {
])
})
it('refreshes existing scripts for agents whose CLI is no longer detected', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'found' }
})
await installManagedAgentHooks({ agentCmdOverrides: {} })
// Why (#11549 aftermath): the skipped agent's user-wide config still invokes the
// script, so a stale (leaking) script must not be frozen by the presence gate.
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
expect(mocks.installClaude).not.toHaveBeenCalled()
})
it('refreshes existing scripts even when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
})
it('awaits each script refresh before probing for CLIs', async () => {
let releaseRefresh: (() => void) | undefined
mocks.refreshClaude.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseRefresh = resolve
})
)
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'missing' }
})
const install = installManagedAgentHooks({ agentCmdOverrides: {} })
await Promise.resolve()
expect(mocks.detect).not.toHaveBeenCalled()
releaseRefresh?.()
await install
expect(mocks.detect).toHaveBeenCalledTimes(1)
})
it('keeps installing when a script refresh throws', async () => {
mocks.refreshClaude.mockRejectedValue(new Error('disk full'))
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).toHaveBeenCalledTimes(1)
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({ agent: 'claude', state: 'installed' }),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('only refreshes scripts for the selected agents', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] })
expect(mocks.refreshClaude).not.toHaveBeenCalled()
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
})
it('fails closed when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
@@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
} from './managed-agent-hook-registry'
@@ -87,10 +88,29 @@ function runInstaller(
}
}
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
// Orca's script, but the presence gate below then skips install() forever, freezing the
// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them
// current before any gating; creating new ones remains install()'s presence-gated job.
async function refreshExistingManagedScripts(options: InstallOptions): Promise<void> {
const allowed = options.agents ? new Set(options.agents) : null
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
if (allowed !== null && !allowed.has(agent)) {
continue
}
try {
await refresh()
} catch (error) {
console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error)
}
}
}
export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
await refreshExistingManagedScripts(options)
const installers = selectedInstallers(options)
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
@@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise<void>]
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
@@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['kimi', () => kimiHookService.install()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write
// provider-native plugin code into their own config dirs with their own install lifecycles,
// not shared launchers, so they are deliberately absent. Enforced by the coverage test in
// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding
// a refresher here fails that test.
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [
['claude', () => claudeHookService.refreshManagedScripts()],
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
['codex', () => codexHookService.refreshManagedScripts()],
['gemini', () => geminiHookService.refreshManagedScripts()],
['antigravity', () => antigravityHookService.refreshManagedScripts()],
['cursor', () => cursorHookService.refreshManagedScripts()],
['droid', () => droidHookService.refreshManagedScripts()],
['command-code', () => commandCodeHookService.refreshManagedScripts()],
['grok', () => grokHookService.refreshManagedScripts()],
['copilot', () => copilotHookService.refreshManagedScripts()],
['devin', () => devinHookService.refreshManagedScripts()],
['kimi', () => kimiHookService.refreshManagedScripts()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
@@ -0,0 +1,101 @@
import type * as NodeFsModule from 'node:fs'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import { tmpdir } from 'node:os'
import type * as NodeOsModule from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
home: '',
syncCalls: [] as { name: string; target: string }[],
blockAsyncReads: false
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsModule>()
const wrapped: Record<string, unknown> = { ...actual }
for (const [name, value] of Object.entries(actual)) {
if (!name.endsWith('Sync') || typeof value !== 'function') {
continue
}
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
const recorder = (...args: unknown[]): unknown => {
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
return original(...args)
}
Object.assign(recorder, original)
wrapped[name] = recorder
}
return { ...wrapped, default: wrapped }
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromisesModule>()
return {
...actual,
default: actual,
readFile: (...args: Parameters<typeof actual.readFile>) =>
state.blockAsyncReads && String(args[0]).startsWith(state.home)
? new Promise<never>(() => {})
: actual.readFile(...args)
}
})
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof NodeOsModule>()
return { ...actual, default: actual, homedir: () => state.home }
})
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry'
function syncCallsUnderHome(): string[] {
return state.syncCalls
.filter((call) => call.target.startsWith(state.home))
.map((call) => `${call.name}(${call.target})`)
}
describe('managed hook script refresh stays off the main thread', () => {
beforeEach(async () => {
state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-'))
state.syncCalls = []
state.blockAsyncReads = false
})
afterEach(async () => {
state.blockAsyncReads = false
await rm(state.home, { recursive: true, force: true })
})
it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => {
const hooksDir = join(state.home, '.orca', 'agent-hooks')
const claudeScript = join(
hooksDir,
process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
)
await mkdir(hooksDir, { recursive: true })
await writeFile(claudeScript, 'stale', 'utf-8')
state.syncCalls = []
for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
await refresh()
}
expect(syncCallsUnderHome()).toEqual([])
})
it('keeps the event loop responsive while a HOME read is stalled', async () => {
state.blockAsyncReads = true
const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]()
let settled = false
void refresh.then(() => {
settled = true
})
await new Promise<void>((resolve) => setTimeout(resolve, 5))
expect(settled).toBe(false)
})
})
@@ -0,0 +1,206 @@
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
// Orca's script while the presence gate skips install() forever. These tests pin the
// repair — existing scripts come current, missing ones are never created.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as osModule from 'node:os'
let isolatedUserDataDir = ''
let previousUserDataPath: string | undefined
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-'))
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(isolatedUserDataDir, { recursive: true, force: true })
})
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof osModule>()
return {
...actual,
homedir: homedirMock.mockImplementation(actual.homedir)
}
})
import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS
} from './managed-agent-hook-registry'
import { ClaudeHookService } from '../claude/hook-service'
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (original) {
Object.defineProperty(process, 'platform', original)
}
}
}
const STALE_WINDOWS_HOOK = [
'@echo off',
'setlocal',
'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin',
':orca_agent_hook_drain_stdin',
'"%SystemRoot%\\System32\\more.com" >nul 2>nul',
'exit /b 0',
''
].join('\r\n')
describe('refreshManagedScriptIfPresent', () => {
it('rewrites an existing script and refuses to create a missing one', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-'))
try {
const present = join(dir, 'present.cmd')
writeFileSync(present, 'stale')
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
expect(readFileSync(present, 'utf8')).toBe('fresh')
if (process.platform !== 'win32') {
chmodSync(present, 0o600)
}
const fixedTime = new Date(1_000)
utimesSync(present, fixedTime, fixedTime)
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
expect(statSync(present).mtimeMs).toBe(fixedTime.getTime())
if (process.platform !== 'win32') {
expect(statSync(present).mode & 0o777).toBe(0o755)
}
const missing = join(dir, 'missing.cmd')
expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false)
expect(existsSync(missing)).toBe(false)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('managed hook script refresh', () => {
it('brings a stale leaking script current without touching agent config', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-'))
homedirMock.mockReturnValue(home)
try {
// Why: the bug population has a script (from a past install) but no reachable
// CLI — and possibly no config dir Orca may create. Seed only the script.
const hooksDir = join(home, '.orca', 'agent-hooks')
mkdirSync(hooksDir, { recursive: true })
writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK)
await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts())
const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0')
expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto')
// Why: refresh must not resurrect config for a CLI the user may have removed.
expect(existsSync(join(home, '.claude'))).toBe(false)
// Why: the statusline script was never installed here, so it must not appear.
expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false)
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(home, { recursive: true, force: true })
}
})
it('covers every shared launcher script with a refresher', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-'))
homedirMock.mockReturnValue(home)
const previousGrokHome = process.env.GROK_HOME
const previousKimiHome = process.env.KIMI_CODE_HOME
delete process.env.GROK_HOME
delete process.env.KIMI_CODE_HOME
try {
await withPlatform('win32', () => {
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
install()
}
})
const hooksDir = join(home, '.orca', 'agent-hooks')
const files = readdirSync(hooksDir)
expect(files.length).toBeGreaterThan(0)
const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent)
// Why: an installer that writes a shared launcher but skips the refresher list would
// recreate the frozen-stale-script class this suite exists to prevent.
for (const file of files) {
expect(
refresherAgents.some((agent) => file.startsWith(`${agent}-`)),
`${file} is written to ~/.orca/agent-hooks but no refresher owns it`
).toBe(true)
}
// Why: the reverse direction — a refresher naming an agent that writes nothing is a
// stale registry entry, likely a renamed script file.
for (const agent of refresherAgents) {
expect(
files.some((file) => file.startsWith(`${agent}-`)),
`refresher for ${agent} matches no installed script`
).toBe(true)
}
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
if (previousGrokHome === undefined) {
delete process.env.GROK_HOME
} else {
process.env.GROK_HOME = previousGrokHome
}
if (previousKimiHome === undefined) {
delete process.env.KIMI_CODE_HOME
} else {
process.env.KIMI_CODE_HOME = previousKimiHome
}
rmSync(home, { recursive: true, force: true })
}
})
it('creates nothing anywhere when no managed scripts exist', async () => {
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-'))
homedirMock.mockReturnValue(home)
try {
await withPlatform('win32', async () => {
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined()
}
})
// Why: a refresh pass on a machine with no prior installs must be a strict no-op.
expect(readdirSync(home)).toEqual([])
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(home, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto'
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
type ExistingScript = { exists: false } | { exists: true; content: string | null }
function isMissingPathError(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
try {
return { exists: true, content: await readFile(scriptPath, 'utf-8') }
} catch (error) {
if (isMissingPathError(error)) {
return { exists: false }
}
try {
await stat(scriptPath)
return { exists: true, content: null }
} catch (statError) {
if (isMissingPathError(statError)) {
return { exists: false }
}
throw error
}
}
}
async function scriptStillExists(scriptPath: string): Promise<boolean> {
try {
await stat(scriptPath)
return true
} catch (error) {
if (isMissingPathError(error)) {
return false
}
throw error
}
}
async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise<void> {
try {
await writeFile(scriptPath, content, 'utf-8')
} catch (error) {
if (isPermissionError(error) && process.platform === 'win32') {
try {
await grantDirAclAsync(dirname(scriptPath))
await writeFile(scriptPath, content, 'utf-8')
return
} catch {
// Re-throw the original permission error.
}
}
throw error
}
}
// Why: refresh must not block Electron's main thread or create state for an absent CLI.
export async function refreshManagedScriptIfPresent(
scriptPath: string,
content: string
): Promise<boolean> {
const existing = await readExistingScript(scriptPath)
if (!existing.exists) {
return false
}
if (existing.content === content) {
if (process.platform !== 'win32') {
await chmod(scriptPath, 0o755)
}
return true
}
const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`)
try {
await writeScriptWithAclRetry(tmpPath, content)
if (process.platform !== 'win32') {
await chmod(tmpPath, 0o755)
}
if (!(await scriptStillExists(scriptPath))) {
return false
}
await rename(tmpPath, scriptPath)
return true
} finally {
await rm(tmpPath, { force: true }).catch(() => undefined)
}
}
+13
View File
@@ -25,6 +25,7 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildWindowsHookEnvironmentGuardLines,
@@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void {
}
export class AntigravityHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
if (process.platform === 'win32') {
for (const event of ANTIGRAVITY_EVENTS) {
await refreshManagedScriptIfPresent(
getWindowsWrapperScriptPath(event),
getWindowsWrapperScript(event.eventName)
)
}
}
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+13
View File
@@ -13,6 +13,7 @@ import {
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildWindowsHookEnvironmentGuardLines,
@@ -171,6 +172,18 @@ export class ClaudeHookService {
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
}
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(
getManagedScriptPath(this.options.settings),
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
)
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
await refreshManagedScriptIfPresent(
getStatusLineScriptPath(this.options.settings),
getManagedStatusLineScript('local')
)
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath(this.options.settings)
const scriptPath = getManagedScriptPath(this.options.settings)
+5
View File
@@ -19,6 +19,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import {
@@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string {
}
export class CodexHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
private readonly wslReconciliationGeneration = new Map<string, number>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
+5
View File
@@ -14,6 +14,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -88,6 +89,10 @@ function buildInstalledConfig(
}
export class CommandCodeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -17,6 +17,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class CopilotHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -15,6 +15,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class CursorHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -5,6 +5,7 @@ import {
writeHooksJson,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeHooksJsonRemote,
@@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class DevinHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getDevinConfigPath()
const scriptPath = getDevinManagedScriptPath()
+5
View File
@@ -16,6 +16,7 @@ import {
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -159,6 +160,10 @@ function buildInstalledDroidConfig(
}
export class DroidHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -16,6 +16,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
}
export class GeminiHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -14,6 +14,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -182,6 +183,10 @@ function buildInstalledConfig(
}
export class GrokHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
+5
View File
@@ -18,6 +18,7 @@ import {
wrapPosixHookCommand,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeManagedScriptRemote,
@@ -155,6 +156,10 @@ function buildStatus(present: Set<string>, configPath: string): AgentHookInstall
}
export class KimiHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const text = readConfigToml(configPath)