Add Copilot agent hook status support (#2027)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
102ca09a38
commit
e671606fff
|
|
@ -17,6 +17,8 @@ import {
|
|||
buildWindowsAgentHookPostCommand,
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
hookDefinitionHasManagedCommand,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
writeManagedScript,
|
||||
writeHooksJson,
|
||||
|
|
@ -175,6 +177,71 @@ describe('createManagedCommandMatcher', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('removeManagedCommands', () => {
|
||||
const match = createManagedCommandMatcher('copilot-hook.sh')
|
||||
|
||||
it('removes managed direct bash/powershell/command fields', () => {
|
||||
const cleaned = removeManagedCommands(
|
||||
[
|
||||
{
|
||||
type: 'command',
|
||||
bash: '/bin/sh "/Users/alice/Orca/agent-hooks/copilot-hook.sh"',
|
||||
timeoutSec: 5
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
powershell: "& 'C:\\Users\\alice\\Orca\\agent-hooks\\copilot-hook.sh'",
|
||||
timeoutSec: 5
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo user hook',
|
||||
timeoutSec: 5
|
||||
}
|
||||
],
|
||||
match
|
||||
)
|
||||
|
||||
expect(cleaned).toEqual([{ type: 'command', command: 'echo user hook', timeoutSec: 5 }])
|
||||
})
|
||||
|
||||
it('preserves unrelated nested hooks while removing managed entries', () => {
|
||||
const cleaned = removeManagedCommands(
|
||||
[
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' },
|
||||
{ type: 'command', command: 'echo keep me' }
|
||||
]
|
||||
}
|
||||
],
|
||||
match
|
||||
)
|
||||
|
||||
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hookDefinitionHasManagedCommand', () => {
|
||||
it('detects managed commands in direct and nested fields', () => {
|
||||
const match = createManagedCommandMatcher('copilot-hook.sh')
|
||||
|
||||
expect(
|
||||
hookDefinitionHasManagedCommand(
|
||||
{ bash: '/bin/sh "/Users/alice/Orca/agent-hooks/copilot-hook.sh"' },
|
||||
match
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hookDefinitionHasManagedCommand(
|
||||
{ hooks: [{ type: 'command', command: '/bin/sh "/path/agent-hooks/copilot-hook.sh"' }] },
|
||||
match
|
||||
)
|
||||
).toBe(true)
|
||||
expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSharedManagedScriptPath', () => {
|
||||
it("returns ~/.orca/agent-hooks/<scriptFileName> rooted at the user's home", () => {
|
||||
expect(getSharedManagedScriptPath('claude-hook.sh')).toBe(
|
||||
|
|
@ -230,6 +297,15 @@ describe('wrapPosixHookCommand', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('can scope environment variables to the guarded script invocation', () => {
|
||||
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {
|
||||
ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit'
|
||||
})
|
||||
expect(cmd).toBe(
|
||||
"if [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; fi"
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'returns exit code 0 when the script does not exist (no-op)',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export type HookCommandConfig = {
|
|||
|
||||
export type HookDefinition = {
|
||||
matcher?: string
|
||||
command?: string
|
||||
bash?: string
|
||||
powershell?: string
|
||||
hooks?: HookCommandConfig[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
|
@ -81,12 +84,16 @@ export function getSharedManagedScriptPath(scriptFileName: string): string {
|
|||
// missing/non-executable script a silent no-op so a broken install never
|
||||
// poisons the user's session. Failures inside the script itself are
|
||||
// unaffected — only the missing-script case short-circuits.
|
||||
export function wrapPosixHookCommand(scriptPath: string): string {
|
||||
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
|
||||
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
|
||||
// literally — avoids a shell-injection footgun if a future caller passes an
|
||||
// arbitrary path.
|
||||
const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'`
|
||||
return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi`
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
||||
.join(' ')
|
||||
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
|
||||
return `if [ -x ${quoted} ]; then ${invocation}; fi`
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
|
|
@ -101,19 +108,54 @@ export function removeManagedCommands(
|
|||
isManagedCommand: (command: string | undefined) => boolean
|
||||
): HookDefinition[] {
|
||||
return definitions.flatMap((definition) => {
|
||||
if (!Array.isArray(definition.hooks)) {
|
||||
const directCommandKeys = ['command', 'bash', 'powershell'] as const
|
||||
const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key]))
|
||||
const hasNestedHooks = Array.isArray(definition.hooks)
|
||||
const hasManagedNestedHook =
|
||||
hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command))
|
||||
|
||||
if (directManagedKeys.length === 0 && !hasManagedNestedHook) {
|
||||
return [definition]
|
||||
}
|
||||
|
||||
const filteredHooks = definition.hooks.filter((hook) => !isManagedCommand(hook.command))
|
||||
if (filteredHooks.length === 0) {
|
||||
const nextDefinition: HookDefinition = { ...definition }
|
||||
for (const key of directManagedKeys) {
|
||||
delete nextDefinition[key]
|
||||
}
|
||||
|
||||
if (hasManagedNestedHook) {
|
||||
const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command))
|
||||
if (filteredHooks.length > 0) {
|
||||
nextDefinition.hooks = filteredHooks
|
||||
} else {
|
||||
delete nextDefinition.hooks
|
||||
}
|
||||
}
|
||||
|
||||
const hasCommandAfterCleanup =
|
||||
directCommandKeys.some((key) => typeof nextDefinition[key] === 'string') ||
|
||||
(Array.isArray(nextDefinition.hooks) && nextDefinition.hooks.length > 0)
|
||||
if (!hasCommandAfterCleanup) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ ...definition, hooks: filteredHooks }]
|
||||
return [nextDefinition]
|
||||
})
|
||||
}
|
||||
|
||||
export function hookDefinitionHasManagedCommand(
|
||||
definition: HookDefinition,
|
||||
isManagedCommand: (command: string | undefined) => boolean
|
||||
): boolean {
|
||||
return (
|
||||
isManagedCommand(definition.command) ||
|
||||
isManagedCommand(definition.bash) ||
|
||||
isManagedCommand(definition.powershell) ||
|
||||
(Array.isArray(definition.hooks) &&
|
||||
definition.hooks.some((hook) => isManagedCommand(hook.command)))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: temp+rename so concurrent Orca instances writing this shared path can't
|
||||
// produce a torn script that an in-flight `/bin/sh <scriptPath>` would source.
|
||||
export function writeManagedScript(scriptPath: string, content: string): void {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { CursorHookService } from '../cursor/hook-service'
|
|||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
import { CopilotHookService } from '../copilot/hook-service'
|
||||
import { HermesHookService } from '../hermes/hook-service'
|
||||
|
||||
type FakeFs = {
|
||||
|
|
@ -130,6 +131,10 @@ describe('remote hook service installers', () => {
|
|||
{
|
||||
path: '/home/dev/.orca/agent-hooks/grok-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/copilot-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, '/home/dev')
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -250,6 +255,54 @@ describe('remote hook service installers', () => {
|
|||
expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*')
|
||||
})
|
||||
|
||||
it('installs remote Copilot hooks under the user-level hooks directory', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.dirs.add('/home/dev/.copilot')
|
||||
fs.dirs.add('/home/dev/.copilot/hooks')
|
||||
fs.files.set(
|
||||
'/home/dev/.copilot/hooks/orca.json',
|
||||
JSON.stringify({
|
||||
version: 99,
|
||||
disableAllHooks: true,
|
||||
hooks: {}
|
||||
})
|
||||
)
|
||||
|
||||
const status = await new CopilotHookService().installRemote(sftp, '/home/dev/')
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe('/home/dev/.copilot/hooks/orca.json')
|
||||
const config = JSON.parse(fs.files.get('/home/dev/.copilot/hooks/orca.json')!) as {
|
||||
version: number
|
||||
disableAllHooks?: boolean
|
||||
hooks: Record<string, { bash?: string; timeoutSec?: number }[]>
|
||||
}
|
||||
expect(config.version).toBe(1)
|
||||
for (const eventName of [
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'subagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'Stop',
|
||||
'ErrorOccurred',
|
||||
'PermissionRequest',
|
||||
'Notification'
|
||||
]) {
|
||||
const definition = config.hooks[eventName]?.[0]
|
||||
expect(definition?.bash).toContain('/home/dev/.orca/agent-hooks/copilot-hook.sh')
|
||||
expect(definition?.bash).toContain(`ORCA_COPILOT_HOOK_EVENT='${eventName}'`)
|
||||
expect(definition?.timeoutSec).toBe(5)
|
||||
}
|
||||
expect(config.disableAllHooks).toBeUndefined()
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toContain('#!/bin/sh')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
it('installs remote Hermes plugin files and enables the plugin', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
|
|
|
|||
|
|
@ -1672,6 +1672,338 @@ describe('Pi hook normalization', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Copilot hook normalization', () => {
|
||||
it('UserPromptSubmit maps to working and captures the prompt', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'add a migration' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.agentType).toBe('copilot')
|
||||
expect(result?.payload.prompt).toBe('add a migration')
|
||||
})
|
||||
|
||||
it('accepts camelCase Copilot event names from older hook configs', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'userPromptSubmitted', prompt: 'camel event' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.prompt).toBe('camel event')
|
||||
})
|
||||
|
||||
it('infers Copilot user prompt payloads that omit hook_event_name', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ prompt: 'raw prompt payload' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.prompt).toBe('raw prompt payload')
|
||||
})
|
||||
|
||||
it('captures initialPrompt from Copilot sessionStart payloads', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ initialPrompt: 'first prompt' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.prompt).toBe('first prompt')
|
||||
})
|
||||
|
||||
it('PreToolUse stays working and surfaces tool context', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
toolName: 'bash',
|
||||
toolInput: { command: 'pnpm test' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.toolName).toBe('bash')
|
||||
expect(result?.payload.toolInput).toBe('pnpm test')
|
||||
})
|
||||
|
||||
it('PreToolUse ask_user maps to blocked and surfaces the question', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ prompt: 'ask me a question' }),
|
||||
'production'
|
||||
)
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'ask_user',
|
||||
args: JSON.stringify({ question: 'Which deployment target should I use?' })
|
||||
}
|
||||
]
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('blocked')
|
||||
expect(result?.payload.prompt).toBe('ask me a question')
|
||||
expect(result?.payload.toolName).toBe('ask_user')
|
||||
expect(result?.payload.toolInput).toBe('Which deployment target should I use?')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?')
|
||||
})
|
||||
|
||||
it('PermissionRequest stays working and preserves tool context', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'bash',
|
||||
tool_input: { command: 'rm -rf /tmp/orca-test' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.toolName).toBe('bash')
|
||||
expect(result?.payload.toolInput).toBe('rm -rf /tmp/orca-test')
|
||||
})
|
||||
|
||||
it('surfaces lowercase Copilot file tool input previews', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'edit',
|
||||
tool_input: { path: '/repo/src/app.ts' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.toolName).toBe('edit')
|
||||
expect(result?.payload.toolInput).toBe('/repo/src/app.ts')
|
||||
})
|
||||
|
||||
it('Notification(permission_prompt) maps to blocked and surfaces message text', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'Notification',
|
||||
notification_type: 'permission_prompt',
|
||||
title: 'Approval needed',
|
||||
message: 'Allow Bash to run?'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('blocked')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Allow Bash to run?')
|
||||
})
|
||||
|
||||
it('Notification(elicitation_dialog) preserves the cached prompt', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'deploy the app' }),
|
||||
'production'
|
||||
)
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'Notification',
|
||||
notification_type: 'elicitation_dialog',
|
||||
message: 'Which environment?'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('blocked')
|
||||
expect(result?.payload.prompt).toBe('deploy the app')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Which environment?')
|
||||
})
|
||||
|
||||
it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'Notification',
|
||||
notificationType: 'elicitation_dialog',
|
||||
message: 'Which deployment target should I use?'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('blocked')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?')
|
||||
})
|
||||
|
||||
it('later progress clears a prior blocked state for the same pane', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'bash',
|
||||
tool_input: { command: 'pnpm build' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'bash',
|
||||
tool_input: { command: 'pnpm build' },
|
||||
tool_result: { text_result_for_llm: 'build passed' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('build passed')
|
||||
})
|
||||
|
||||
it('Stop reads the final assistant message from Copilot transcript events', () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-'))
|
||||
const transcriptPath = join(tmpDir, 'events.jsonl')
|
||||
try {
|
||||
const lines = [
|
||||
{
|
||||
type: 'assistant.message',
|
||||
data: {
|
||||
content: '',
|
||||
toolRequests: [{ name: 'bash', arguments: { command: 'pnpm test' } }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Done - tests pass now.', toolRequests: [] }
|
||||
}
|
||||
]
|
||||
writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`)
|
||||
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }),
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(result?.payload.state).toBe('done')
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Done - tests pass now.')
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('unknown event name returns null', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'copilot',
|
||||
buildBody({ hook_event_name: 'somethingElse' }),
|
||||
'production'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts authenticated HTTP posts on /hook/copilot', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody({ hook_event_name: 'Notification', notificationType: 'permission_prompt' })
|
||||
)
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
payload: expect.objectContaining({ state: 'blocked', agentType: 'copilot' })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('updates Copilot Stop with final transcript text after a non-blocking retry', async () => {
|
||||
const server = new AgentHookServer()
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-retry-'))
|
||||
const transcriptPath = join(tmpDir, 'events.jsonl')
|
||||
writeFileSync(transcriptPath, '')
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_result: { text_result_for_llm: 'stale tool output' }
|
||||
})
|
||||
)
|
||||
})
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath })
|
||||
)
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody({ hook_event_name: 'SessionEnd', reason: 'complete' }))
|
||||
})
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
state: 'done',
|
||||
lastAssistantMessage: undefined
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Done after transcript flush.' }
|
||||
})}\n`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
state: 'done',
|
||||
lastAssistantMessage: 'Done after transcript flush.'
|
||||
})
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Endpoint file lifecycle', () => {
|
||||
let userDataPath: string
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ type PaneKeyAliasEntry = {
|
|||
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
|
||||
// restart artifacts stay co-located.
|
||||
const LAST_STATUS_FILE_NAME = 'last-status.json'
|
||||
const COPILOT_TRANSCRIPT_RETRY_ATTEMPTS = 5
|
||||
const COPILOT_TRANSCRIPT_RETRY_MS = 50
|
||||
|
||||
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
|
||||
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
|
||||
|
|
@ -199,6 +201,34 @@ function trackEmptyPaneKeyHook(body: unknown): void {
|
|||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
}
|
||||
|
||||
function hasPendingCopilotTranscript(source: AgentHookSource, body: unknown): boolean {
|
||||
if (source !== 'copilot' || typeof body !== 'object' || body === null) {
|
||||
return false
|
||||
}
|
||||
const rawPayload = (body as Record<string, unknown>).payload
|
||||
const payload =
|
||||
typeof rawPayload === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(rawPayload) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: rawPayload
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return false
|
||||
}
|
||||
const record = payload as Record<string, unknown>
|
||||
const directMessage =
|
||||
record.last_assistant_message ?? record.lastAssistantMessage ?? record.message
|
||||
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
|
||||
return false
|
||||
}
|
||||
const transcriptPath = record.transcript_path ?? record.transcriptPath
|
||||
return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0
|
||||
}
|
||||
|
||||
export class AgentHookServer {
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
private port = 0
|
||||
|
|
@ -232,6 +262,7 @@ export class AgentHookServer {
|
|||
// Why: trailing-edge debounce timer. Captured per-instance so multiple
|
||||
// server instances in the same process (tests) don't share state.
|
||||
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private copilotTranscriptRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// Why: identity check — skip writes when the JSON-stringified contents
|
||||
// exactly match the last successful disk write. Cheap protection against
|
||||
// re-firing trailing timers when nothing changed.
|
||||
|
|
@ -314,6 +345,75 @@ export class AgentHookServer {
|
|||
}
|
||||
}
|
||||
|
||||
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
|
||||
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
|
||||
this.clearCopilotTranscriptRetry(payload.paneKey)
|
||||
}
|
||||
const enriched = this.attachStatusTiming(payload)
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
return enriched
|
||||
}
|
||||
|
||||
private clearCopilotTranscriptRetry(paneKey: string): void {
|
||||
const timer = this.copilotTranscriptRetryTimers.get(paneKey)
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
this.copilotTranscriptRetryTimers.delete(paneKey)
|
||||
}
|
||||
|
||||
private scheduleCopilotTranscriptRetry(
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
original: EnrichedAgentHookEventPayload,
|
||||
attempt = 1
|
||||
): void {
|
||||
if (
|
||||
original.payload.lastAssistantMessage ||
|
||||
!hasPendingCopilotTranscript(source, body) ||
|
||||
attempt > COPILOT_TRANSCRIPT_RETRY_ATTEMPTS
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.clearCopilotTranscriptRetry(original.paneKey)
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
this.copilotTranscriptRetryTimers.delete(original.paneKey)
|
||||
const current = this.state.lastStatusByPaneKey.get(original.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
if (
|
||||
!current ||
|
||||
current.payload.agentType !== 'copilot' ||
|
||||
current.payload.prompt !== original.payload.prompt ||
|
||||
current.payload.lastAssistantMessage
|
||||
) {
|
||||
return
|
||||
}
|
||||
const normalized = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (!normalized?.payload.lastAssistantMessage) {
|
||||
this.scheduleCopilotTranscriptRetry(source, body, original, attempt + 1)
|
||||
return
|
||||
}
|
||||
// Why: Copilot can POST Stop before its transcript line is flushed. Retry
|
||||
// from a timer so the hook request returns immediately and the main loop
|
||||
// is not blocked by synchronous sleeps.
|
||||
this.applyNormalizedStatus(normalized)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] copilot transcript retry failed:', err)
|
||||
}
|
||||
}, COPILOT_TRANSCRIPT_RETRY_MS)
|
||||
this.copilotTranscriptRetryTimers.set(original.paneKey, timer)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
|
||||
this.paneKeyAliasPersistenceListener = listener
|
||||
}
|
||||
|
|
@ -522,12 +622,7 @@ export class AgentHookServer {
|
|||
connectionId: trimmedConnectionId,
|
||||
payload: normalizedPayload
|
||||
}
|
||||
const enriched = this.attachStatusTiming(event)
|
||||
this.runtimeObservedStatusPaneKeys.add(paneKey)
|
||||
this.state.lastStatusByPaneKey.set(paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
this.applyNormalizedStatus(event)
|
||||
}
|
||||
|
||||
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
|
||||
|
|
@ -584,19 +679,11 @@ export class AgentHookServer {
|
|||
}
|
||||
|
||||
trackEmptyPaneKeyHook(body)
|
||||
const normalized = normalizeHookPayload(
|
||||
this.state,
|
||||
source,
|
||||
this.normalizeHookBodyPaneKeyAlias(body),
|
||||
this.env
|
||||
)
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
||||
if (normalized) {
|
||||
const enriched = this.attachStatusTiming(normalized)
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
const enriched = this.applyNormalizedStatus(normalized)
|
||||
this.scheduleCopilotTranscriptRetry(source, aliasedBody, enriched)
|
||||
}
|
||||
|
||||
res.writeHead(204)
|
||||
|
|
@ -647,6 +734,10 @@ export class AgentHookServer {
|
|||
this.token = ''
|
||||
this.env = 'production'
|
||||
this.onAgentStatus = null
|
||||
for (const timer of this.copilotTranscriptRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.copilotTranscriptRetryTimers.clear()
|
||||
// Why: intentionally do NOT delete the endpoint file on stop(). A stale
|
||||
// file points at a dead port, which matches the fail-open policy. Unlink
|
||||
// would introduce a TOCTOU race vs. a concurrent Orca instance.
|
||||
|
|
@ -674,6 +765,7 @@ export class AgentHookServer {
|
|||
return
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
this.clearCopilotTranscriptRetry(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
|
|
@ -686,6 +778,7 @@ export class AgentHookServer {
|
|||
// event does not change the on-disk file, and skipping the write avoids
|
||||
// re-stat'ing on every dead-pane teardown.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
this.clearCopilotTranscriptRetry(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
import { CopilotHookService } from './hook-service'
|
||||
|
||||
let tmpDir: string
|
||||
let copilotHome: string
|
||||
let originalCopilotHome: string | undefined
|
||||
let originalHome: string | undefined
|
||||
let originalUserProfile: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-hooks-'))
|
||||
copilotHome = join(tmpDir, 'copilot-home')
|
||||
originalCopilotHome = process.env.COPILOT_HOME
|
||||
originalHome = process.env.HOME
|
||||
originalUserProfile = process.env.USERPROFILE
|
||||
process.env.COPILOT_HOME = copilotHome
|
||||
process.env.HOME = tmpDir
|
||||
process.env.USERPROFILE = tmpDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCopilotHome === undefined) {
|
||||
delete process.env.COPILOT_HOME
|
||||
} else {
|
||||
process.env.COPILOT_HOME = originalCopilotHome
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function readConfig(): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(copilotHome, 'hooks', 'orca.json'), 'utf-8'))
|
||||
}
|
||||
|
||||
describe('CopilotHookService', () => {
|
||||
it('installs a user-level Copilot hook file under COPILOT_HOME', () => {
|
||||
const service = new CopilotHookService()
|
||||
|
||||
const status = service.install()
|
||||
const config = readConfig()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(join(copilotHome, 'hooks', 'orca.json'))
|
||||
expect(config.version).toBe(1)
|
||||
const hooks = config.hooks as Record<string, unknown[]>
|
||||
expect(Object.keys(hooks).sort()).toEqual(
|
||||
[
|
||||
'ErrorOccurred',
|
||||
'Notification',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PreCompact',
|
||||
'PreToolUse',
|
||||
'SessionEnd',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'UserPromptSubmit',
|
||||
'subagentStart'
|
||||
].sort()
|
||||
)
|
||||
const firstPromptHook = hooks.UserPromptSubmit[0] as Record<string, unknown>
|
||||
expect(firstPromptHook.type).toBe('command')
|
||||
expect(firstPromptHook.timeoutSec).toBe(5)
|
||||
if (process.platform === 'win32') {
|
||||
expect(firstPromptHook.powershell).toContain('agent-hooks')
|
||||
expect(firstPromptHook.powershell).toContain('copilot-hook.ps1')
|
||||
expect(firstPromptHook.powershell).toContain('ORCA_COPILOT_HOOK_EVENT')
|
||||
expect(firstPromptHook.powershell).toContain('UserPromptSubmit')
|
||||
} else {
|
||||
expect(firstPromptHook.bash).toContain('if [ -x ')
|
||||
expect(firstPromptHook.bash).toContain('.orca/agent-hooks/copilot-hook.sh')
|
||||
expect(firstPromptHook.bash).toContain("ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit'")
|
||||
}
|
||||
expect(existsSync(join(tmpDir, '.orca', 'agent-hooks', 'copilot-hook.sh'))).toBe(
|
||||
process.platform !== 'win32'
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('writes syntactically valid POSIX commands', () => {
|
||||
const service = new CopilotHookService()
|
||||
service.install()
|
||||
const hooks = readConfig().hooks as Record<string, unknown[]>
|
||||
|
||||
for (const definitions of Object.values(hooks)) {
|
||||
for (const definition of definitions) {
|
||||
const bash = (definition as Record<string, unknown>).bash
|
||||
expect(typeof bash).toBe('string')
|
||||
const result = spawnSync('/bin/sh', ['-n', '-c', bash as string])
|
||||
expect(result.status).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves user-authored hooks and sweeps stale managed entries', () => {
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
mkdirSync(join(copilotHome, 'hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{ type: 'command', bash: 'echo user prompt' },
|
||||
{ type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' }
|
||||
],
|
||||
OldEvent: [{ type: 'command', bash: '/bin/sh "/old/agent-hooks/copilot-hook.sh"' }]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const service = new CopilotHookService()
|
||||
service.install()
|
||||
const hooks = readConfig().hooks as Record<string, unknown[]>
|
||||
|
||||
expect(hooks.OldEvent).toBeUndefined()
|
||||
expect(hooks.UserPromptSubmit).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ bash: 'echo user prompt' })])
|
||||
)
|
||||
expect(hooks.UserPromptSubmit).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('forces version 1 in the dedicated Copilot hook file', () => {
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
mkdirSync(join(copilotHome, 'hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
version: 99,
|
||||
hooks: {}
|
||||
})
|
||||
)
|
||||
|
||||
const status = new CopilotHookService().install()
|
||||
const config = readConfig()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(config.version).toBe(1)
|
||||
})
|
||||
|
||||
it('clears disableAllHooks in the dedicated Copilot hook file', () => {
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
mkdirSync(join(copilotHome, 'hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
disableAllHooks: true,
|
||||
hooks: {}
|
||||
})
|
||||
)
|
||||
|
||||
const status = new CopilotHookService().install()
|
||||
const config = readConfig()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(config.disableAllHooks).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports partial when the dedicated Copilot hook file is disabled', () => {
|
||||
const service = new CopilotHookService()
|
||||
service.install()
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
const config = readConfig()
|
||||
config.disableAllHooks = true
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
const status = service.getStatus()
|
||||
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toBe('Managed Copilot hook file is disabled')
|
||||
})
|
||||
|
||||
it('remove deletes only Orca-managed Copilot hooks', () => {
|
||||
const service = new CopilotHookService()
|
||||
service.install()
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
const config = readConfig()
|
||||
const hooks = config.hooks as Record<string, unknown[]>
|
||||
hooks.UserPromptSubmit.unshift({ type: 'command', bash: 'echo user prompt' })
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
const status = service.remove()
|
||||
const nextHooks = readConfig().hooks as Record<string, unknown[]>
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(nextHooks.UserPromptSubmit).toEqual([{ type: 'command', bash: 'echo user prompt' }])
|
||||
expect(nextHooks.SessionStart).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove does not create an orca.json file when nothing is installed', () => {
|
||||
const status = new CopilotHookService().remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(existsSync(join(copilotHome, 'hooks', 'orca.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('remove leaves nested user hooks untouched when no managed hook is present', () => {
|
||||
const configPath = join(copilotHome, 'hooks', 'orca.json')
|
||||
mkdirSync(join(copilotHome, 'hooks'), { recursive: true })
|
||||
const original = JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'echo nested user hook' },
|
||||
{ type: 'command', command: 'echo another user hook' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
writeFileSync(configPath, original)
|
||||
|
||||
const status = new CopilotHookService().remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('returns an error status for malformed JSON', () => {
|
||||
mkdirSync(join(copilotHome, 'hooks'), { recursive: true })
|
||||
writeFileSync(join(copilotHome, 'hooks', 'orca.json'), '{not json')
|
||||
|
||||
const status = new CopilotHookService().getStatus()
|
||||
|
||||
expect(status).toEqual({
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath: join(copilotHome, 'hooks', 'orca.json'),
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
/* eslint-disable max-lines -- Why: local status/install/remove and SSH remote
|
||||
install must share the same Copilot event list, script body, and
|
||||
managed-command matching so local and remote hook behavior cannot drift. */
|
||||
import { existsSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
hookDefinitionHasManagedCommand,
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
// Why: Copilot's user-level hook files can use VS Code-compatible PascalCase
|
||||
// names, which match the event vocabulary already normalized by Orca's hook
|
||||
// server and avoid wrapper-side event remapping.
|
||||
const COPILOT_EVENTS = [
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
// Why: GitHub's current reference documents subagentStart with only the
|
||||
// camelCase payload shape. The wrapper passes the event name separately, so
|
||||
// Orca can normalize it without depending on a PascalCase payload.
|
||||
'subagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'Stop',
|
||||
'ErrorOccurred',
|
||||
'PermissionRequest',
|
||||
'Notification'
|
||||
] as const
|
||||
|
||||
function getCopilotHome(): string {
|
||||
const fromEnv = process.env.COPILOT_HOME?.trim()
|
||||
return fromEnv ? fromEnv : join(homedir(), '.copilot')
|
||||
}
|
||||
|
||||
function getConfigPath(): string {
|
||||
return join(getCopilotHome(), 'hooks', 'orca.json')
|
||||
}
|
||||
|
||||
function getManagedScriptFileName(): string {
|
||||
return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh'
|
||||
}
|
||||
|
||||
function getManagedScriptPath(): string {
|
||||
return getSharedManagedScriptPath(getManagedScriptFileName())
|
||||
}
|
||||
|
||||
function quotePowerShellPath(path: string): string {
|
||||
return `'${path.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, eventName: string): string {
|
||||
return process.platform === 'win32'
|
||||
? `$env:ORCA_COPILOT_HOOK_EVENT = '${eventName}'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${quotePowerShellPath(scriptPath)}`
|
||||
: wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
|
||||
}
|
||||
|
||||
function getManagedHookDefinition(command: string): HookDefinition {
|
||||
return process.platform === 'win32'
|
||||
? { type: 'command', powershell: command, timeoutSec: 5 }
|
||||
: { type: 'command', bash: command, timeoutSec: 5 }
|
||||
}
|
||||
|
||||
function getRemoteManagedHookDefinition(command: string): HookDefinition {
|
||||
return { type: 'command', bash: command, timeoutSec: 5 }
|
||||
}
|
||||
|
||||
function definitionHasCurrentCommand(definition: HookDefinition, command: string): boolean {
|
||||
return (
|
||||
definition.command === command ||
|
||||
definition.bash === command ||
|
||||
definition.powershell === command ||
|
||||
(Array.isArray(definition.hooks) && definition.hooks.some((hook) => hook.command === command))
|
||||
)
|
||||
}
|
||||
|
||||
function definitionsChanged(before: HookDefinition[], after: HookDefinition[]): boolean {
|
||||
return (
|
||||
before.length !== after.length ||
|
||||
after.some((definition, index) => definition !== before[index])
|
||||
)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
"Write-Output '{}'",
|
||||
// Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...`
|
||||
// lines so surviving PTYs can refresh to the current Orca server.
|
||||
'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {',
|
||||
' try {',
|
||||
' Get-Content -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT | ForEach-Object {',
|
||||
" if ($_ -match '^set ([A-Za-z0-9_]+)=(.*)$') {",
|
||||
" [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')",
|
||||
' }',
|
||||
' }',
|
||||
' } catch {}',
|
||||
'}',
|
||||
'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }',
|
||||
'$inputData = [Console]::In.ReadToEnd()',
|
||||
'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }',
|
||||
'try {',
|
||||
' $payload = $inputData | ConvertFrom-Json',
|
||||
' $body = @{',
|
||||
' paneKey = $env:ORCA_PANE_KEY',
|
||||
' tabId = $env:ORCA_TAB_ID',
|
||||
' worktreeId = $env:ORCA_WORKTREE_ID',
|
||||
' hookEventName = $env:ORCA_COPILOT_HOOK_EVENT',
|
||||
' env = $env:ORCA_AGENT_HOOK_ENV',
|
||||
' version = $env:ORCA_AGENT_HOOK_VERSION',
|
||||
' payload = $payload',
|
||||
' } | ConvertTo-Json -Depth 100',
|
||||
" Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/copilot') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body -TimeoutSec 2 | Out-Null",
|
||||
'} catch {}',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
"printf '{}\\n'",
|
||||
// Why: Copilot consumes stdout for some hooks, so stdout is emitted before
|
||||
// endpoint refresh, stdin parsing, or the network POST can fail.
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'payload=$(cat)',
|
||||
'if [ -z "$payload" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export class CopilotHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
let staleManagedPresent = false
|
||||
for (const eventName of COPILOT_EVENTS) {
|
||||
const command = getManagedCommand(scriptPath, eventName)
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
const hasCurrentCommand = definitions.some((definition) =>
|
||||
definitionHasCurrentCommand(definition, command)
|
||||
)
|
||||
if (hasCurrentCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
staleManagedPresent =
|
||||
staleManagedPresent ||
|
||||
definitions.some((definition) =>
|
||||
hookDefinitionHasManagedCommand(definition, isManagedCommand)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0 || staleManagedPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (config.disableAllHooks === true && managedHooksPresent) {
|
||||
state = 'partial'
|
||||
detail = 'Managed Copilot hook file is disabled'
|
||||
} else if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0 && !staleManagedPresent) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'copilot', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(COPILOT_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
|
||||
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 eventName of COPILOT_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
nextHooks[eventName] = [
|
||||
...cleaned,
|
||||
getManagedHookDefinition(getManagedCommand(scriptPath, eventName))
|
||||
]
|
||||
}
|
||||
|
||||
config.version = 1
|
||||
delete config.disableAllHooks
|
||||
config.hooks = nextHooks
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.copilot/hooks/orca.json`
|
||||
const remoteScriptPath = `${home}/.orca/agent-hooks/copilot-hook.sh`
|
||||
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(COPILOT_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher('copilot-hook.sh')
|
||||
|
||||
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 eventName of COPILOT_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
nextHooks[eventName] = [
|
||||
...cleaned,
|
||||
getRemoteManagedHookDefinition(
|
||||
wrapPosixHookCommand(remoteScriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
config.version = 1
|
||||
delete config.disableAllHooks
|
||||
config.hooks = nextHooks
|
||||
// Why: SSH remotes use POSIX scripts regardless of Orca's local OS. Write
|
||||
// the script before hooks/orca.json so a partial install cannot point
|
||||
// Copilot at a missing managed command.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
if (!existsSync(configPath)) {
|
||||
return this.getStatus()
|
||||
}
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
let changed = false
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
changed = changed || definitionsChanged(definitions, cleaned)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return this.getStatus()
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export const copilotHookService = new CopilotHookService()
|
||||
|
|
@ -128,6 +128,75 @@ describe('createPtySubprocess', () => {
|
|||
expect(handle.pid).toBe(42)
|
||||
})
|
||||
|
||||
it('does not inherit parent Orca pane identity when caller omits pane env', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.ORCA_PANE_KEY).toBeUndefined()
|
||||
expect(env.ORCA_TAB_ID).toBeUndefined()
|
||||
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves explicit child Orca pane identity over parent env', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
ORCA_PANE_KEY: 'child-tab:child-leaf',
|
||||
ORCA_TAB_ID: 'child-tab',
|
||||
ORCA_WORKTREE_ID: 'child-worktree'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.ORCA_PANE_KEY).toBe('child-tab:child-leaf')
|
||||
expect(env.ORCA_TAB_ID).toBe('child-tab')
|
||||
expect(env.ORCA_WORKTREE_ID).toBe('child-worktree')
|
||||
})
|
||||
|
||||
it('forwards write calls', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershe
|
|||
import { isPwshAvailable } from '../pwsh'
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
|
||||
export type PtySubprocessOptions = {
|
||||
sessionId: string
|
||||
cols: number
|
||||
|
|
@ -50,6 +52,17 @@ function getDefaultCwd(): string {
|
|||
return 'C:\\'
|
||||
}
|
||||
|
||||
function removeUnspecifiedPaneIdentityEnv(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
for (const key of PANE_IDENTITY_ENV_KEYS) {
|
||||
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
|
||||
const detailName = kind === 'helper' ? 'helper' : 'cwd'
|
||||
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
|
||||
|
|
@ -149,6 +162,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
|||
// restores clickable refs like `owner/repo#123` / `PR#123`.
|
||||
FORCE_HYPERLINK: '1'
|
||||
} as Record<string, string>
|
||||
// Why: the daemon is forked from Electron and can inherit the pane identity
|
||||
// of the terminal that launched `pn dev`; each PTY must opt into its own.
|
||||
removeUnspecifiedPaneIdentityEnv(env, opts.env)
|
||||
removeInheritedNoColor(env)
|
||||
|
||||
env.LANG ??= 'en_US.UTF-8'
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import { geminiHookService } from './gemini/hook-service'
|
|||
import { cursorHookService } from './cursor/hook-service'
|
||||
import { droidHookService } from './droid/hook-service'
|
||||
import { grokHookService } from './grok/hook-service'
|
||||
import { copilotHookService } from './copilot/hook-service'
|
||||
import { hermesHookService } from './hermes/hook-service'
|
||||
import {
|
||||
getPtyIdForPaneKey,
|
||||
|
|
@ -806,15 +807,17 @@ app.whenReady().then(async () => {
|
|||
// (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup.
|
||||
// The agent label travels with each installer so the catch can attribute
|
||||
// the failure in the `agent_hook_install_failed` telemetry event.
|
||||
runManagedHookInstallers([
|
||||
const managedHookInstallers = [
|
||||
['claude', () => claudeHookService.install()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['grok', () => grokHookService.install()],
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['hermes', () => hermesHookService.install()]
|
||||
])
|
||||
] as const
|
||||
runManagedHookInstallers(managedHookInstallers)
|
||||
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { geminiHookService } from '../gemini/hook-service'
|
|||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
|
||||
// Why: install/remove are intentionally not exposed to the renderer. Orca
|
||||
|
|
@ -34,6 +35,7 @@ export function registerAgentHookHandlers(): void {
|
|||
ipcMain.removeHandler('agentHooks:cursorStatus')
|
||||
ipcMain.removeHandler('agentHooks:droidStatus')
|
||||
ipcMain.removeHandler('agentHooks:grokStatus')
|
||||
ipcMain.removeHandler('agentHooks:copilotStatus')
|
||||
ipcMain.removeHandler('agentHooks:hermesStatus')
|
||||
ipcMain.removeHandler('agentStatus:getSnapshot')
|
||||
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
|
||||
|
|
@ -150,6 +152,19 @@ export function registerAgentHookHandlers(): void {
|
|||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:copilotStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return copilotHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:hermesStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return hermesHookService.getStatus()
|
||||
|
|
|
|||
|
|
@ -1156,13 +1156,25 @@ export function registerPtyHandlers(
|
|||
? makePaneKey(args.tabId, args.leafId)
|
||||
: null
|
||||
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
|
||||
const baseEnv = baseEnvWithAuth
|
||||
? { ...baseEnvWithAuth, ...(stablePaneKey ? { ORCA_PANE_KEY: stablePaneKey } : {}) }
|
||||
: undefined
|
||||
if (baseEnv && !stablePaneKey) {
|
||||
const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
|
||||
if (baseEnv && stablePaneKey) {
|
||||
baseEnv.ORCA_PANE_KEY = stablePaneKey
|
||||
if (typeof args.tabId === 'string') {
|
||||
baseEnv.ORCA_TAB_ID = args.tabId
|
||||
} else if (!args.connectionId) {
|
||||
delete baseEnv.ORCA_TAB_ID
|
||||
}
|
||||
if (typeof args.worktreeId === 'string') {
|
||||
baseEnv.ORCA_WORKTREE_ID = args.worktreeId
|
||||
} else if (!args.connectionId) {
|
||||
delete baseEnv.ORCA_WORKTREE_ID
|
||||
}
|
||||
} else if (baseEnv) {
|
||||
// Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the
|
||||
// key proven to match this spawn's tab+leaf may leave the IPC boundary.
|
||||
delete baseEnv.ORCA_PANE_KEY
|
||||
delete baseEnv.ORCA_TAB_ID
|
||||
delete baseEnv.ORCA_WORKTREE_ID
|
||||
}
|
||||
const validatedPaneKey = stablePaneKey
|
||||
const validatedLeafId = verifiedLeafId ?? metadataLeafId
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ describe('registerCoreHandlers', () => {
|
|||
const codexAccounts = { marker: 'codexAccounts' }
|
||||
const claudeAccounts = { marker: 'claudeAccounts' }
|
||||
const rateLimits = { marker: 'rateLimits' }
|
||||
const agentAwakeService = { marker: 'agentAwakeService' }
|
||||
|
||||
registerCoreHandlers(
|
||||
store as never,
|
||||
|
|
@ -319,7 +320,11 @@ describe('registerCoreHandlers', () => {
|
|||
openCodeUsage as never,
|
||||
codexAccounts as never,
|
||||
claudeAccounts as never,
|
||||
rateLimits as never
|
||||
rateLimits as never,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
agentAwakeService as never
|
||||
)
|
||||
|
||||
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
|
||||
|
|
@ -341,7 +346,7 @@ describe('registerCoreHandlers', () => {
|
|||
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
|
||||
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
|
||||
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
|
||||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, undefined)
|
||||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
|
||||
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
|
|
|||
|
|
@ -140,6 +140,70 @@ describe('LocalPtyProvider', () => {
|
|||
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
|
||||
})
|
||||
|
||||
it('does not inherit parent Orca pane identity when caller omits pane env', async () => {
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.ORCA_PANE_KEY).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_TAB_ID).toBeUndefined()
|
||||
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves explicit child Orca pane identity over parent env', async () => {
|
||||
const saved = {
|
||||
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
|
||||
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
|
||||
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
|
||||
process.env.ORCA_TAB_ID = 'parent-tab'
|
||||
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
ORCA_PANE_KEY: 'child-tab:child-leaf',
|
||||
ORCA_TAB_ID: 'child-tab',
|
||||
ORCA_WORKTREE_ID: 'child-worktree'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].env.ORCA_PANE_KEY).toBe('child-tab:child-leaf')
|
||||
expect(spawnCall[2].env.ORCA_TAB_ID).toBe('child-tab')
|
||||
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBe('child-worktree')
|
||||
})
|
||||
|
||||
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import {
|
|||
} from './local-pty-shell-ready'
|
||||
import { removeInheritedNoColor } from '../pty/terminal-color-env'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
|
||||
let ptyCounter = 0
|
||||
const ptyProcesses = new Map<string, pty.IPty>()
|
||||
const ptyShellName = new Map<string, string>()
|
||||
|
|
@ -66,6 +68,17 @@ function getDefaultCwd(): string {
|
|||
return 'C:\\'
|
||||
}
|
||||
|
||||
function removeUnspecifiedPaneIdentityEnv(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
for (const key of PANE_IDENTITY_ENV_KEYS) {
|
||||
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disposePtyListeners(id: string): void {
|
||||
const disposables = ptyDisposables.get(id)
|
||||
if (disposables) {
|
||||
|
|
@ -236,6 +249,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
|||
// restores clickable refs like `owner/repo#123` / `PR#123`.
|
||||
FORCE_HYPERLINK: '1'
|
||||
} as Record<string, string>
|
||||
// Why: Orca can be launched from an Orca terminal while developing. Pane
|
||||
// identity belongs to the child PTY, not the parent shell that spawned app.
|
||||
removeUnspecifiedPaneIdentityEnv(spawnEnv, args.env)
|
||||
removeInheritedNoColor(spawnEnv)
|
||||
for (const key of args.envToDelete ?? []) {
|
||||
delete spawnEnv[key]
|
||||
|
|
|
|||
|
|
@ -1119,6 +1119,7 @@ export type PreloadApi = {
|
|||
cursorStatus: () => Promise<AgentHookInstallStatus>
|
||||
droidStatus: () => Promise<AgentHookInstallStatus>
|
||||
grokStatus: () => Promise<AgentHookInstallStatus>
|
||||
copilotStatus: () => Promise<AgentHookInstallStatus>
|
||||
hermesStatus: () => Promise<AgentHookInstallStatus>
|
||||
}
|
||||
agentTrust: {
|
||||
|
|
|
|||
|
|
@ -1148,6 +1148,8 @@ const api = {
|
|||
droidStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:droidStatus'),
|
||||
grokStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:grokStatus'),
|
||||
copilotStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:copilotStatus'),
|
||||
hermesStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:hermesStatus')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
|
|
@ -197,4 +197,59 @@ describe('RelayAgentHookServer', () => {
|
|||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Copilot transcript retry alive across a following SessionEnd event', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
const transcriptPath = join(dir, 'events.jsonl')
|
||||
writeFileSync(transcriptPath, '')
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
await fetch(`http://127.0.0.1:${port}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'Stop', transcriptPath }
|
||||
})
|
||||
})
|
||||
await fetch(`http://127.0.0.1:${port}/hook/copilot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'SessionEnd', reason: 'complete' }
|
||||
})
|
||||
})
|
||||
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined()
|
||||
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Relay transcript completed.' }
|
||||
})}\n`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
|
||||
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe(
|
||||
'Relay transcript completed.'
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: relay hook parsing, replay cache, endpoint
|
||||
writing, and Copilot transcript retry state are one lifecycle unit; splitting
|
||||
them would obscure cleanup ordering across remote PTY reconnects. */
|
||||
// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts
|
||||
// a loopback HTTP server (same shape as Orca's main-process server: bind
|
||||
// 127.0.0.1:0, bearer-token auth, /hook/<source> routing) and forwards every
|
||||
|
|
@ -41,6 +44,8 @@ export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
|
|||
// server is the only consumer.
|
||||
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
|
||||
const RELAY_HOOKS_SUBDIR = 'agent-hooks'
|
||||
const COPILOT_TRANSCRIPT_RETRY_ATTEMPTS = 5
|
||||
const COPILOT_TRANSCRIPT_RETRY_MS = 50
|
||||
|
||||
// Why: cap env/version metadata at 64 chars so a misbehaving agent CLI
|
||||
// cannot grow lastEnvelopeMetaByPaneKey unboundedly per pane via the cache
|
||||
|
|
@ -52,6 +57,34 @@ function defaultEndpointDir(): string {
|
|||
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
|
||||
}
|
||||
|
||||
function hasPendingCopilotTranscript(source: AgentHookSource, body: unknown): boolean {
|
||||
if (source !== 'copilot' || typeof body !== 'object' || body === null) {
|
||||
return false
|
||||
}
|
||||
const rawPayload = (body as Record<string, unknown>).payload
|
||||
const payload =
|
||||
typeof rawPayload === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(rawPayload) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: rawPayload
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return false
|
||||
}
|
||||
const record = payload as Record<string, unknown>
|
||||
const directMessage =
|
||||
record.last_assistant_message ?? record.lastAssistantMessage ?? record.message
|
||||
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
|
||||
return false
|
||||
}
|
||||
const transcriptPath = record.transcript_path ?? record.transcriptPath
|
||||
return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0
|
||||
}
|
||||
|
||||
export function endpointDirForRelaySocket(sockPath: string): string {
|
||||
return join(dirname(sockPath), RELAY_HOOKS_SUBDIR, basename(sockPath))
|
||||
}
|
||||
|
|
@ -87,6 +120,7 @@ export class RelayAgentHookServer {
|
|||
string,
|
||||
{ source: AgentHookSource; env?: string; version?: string }
|
||||
> = new Map()
|
||||
private copilotTranscriptRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private forward: RelayHookForward
|
||||
|
||||
constructor(options: RelayHookServerOptions) {
|
||||
|
|
@ -144,6 +178,10 @@ export class RelayAgentHookServer {
|
|||
this.port = 0
|
||||
this.token = ''
|
||||
this.endpointFileWritten = false
|
||||
for (const timer of this.copilotTranscriptRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.copilotTranscriptRetryTimers.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
this.lastEnvelopeMetaByPaneKey.clear()
|
||||
}
|
||||
|
|
@ -175,6 +213,7 @@ export class RelayAgentHookServer {
|
|||
* resurfaces as a ghost event on a later reconnect. Symmetric with the
|
||||
* local server's clearPaneState on PTY teardown. */
|
||||
clearPaneState(paneKey: string): void {
|
||||
this.clearCopilotTranscriptRetry(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
|
@ -229,13 +268,12 @@ export class RelayAgentHookServer {
|
|||
}
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (event) {
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, event)
|
||||
// TODO: once normalizeHookPayload returns validated env/version, drop
|
||||
// bodyEnv/bodyVersion and source those from the listener result instead.
|
||||
const env = this.bodyEnv(body)
|
||||
const version = this.bodyVersion(body)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
this.forwardEvent(event, source, env, version)
|
||||
this.applyEvent(event, source, env, version)
|
||||
this.scheduleCopilotTranscriptRetry(source, body, event, env, version)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
|
|
@ -271,6 +309,77 @@ export class RelayAgentHookServer {
|
|||
this.forward(envelope)
|
||||
}
|
||||
|
||||
private applyEvent(
|
||||
event: AgentHookEventPayload,
|
||||
source: AgentHookSource,
|
||||
env?: string,
|
||||
version?: string
|
||||
): void {
|
||||
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
|
||||
this.clearCopilotTranscriptRetry(event.paneKey)
|
||||
}
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, event)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
this.forwardEvent(event, source, env, version)
|
||||
}
|
||||
|
||||
private clearCopilotTranscriptRetry(paneKey: string): void {
|
||||
const timer = this.copilotTranscriptRetryTimers.get(paneKey)
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
this.copilotTranscriptRetryTimers.delete(paneKey)
|
||||
}
|
||||
|
||||
private scheduleCopilotTranscriptRetry(
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
original: AgentHookEventPayload,
|
||||
env?: string,
|
||||
version?: string,
|
||||
attempt = 1
|
||||
): void {
|
||||
if (
|
||||
original.payload.lastAssistantMessage ||
|
||||
!hasPendingCopilotTranscript(source, body) ||
|
||||
attempt > COPILOT_TRANSCRIPT_RETRY_ATTEMPTS
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.clearCopilotTranscriptRetry(original.paneKey)
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
this.copilotTranscriptRetryTimers.delete(original.paneKey)
|
||||
const current = this.state.lastStatusByPaneKey.get(original.paneKey)
|
||||
if (
|
||||
!current ||
|
||||
current.payload.agentType !== 'copilot' ||
|
||||
current.payload.prompt !== original.payload.prompt ||
|
||||
current.payload.lastAssistantMessage
|
||||
) {
|
||||
return
|
||||
}
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (!event?.payload.lastAssistantMessage) {
|
||||
this.scheduleCopilotTranscriptRetry(source, body, original, env, version, attempt + 1)
|
||||
return
|
||||
}
|
||||
// Why: the relay runs on SSH targets too; retry from a timer so a delayed
|
||||
// Copilot transcript does not block the remote hook server's event loop.
|
||||
this.applyEvent(event, source, env, version)
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[relay-hook-server] copilot transcript retry failed: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
}
|
||||
}, COPILOT_TRANSCRIPT_RETRY_MS)
|
||||
this.copilotTranscriptRetryTimers.set(original.paneKey, timer)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
private bodyEnv(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
|
|||
id: 'copilot',
|
||||
label: 'GitHub Copilot',
|
||||
cmd: 'copilot',
|
||||
faviconDomain: 'github.com',
|
||||
homepageUrl: 'https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli'
|
||||
},
|
||||
{
|
||||
|
|
@ -267,6 +266,25 @@ function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from Primer Octicons' copilot-16 icon. GitHub's 2025 brand
|
||||
// guidance deprecated the old standalone Copilot mascot logo.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z" />
|
||||
<path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentLetterIcon({
|
||||
letter,
|
||||
size = 14
|
||||
|
|
@ -331,6 +349,9 @@ export function AgentIcon({
|
|||
if (agent === 'kilo') {
|
||||
return <KiloIcon size={size} />
|
||||
}
|
||||
if (agent === 'copilot') {
|
||||
return <CopilotIcon size={size} />
|
||||
}
|
||||
const catalogEntry = AGENT_CATALOG.find((a) => a.id === agent)
|
||||
if (catalogEntry?.faviconDomain) {
|
||||
// Why: agents without a published SVG icon use their site favicon via
|
||||
|
|
@ -343,7 +364,6 @@ export function AgentIcon({
|
|||
alt=""
|
||||
aria-hidden
|
||||
style={{ borderRadius: 2 }}
|
||||
className={agent === 'copilot' ? 'dark:invert' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
|
|||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'GitHub Copilot',
|
||||
opencode: 'OpenCode',
|
||||
cursor: 'Cursor',
|
||||
aider: 'Aider',
|
||||
|
|
|
|||
|
|
@ -874,7 +874,9 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
|
|||
}
|
||||
|
||||
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
||||
const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok' | 'hermes') =>
|
||||
const status = (
|
||||
agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok' | 'copilot' | 'hermes'
|
||||
) =>
|
||||
Promise.resolve({
|
||||
agent,
|
||||
state: 'not_installed',
|
||||
|
|
@ -889,6 +891,7 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
|||
cursorStatus: () => status('cursor'),
|
||||
droidStatus: () => status('droid'),
|
||||
grokStatus: () => status('grok'),
|
||||
copilotStatus: () => status('copilot'),
|
||||
hermesStatus: () => status('hermes')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,7 +217,15 @@ export function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
|||
// ─── Per-pane field caches + extractors ─────────────────────────────
|
||||
|
||||
function extractPromptText(hookPayload: Record<string, unknown>): string {
|
||||
const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'user_message', 'message']
|
||||
const candidateKeys = [
|
||||
'prompt',
|
||||
'user_prompt',
|
||||
'userPrompt',
|
||||
'initial_prompt',
|
||||
'initialPrompt',
|
||||
'user_message',
|
||||
'message'
|
||||
]
|
||||
for (const key of candidateKeys) {
|
||||
const value = hookPayload[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
|
|
@ -258,6 +266,7 @@ export type ToolSnapshot = {
|
|||
toolName?: string
|
||||
toolInput?: string
|
||||
lastAssistantMessage?: string
|
||||
clearLastAssistantMessage?: boolean
|
||||
}
|
||||
|
||||
function resolveToolState(
|
||||
|
|
@ -273,7 +282,9 @@ function resolveToolState(
|
|||
const merged: ToolSnapshot = {
|
||||
toolName: update.toolName ?? previous.toolName,
|
||||
toolInput: update.toolInput ?? previous.toolInput,
|
||||
lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage
|
||||
lastAssistantMessage: update.clearLastAssistantMessage
|
||||
? undefined
|
||||
: (update.lastAssistantMessage ?? previous.lastAssistantMessage)
|
||||
}
|
||||
state.lastToolByPaneKey.set(paneKey, merged)
|
||||
return merged
|
||||
|
|
@ -309,10 +320,15 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
|
|||
execute_code: ['code', 'command', 'cmd'],
|
||||
apply_patch: ['path', 'file_path'],
|
||||
view_image: ['path', 'file_path'],
|
||||
AskUser: ['question', 'prompt', 'message'],
|
||||
ask_user: ['question', 'prompt', 'message'],
|
||||
bash: ['command'],
|
||||
powershell: ['command'],
|
||||
create: ['path', 'file_path'],
|
||||
read: ['path', 'file_path'],
|
||||
write: ['path', 'file_path'],
|
||||
edit: ['path', 'file_path'],
|
||||
view: ['path', 'file_path'],
|
||||
grep: ['pattern'],
|
||||
web_search: ['query'],
|
||||
fetch_content: ['url'],
|
||||
|
|
@ -394,6 +410,33 @@ function readString(record: Record<string, unknown>, key: string): string | unde
|
|||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function readFirstString(
|
||||
record: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = readString(record, key)
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseJsonObjectString(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolResponseText(toolResponse: unknown): string | undefined {
|
||||
if (typeof toolResponse === 'string' && toolResponse.length > 0) {
|
||||
return toolResponse
|
||||
|
|
@ -402,6 +445,10 @@ function extractToolResponseText(toolResponse: unknown): string | undefined {
|
|||
return undefined
|
||||
}
|
||||
const record = toolResponse as Record<string, unknown>
|
||||
const directText = readFirstString(record, ['text_result_for_llm', 'textResultForLlm', 'text'])
|
||||
if (directText) {
|
||||
return directText
|
||||
}
|
||||
const content = record.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
|
|
@ -413,10 +460,6 @@ function extractToolResponseText(toolResponse: unknown): string | undefined {
|
|||
}
|
||||
}
|
||||
}
|
||||
const text = record.text
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
@ -434,12 +477,25 @@ function extractAssistantTextFromLine(line: string): string | undefined {
|
|||
return undefined
|
||||
}
|
||||
const record = entry as Record<string, unknown>
|
||||
if (record.type === 'assistant.message') {
|
||||
const data = record.data
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const text = extractAssistantContentText((data as Record<string, unknown>).content)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
const nestedMessage = record.message as Record<string, unknown> | undefined
|
||||
const role = record.role ?? nestedMessage?.role
|
||||
if (role !== 'assistant') {
|
||||
return undefined
|
||||
}
|
||||
const content = (nestedMessage ?? record).content
|
||||
return extractAssistantContentText(content)
|
||||
}
|
||||
|
||||
function extractAssistantContentText(content: unknown): string | undefined {
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
|
|
@ -460,6 +516,10 @@ function readLastAssistantFromTranscript(transcriptPath: unknown): string | unde
|
|||
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return readLastAssistantFromTranscriptOnce(transcriptPath)
|
||||
}
|
||||
|
||||
function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined {
|
||||
try {
|
||||
const stats = statSync(transcriptPath)
|
||||
const size = stats.size
|
||||
|
|
@ -678,6 +738,180 @@ function extractCursorToolFields(
|
|||
return {}
|
||||
}
|
||||
|
||||
function normalizeCopilotEventName(eventName: unknown): unknown {
|
||||
if (typeof eventName !== 'string') {
|
||||
return eventName
|
||||
}
|
||||
const eventMap: Record<string, string> = {
|
||||
sessionStart: 'SessionStart',
|
||||
sessionEnd: 'SessionEnd',
|
||||
userPromptSubmitted: 'UserPromptSubmit',
|
||||
userPromptSubmit: 'UserPromptSubmit',
|
||||
preToolUse: 'PreToolUse',
|
||||
postToolUse: 'PostToolUse',
|
||||
postToolUseFailure: 'PostToolUseFailure',
|
||||
subagentStart: 'SubagentStart',
|
||||
subagentStop: 'SubagentStop',
|
||||
preCompact: 'PreCompact',
|
||||
agentStop: 'Stop',
|
||||
stop: 'Stop',
|
||||
errorOccurred: 'ErrorOccurred',
|
||||
permissionRequest: 'PermissionRequest',
|
||||
notification: 'Notification'
|
||||
}
|
||||
return eventMap[eventName] ?? eventName
|
||||
}
|
||||
|
||||
function resolveCopilotEventName(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): unknown {
|
||||
const explicit =
|
||||
eventName ??
|
||||
readFirstString(hookPayload, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType'])
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
if (readFirstString(hookPayload, ['initial_prompt', 'initialPrompt'])) {
|
||||
return 'SessionStart'
|
||||
}
|
||||
if (readString(hookPayload, 'prompt')) {
|
||||
return 'UserPromptSubmit'
|
||||
}
|
||||
if (readFirstString(hookPayload, ['notification_type', 'notificationType'])) {
|
||||
return 'Notification'
|
||||
}
|
||||
if (
|
||||
readFirstString(hookPayload, ['transcript_path', 'transcriptPath', 'stop_reason', 'stopReason'])
|
||||
) {
|
||||
return 'Stop'
|
||||
}
|
||||
if (hookPayload.error || readFirstString(hookPayload, ['error_context', 'errorContext'])) {
|
||||
return 'ErrorOccurred'
|
||||
}
|
||||
if (
|
||||
Array.isArray(hookPayload.toolCalls) ||
|
||||
readFirstString(hookPayload, ['tool_name', 'toolName', 'name'])
|
||||
) {
|
||||
if (
|
||||
hookPayload.tool_result ||
|
||||
hookPayload.toolResult ||
|
||||
hookPayload.tool_response ||
|
||||
hookPayload.toolResponse
|
||||
) {
|
||||
return 'PostToolUse'
|
||||
}
|
||||
return 'PreToolUse'
|
||||
}
|
||||
return eventName
|
||||
}
|
||||
|
||||
function readCopilotToolCall(hookPayload: Record<string, unknown>): {
|
||||
toolName?: string
|
||||
toolInputSource?: unknown
|
||||
} {
|
||||
const toolCalls = hookPayload.toolCalls
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
||||
return {}
|
||||
}
|
||||
const first = toolCalls[0]
|
||||
if (typeof first !== 'object' || first === null) {
|
||||
return {}
|
||||
}
|
||||
const record = first as Record<string, unknown>
|
||||
return {
|
||||
toolName: readFirstString(record, ['name', 'toolName', 'tool_name']),
|
||||
toolInputSource:
|
||||
parseJsonObjectString(record.args) ??
|
||||
record.args ??
|
||||
parseJsonObjectString(record.arguments) ??
|
||||
record.arguments
|
||||
}
|
||||
}
|
||||
|
||||
function isAskUserTool(toolName: string | undefined): boolean {
|
||||
return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser'
|
||||
}
|
||||
|
||||
function extractCopilotToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ToolSnapshot {
|
||||
const update: ToolSnapshot = {}
|
||||
if (
|
||||
eventName === 'PreToolUse' ||
|
||||
eventName === 'PostToolUse' ||
|
||||
eventName === 'PostToolUseFailure' ||
|
||||
eventName === 'PermissionRequest'
|
||||
) {
|
||||
const copilotToolCall = readCopilotToolCall(hookPayload)
|
||||
const toolName =
|
||||
readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) ?? copilotToolCall.toolName
|
||||
const toolInput =
|
||||
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.toolInput) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.toolArgs) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.input) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.arguments) ??
|
||||
deriveToolInputPreview(toolName, copilotToolCall.toolInputSource)
|
||||
update.toolName = toolName
|
||||
update.toolInput = toolInput
|
||||
if (isAskUserTool(toolName) && toolInput) {
|
||||
update.lastAssistantMessage = toolInput
|
||||
}
|
||||
}
|
||||
if (eventName === 'PostToolUse') {
|
||||
const responseText =
|
||||
extractToolResponseText(hookPayload.tool_result) ??
|
||||
extractToolResponseText(hookPayload.toolResult) ??
|
||||
extractToolResponseText(hookPayload.tool_response) ??
|
||||
extractToolResponseText(hookPayload.toolResponse)
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
}
|
||||
}
|
||||
if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') {
|
||||
const errorText =
|
||||
extractToolResponseText(hookPayload.tool_result) ??
|
||||
extractToolResponseText(hookPayload.toolResult) ??
|
||||
extractToolResponseText(hookPayload.tool_response) ??
|
||||
extractToolResponseText(hookPayload.toolResponse) ??
|
||||
readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message'])
|
||||
if (errorText) {
|
||||
update.lastAssistantMessage = errorText
|
||||
}
|
||||
}
|
||||
if (eventName === 'Notification') {
|
||||
const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType'])
|
||||
if (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') {
|
||||
const message = readFirstString(hookPayload, ['message', 'body', 'text', 'title'])
|
||||
if (message) {
|
||||
update.lastAssistantMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eventName === 'Stop') {
|
||||
const direct = readFirstString(hookPayload, [
|
||||
'last_assistant_message',
|
||||
'lastAssistantMessage',
|
||||
'message'
|
||||
])
|
||||
if (direct) {
|
||||
update.lastAssistantMessage = direct
|
||||
} else {
|
||||
const lastFromTranscript = readLastAssistantFromTranscript(
|
||||
hookPayload.transcript_path ?? hookPayload.transcriptPath
|
||||
)
|
||||
if (lastFromTranscript) {
|
||||
update.lastAssistantMessage = lastFromTranscript
|
||||
} else {
|
||||
update.clearLastAssistantMessage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
||||
function extractPiToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
|
|
@ -952,6 +1186,10 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
|||
return eventName === 'UserPromptSubmit'
|
||||
case 'grok':
|
||||
return isGrokEvent(eventName, 'user_prompt_submit')
|
||||
case 'copilot': {
|
||||
const normalizedEventName = normalizeCopilotEventName(eventName)
|
||||
return normalizedEventName === 'SessionStart' || normalizedEventName === 'UserPromptSubmit'
|
||||
}
|
||||
case 'hermes':
|
||||
return eventName === 'pre_llm_call' || eventName === 'on_session_start'
|
||||
default: {
|
||||
|
|
@ -986,6 +1224,8 @@ function extractToolFields(
|
|||
return extractDroidToolFields(eventName, hookPayload)
|
||||
case 'grok':
|
||||
return extractGrokToolFields(eventName, hookPayload)
|
||||
case 'copilot':
|
||||
return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload)
|
||||
case 'hermes':
|
||||
return extractHermesToolFields(eventName, hookPayload)
|
||||
default: {
|
||||
|
|
@ -1225,6 +1465,69 @@ function normalizeCursorEvent(
|
|||
)
|
||||
}
|
||||
|
||||
// Why: PermissionRequest fires before Copilot's allow/ask/deny checks, so a
|
||||
// generic PermissionRequest stays working. `ask_user` itself is a user-input
|
||||
// boundary, and notification prompts are the async user-visible blocked signal.
|
||||
function normalizeCopilotEvent(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
promptText: string,
|
||||
paneKey: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ParsedAgentStatusPayload | null {
|
||||
const normalizedEventName = normalizeCopilotEventName(
|
||||
resolveCopilotEventName(eventName, hookPayload)
|
||||
)
|
||||
const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType'])
|
||||
const isBlockingNotification =
|
||||
normalizedEventName === 'Notification' &&
|
||||
(notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog')
|
||||
const toolSnapshot = extractToolFields('copilot', normalizedEventName, hookPayload)
|
||||
const isAskUserPrompt =
|
||||
(normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest') &&
|
||||
isAskUserTool(toolSnapshot.toolName)
|
||||
const stateName =
|
||||
normalizedEventName === 'SessionStart' ||
|
||||
normalizedEventName === 'UserPromptSubmit' ||
|
||||
normalizedEventName === 'PostToolUse' ||
|
||||
normalizedEventName === 'PostToolUseFailure'
|
||||
? 'working'
|
||||
: isBlockingNotification || isAskUserPrompt
|
||||
? 'blocked'
|
||||
: normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest'
|
||||
? 'working'
|
||||
: normalizedEventName === 'Stop' || normalizedEventName === 'SessionEnd'
|
||||
? 'done'
|
||||
: normalizedEventName === 'ErrorOccurred'
|
||||
? hookPayload.recoverable === true
|
||||
? 'working'
|
||||
: 'done'
|
||||
: null
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = resolveToolState(state, paneKey, toolSnapshot, {
|
||||
resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName)
|
||||
})
|
||||
|
||||
const effectivePrompt = normalizedEventName === 'Notification' ? '' : promptText
|
||||
|
||||
return parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state: stateName,
|
||||
prompt: resolvePrompt(state, paneKey, effectivePrompt, {
|
||||
resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName)
|
||||
}),
|
||||
agentType: 'copilot',
|
||||
toolName: snapshot.toolName,
|
||||
toolInput: snapshot.toolInput,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePiEvent(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
|
|
@ -1513,7 +1816,10 @@ export function normalizeHookPayload(
|
|||
const worktreeId = readStringField(record, 'worktreeId')
|
||||
|
||||
const hookPayloadRecord = hookPayload as Record<string, unknown>
|
||||
const eventName = hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName
|
||||
const eventName =
|
||||
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
|
||||
hookPayloadRecord.hook_event_name ??
|
||||
hookPayloadRecord.hookEventName
|
||||
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
|
||||
// Why: exhaustive switch so adding a source to AgentHookSource fails
|
||||
// typecheck here instead of silently routing through OpenCode's normalizer.
|
||||
|
|
@ -1543,6 +1849,9 @@ export function normalizeHookPayload(
|
|||
case 'grok':
|
||||
payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
case 'copilot':
|
||||
payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
case 'hermes':
|
||||
payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
|
|
@ -1571,6 +1880,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
|
|||
'/hook/pi': 'pi',
|
||||
'/hook/droid': 'droid',
|
||||
'/hook/grok': 'grok',
|
||||
'/hook/copilot': 'copilot',
|
||||
'/hook/hermes': 'hermes'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type AgentHookSource =
|
|||
| 'pi'
|
||||
| 'droid'
|
||||
| 'grok'
|
||||
| 'copilot'
|
||||
| 'hermes'
|
||||
|
||||
/** Env marker used by the remote relay. It is a transport/location marker, not
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// Why: shared agent-hook IPC payload shapes and the managed-script protocol
|
||||
// version constant. Consumed by both the main-process hook server (src/main/
|
||||
// agent-hooks/server.ts) and each per-agent hook service (claude/codex/
|
||||
// gemini/cursor/hook-service.ts). Lives in `shared/` to keep a single
|
||||
// source of truth for the version string and status contract.
|
||||
// agent-hooks/server.ts) and each per-agent hook service. Lives in `shared/`
|
||||
// to keep a single source of truth for the version string and status contract.
|
||||
|
||||
export const AGENT_HOOK_TARGETS = [
|
||||
'claude',
|
||||
|
|
@ -11,6 +10,7 @@ export const AGENT_HOOK_TARGETS = [
|
|||
'cursor',
|
||||
'droid',
|
||||
'grok',
|
||||
'copilot',
|
||||
'hermes'
|
||||
] as const
|
||||
export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type WellKnownAgentType =
|
|||
| 'gemini'
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'copilot'
|
||||
| 'aider'
|
||||
| 'pi'
|
||||
| 'droid'
|
||||
|
|
|
|||
Loading…
Reference in New Issue