diff --git a/agents/integrations/providers.md b/agents/integrations/providers.md index e1ec4e10b..b851e2313 100644 --- a/agents/integrations/providers.md +++ b/agents/integrations/providers.md @@ -29,7 +29,7 @@ Each provider has a terminal output classifier in `src/main/core/conversations/i - Claude uses deterministic `--session-id` values for conversation isolation. - Agents with no CLI prompt flag (e.g., Amp, OpenCode) use keystroke injection — Emdash types the prompt into the TUI after startup. -- `src/main/core/agent-hooks/service.ts` forwards hook events to renderer windows and can show OS notifications. Also writes hook config files (`.claude/settings.local.json`, `.codex/config.toml`) into worktrees. +- `src/main/core/agent-hooks/agent-hook-service.ts` forwards hook events to renderer windows and can show OS notifications. Hook config for Claude, Codex, and Pi is written into worktrees; OpenCode uses an Emdash-owned `OPENCODE_CONFIG_DIR` outside the repository. ## Adding Or Changing A Provider diff --git a/src/main/core/agent-hooks/hook-config.test.ts b/src/main/core/agent-hooks/hook-config.test.ts index f3ca40cca..881603765 100644 --- a/src/main/core/agent-hooks/hook-config.test.ts +++ b/src/main/core/agent-hooks/hook-config.test.ts @@ -18,8 +18,8 @@ function makeExecutionContext(): IExecutionContext { }; } -function makeWriter(fs: MemoryFs): HookConfigWriter { - return new HookConfigWriter(fs, makeExecutionContext()); +function makeWriter(fs: MemoryFs, options?: { openCodeConfigFs?: MemoryFs }): HookConfigWriter { + return new HookConfigWriter(fs, makeExecutionContext(), options); } describe('HookConfigWriter', () => { @@ -63,41 +63,46 @@ describe('HookConfigWriter', () => { expect(fs.files.has('.gitignore')).toBe(false); }); - it('writes the OpenCode notifications plugin and ignores it in git', async () => { + it('writes the OpenCode notifications plugin outside the project', async () => { mockResolveCommandPath.mockResolvedValue('/usr/local/bin/opencode'); - const fs = new MemoryFs(); - const writer = makeWriter(fs); + const projectFs = new MemoryFs(); + const openCodeConfigFs = new MemoryFs(); + const writer = makeWriter(projectFs, { openCodeConfigFs }); await writer.writeForProvider('opencode'); - expect(fs.files.get('.opencode/plugins/emdash-notifications.js')).toContain( + expect(openCodeConfigFs.files.get('plugins/emdash-notifications.js')).toContain( 'EmdashNotifications' ); - expect(fs.files.get('.opencode/plugins/emdash-notifications.js')).toContain( + expect(openCodeConfigFs.files.get('plugins/emdash-notifications.js')).toContain( "event.type === 'session.idle'" ); - expect(fs.files.get('.gitignore')).toBe('.opencode/plugins/emdash-notifications.js\n'); + expect(projectFs.files.has('.opencode/plugins/emdash-notifications.js')).toBe(false); + expect(projectFs.files.has('.gitignore')).toBe(false); }); - it('does not duplicate the OpenCode gitignore entry', async () => { + it('does not write OpenCode gitignore entries', async () => { mockResolveCommandPath.mockResolvedValue('/usr/local/bin/opencode'); const fs = new MemoryFs(); - fs.files.set('.gitignore', '.opencode/plugins/emdash-notifications.js\n'); - const writer = makeWriter(fs); + const openCodeConfigFs = new MemoryFs(); + const writer = makeWriter(fs, { openCodeConfigFs }); await writer.writeForProvider('opencode'); - expect(fs.files.get('.gitignore')).toBe('.opencode/plugins/emdash-notifications.js\n'); + expect(openCodeConfigFs.files.has('plugins/emdash-notifications.js')).toBe(true); + expect(fs.files.has('.gitignore')).toBe(false); }); it('skips the OpenCode plugin when opencode is unavailable', async () => { mockResolveCommandPath.mockResolvedValue(undefined); const fs = new MemoryFs(); - const writer = makeWriter(fs); + const openCodeConfigFs = new MemoryFs(); + const writer = makeWriter(fs, { openCodeConfigFs }); await writer.writeForProvider('opencode'); - expect(fs.files.has('.opencode/plugins/emdash-notifications.js')).toBe(false); + expect(fs.files.has('plugins/emdash-notifications.js')).toBe(false); + expect(openCodeConfigFs.files.has('plugins/emdash-notifications.js')).toBe(false); expect(fs.files.has('.gitignore')).toBe(false); }); }); diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index a835e82dd..717cf3237 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -16,9 +16,10 @@ const EMDASH_MARKER = 'EMDASH_HOOK_PORT'; const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json'; const CODEX_CONFIG_PATH = '.codex/config.toml'; const PI_EMDASH_EXTENSION_PATH = '.pi/extensions/emdash-hook.ts'; -const OPENCODE_PLUGIN_PATH = '.opencode/plugins/emdash-notifications.js'; +const OPENCODE_PLUGIN_PATH = 'plugins/emdash-notifications.js'; const GITIGNORE_PATH = '.gitignore'; type HookConfigWriteOptions = { writeGitIgnoreEntries?: boolean }; +type HookConfigWriterOptions = { openCodeConfigFs?: FileSystemProvider }; const HOOK_EVENT_MAP = [ { eventType: 'notification', hookKey: 'Notification' }, @@ -28,7 +29,8 @@ const HOOK_EVENT_MAP = [ export class HookConfigWriter { constructor( private readonly fs: FileSystemProvider, - private readonly exec: IExecutionContext + private readonly exec: IExecutionContext, + private readonly options: HookConfigWriterOptions = {} ) {} async writeClaudeHooks(): Promise { @@ -83,14 +85,15 @@ export class HookConfigWriter { async writeOpenCodePlugin(): Promise { if (!(await resolveCommandPath('opencode', this.exec))) return false; + const fs = this.options.openCodeConfigFs ?? this.fs; const pluginContent = makeOpenCodePluginContent(); - const existing = await this.fs + const existing = await fs .read(OPENCODE_PLUGIN_PATH) .then((r) => r.content) .catch(() => undefined); if (existing === pluginContent) return true; - await this.fs.write(OPENCODE_PLUGIN_PATH, pluginContent); + await fs.write(OPENCODE_PLUGIN_PATH, pluginContent); return true; } @@ -125,10 +128,7 @@ export class HookConfigWriter { } if (providerId === 'opencode') { - const wroteConfig = await this.writeOpenCodePlugin(); - if (wroteConfig && writeGitIgnoreEntries) { - await this.ensureGitIgnoreEntries([OPENCODE_PLUGIN_PATH]); - } + await this.writeOpenCodePlugin(); return; } } diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index a0cec12e5..1974984de 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -1,4 +1,6 @@ import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { app } from 'electron'; import { getProvider } from '@shared/agent-provider-registry'; import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; @@ -26,6 +28,11 @@ import { buildAgentCommand } from './agent-command'; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; const MAX_RESPAWNS = 2; +const OPENCODE_EMDASH_CONFIG_DIR = 'opencode'; + +function getOpenCodeConfigDir(): string { + return join(app.getPath('userData'), OPENCODE_EMDASH_CONFIG_DIR); +} export class LocalConversationProvider implements ConversationProvider { private sessions = new Map(); @@ -65,7 +72,9 @@ export class LocalConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.ctx = ctx; this.taskEnvVars = taskEnvVars; - this.hookConfigWriter = new HookConfigWriter(new LocalFileSystem(taskPath), ctx); + this.hookConfigWriter = new HookConfigWriter(new LocalFileSystem(taskPath), ctx, { + openCodeConfigFs: new LocalFileSystem(getOpenCodeConfigDir()), + }); } async startSession( @@ -127,6 +136,10 @@ export class LocalConversationProvider implements ConversationProvider { env: { ...buildAgentEnv({ hook: port > 0 ? { port, ptyId, token } : undefined, + customVars: + conversation.providerId === 'opencode' + ? { OPENCODE_CONFIG_DIR: getOpenCodeConfigDir() } + : undefined, }), ...this.taskEnvVars, },