Files
orca/src/main/command-code/hook-service.ts
T
Brennan Benson 2ee43bfc0d 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
2026-08-10 16:34:15 -07:00

235 lines
7.3 KiB
TypeScript

import { homedir } from 'node:os'
import { join } from 'node:path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { buildCommandCodeManagedScript } from './command-code-managed-script'
const COMMAND_CODE_EVENTS = [
{
eventName: 'PreToolUse',
definition: { matcher: '.*', hooks: [{ type: 'command', command: '' }] }
},
{
eventName: 'PostToolUse',
definition: { matcher: '.*', hooks: [{ type: 'command', command: '' }] }
},
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }
] as const
function getConfigPath(): string {
return join(homedir(), '.commandcode', 'settings.json')
}
function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'command-code-hook.cmd' : 'command-code-hook.sh'
}
function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function buildInstalledConfig(
config: NonNullable<ReturnType<typeof readHooksJson>>,
command: string,
scriptFileName: string
): void {
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const managedEvents = new Set<string>(COMMAND_CODE_EVENTS.map((event) => event.eventName))
// Why: Orca owns only command-code-hook.* entries. Sweep retired managed
// events while preserving user-authored Command Code hooks.
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
for (const event of COMMAND_CODE_EVENTS) {
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
config.hooks = nextHooks
}
export class CommandCodeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'command-code',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Command Code settings.json'
}
}
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const event of COMMAND_CODE_EVENTS) {
const definitions = Array.isArray(config.hooks?.[event.eventName])
? config.hooks![event.eventName]!
: []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
if (hasCommand) {
presentCount += 1
} else {
missing.push(event.eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'command-code', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'command-code',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Command Code settings.json'
}
}
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
writeManagedScript(scriptPath, buildCommandCodeManagedScript())
writeHooksJson(configPath, config)
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const home = remoteHome.replace(/\/$/, '')
const remoteConfigPath = `${home}/.commandcode/settings.json`
const remoteScriptPath = `${home}/.orca/agent-hooks/command-code-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'command-code',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Command Code settings.json'
}
}
buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'command-code-hook.sh')
await writeManagedScriptRemote(sftp, remoteScriptPath, buildCommandCodeManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'command-code',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'command-code',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'command-code',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Command Code settings.json'
}
}
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
config.hooks = nextHooks
writeHooksJson(configPath, config)
return this.getStatus()
}
}
export const commandCodeHookService = new CommandCodeHookService()