Add Copilot agent hook status support (#2027)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-17 13:05:24 -07:00 committed by GitHub
parent 102ca09a38
commit e671606fff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1989 additions and 49 deletions

View File

@ -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)',
() => {

View File

@ -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 {

View File

@ -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()

View File

@ -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

View File

@ -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) {

View File

@ -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'
})
})
})

View File

@ -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()

View File

@ -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)

View File

@ -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'

View File

@ -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, {

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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]

View File

@ -1119,6 +1119,7 @@ export type PreloadApi = {
cursorStatus: () => Promise<AgentHookInstallStatus>
droidStatus: () => Promise<AgentHookInstallStatus>
grokStatus: () => Promise<AgentHookInstallStatus>
copilotStatus: () => Promise<AgentHookInstallStatus>
hermesStatus: () => Promise<AgentHookInstallStatus>
}
agentTrust: {

View File

@ -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')
},

View File

@ -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()
}
})
})

View File

@ -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

View File

@ -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}
/>
)
}

View File

@ -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',

View File

@ -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')
}
}

View File

@ -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'
})

View File

@ -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

View File

@ -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]

View File

@ -15,6 +15,7 @@ export type WellKnownAgentType =
| 'gemini'
| 'opencode'
| 'cursor'
| 'copilot'
| 'aider'
| 'pi'
| 'droid'