diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 7b1133e6e..f7ca2d3ea 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -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/ 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)', () => { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 570ed9c18..59d354933 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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 { // 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 ` would source. export function writeManagedScript(scriptPath: string, content: string): void { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 201f884a4..eb1f51e03 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 + } + 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() diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 10ac1f62d..a03d34376 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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 diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 7fe764579..d9d717d1d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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).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 + 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 | 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 | null = null + private copilotTranscriptRetryTimers = new Map>() // 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 { @@ -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) { diff --git a/src/main/copilot/hook-service.test.ts b/src/main/copilot/hook-service.test.ts new file mode 100644 index 000000000..6cc687646 --- /dev/null +++ b/src/main/copilot/hook-service.test.ts @@ -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 { + 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 + 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 + 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 + + for (const definitions of Object.values(hooks)) { + for (const definition of definitions) { + const bash = (definition as Record).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 + + 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 + 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 + + 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' + }) + }) +}) diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts new file mode 100644 index 000000000..ad0bb3005 --- /dev/null +++ b/src/main/copilot/hook-service.ts @@ -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(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 { + 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(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() diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0f7b31811..49549d03e 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 105847511..b1a4514b9 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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, + explicitEnv: Record | 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 + // 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' diff --git a/src/main/index.ts b/src/main/index.ts index 8b9336737..3f0e82354 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index f789ef9eb..1e45dd3c9 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -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() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 224d79c03..8fd261178 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 21d0836ec..8b3c226a6 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index fe660ed6d..f186a705d 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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 diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3ce4c5423..74576c680 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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() const ptyShellName = new Map() @@ -66,6 +68,17 @@ function getDefaultCwd(): string { return 'C:\\' } +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | 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 + // 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] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ff54e93b0..de9621d2a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1119,6 +1119,7 @@ export type PreloadApi = { cursorStatus: () => Promise droidStatus: () => Promise grokStatus: () => Promise + copilotStatus: () => Promise hermesStatus: () => Promise } agentTrust: { diff --git a/src/preload/index.ts b/src/preload/index.ts index cf75c0818..733ee7f18 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1148,6 +1148,8 @@ const api = { droidStatus: (): Promise => ipcRenderer.invoke('agentHooks:droidStatus'), grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus'), + copilotStatus: (): Promise => + ipcRenderer.invoke('agentHooks:copilotStatus'), hermesStatus: (): Promise => ipcRenderer.invoke('agentHooks:hermesStatus') }, diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e24182441..63f609814 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -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() + } + }) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index f2fc8e5d2..31b59bb63 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -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/ 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).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 + 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>() 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 diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 1d77e38ae..3f308dcf3 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -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 ( + + + + + ) +} + function AgentLetterIcon({ letter, size = 14 @@ -331,6 +349,9 @@ export function AgentIcon({ if (agent === 'kilo') { return } + if (agent === 'copilot') { + return + } 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} /> ) } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 302d519aa..cae3d4f24 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -113,6 +113,7 @@ const WELL_KNOWN_LABELS: Record = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', + copilot: 'GitHub Copilot', opencode: 'OpenCode', cursor: 'Cursor', aider: 'Aider', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 27bee11fa..e419d8bfc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -874,7 +874,9 @@ function createCliApi(): NonNullable['cli']> { } function createAgentHooksApi(): NonNullable['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['agentHooks']> { cursorStatus: () => status('cursor'), droidStatus: () => status('droid'), grokStatus: () => status('grok'), + copilotStatus: () => status('copilot'), hermesStatus: () => status('hermes') } } diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 4d6364fe2..c684f1583 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -217,7 +217,15 @@ export function readRequestBody(req: IncomingMessage): Promise { // ─── Per-pane field caches + extractors ───────────────────────────── function extractPromptText(hookPayload: Record): 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 = { 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, key: string): string | unde return typeof value === 'string' && value.length > 0 ? value : undefined } +function readFirstString( + record: Record, + 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 | 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) + : 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 + 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 + if (record.type === 'assistant.message') { + const data = record.data + if (typeof data === 'object' && data !== null) { + const text = extractAssistantContentText((data as Record).content) + if (text) { + return text + } + } + } const nestedMessage = record.message as Record | 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 = { + 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 +): 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): { + 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 + 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 +): 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 @@ -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 +): 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 - 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) // 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> '/hook/pi': 'pi', '/hook/droid': 'droid', '/hook/grok': 'grok', + '/hook/copilot': 'copilot', '/hook/hermes': 'hermes' }) diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index d47036ac2..81f5fd48b 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -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 diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 4e97994ac..da6221954 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -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] diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index ae9509ff3..26f01d483 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -15,6 +15,7 @@ export type WellKnownAgentType = | 'gemini' | 'opencode' | 'cursor' + | 'copilot' | 'aider' | 'pi' | 'droid'