Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/integrations/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 19 additions & 14 deletions src/main/core/agent-hooks/hook-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
16 changes: 8 additions & 8 deletions src/main/core/agent-hooks/hook-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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<boolean> {
Expand Down Expand Up @@ -83,14 +85,15 @@ export class HookConfigWriter {
async writeOpenCodePlugin(): Promise<boolean> {
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;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
15 changes: 14 additions & 1 deletion src/main/core/conversations/impl/local-conversation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, Pty>();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
Expand Down