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
8 changes: 7 additions & 1 deletion src/main/core/agent-hooks/agent-notify-command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, win32 } from 'node:path';
import type { AgentEventType } from '@shared/events/agentEvents';
import { log } from '@main/lib/logger';
import ampPluginContent from './amp-notifications-plugin.js?raw';
import openCodePluginContent from './opencode-notifications-plugin.js?raw';

export type CodexNotifyCommandOptions = {
Expand All @@ -13,7 +15,7 @@ export type CodexNotifyCommandOptions = {

const ensuredWindowsCodexNotifyScriptPaths = new Set<string>();

export function makeClaudeHookCommand(eventType: string): string {
export function makeClaudeHookCommand(eventType: AgentEventType): string {
return (
'curl -sf -X POST ' +
'-H "Content-Type: application/json" ' +
Expand All @@ -29,6 +31,10 @@ export function makeOpenCodePluginContent(): string {
return openCodePluginContent;
}

export function makeAmpPluginContent(): string {
return ampPluginContent;
}

function makePosixCodexNotifyCommand(): string[] {
return [
'bash',
Expand Down
39 changes: 39 additions & 0 deletions src/main/core/agent-hooks/amp-notifications-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
/* global fetch, process */

export default function EmdashAmpNotifications(amp) {
const port = process.env.EMDASH_HOOK_PORT;
const token = process.env.EMDASH_HOOK_TOKEN;
const ptyId = process.env.EMDASH_PTY_ID;
if (!port || !token || !ptyId) return;

const post = async (eventType, body) => {
try {
await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emdash-Token': token,
'X-Emdash-Pty-Id': ptyId,
'X-Emdash-Event-Type': eventType,
},
body: JSON.stringify(body ?? {}),
});
} catch {
// Hook delivery is best-effort and must never interrupt Amp.
}
};

amp.on('agent.start', async (event) => {
await post('working', {
message: typeof event.message === 'string' ? event.message : undefined,
});
return {};
});

amp.on('agent.end', async (event) => {
await post(event.status === 'error' ? 'error' : 'stop', {
message: typeof event.message === 'string' ? event.message : undefined,
});
});
Comment on lines +34 to +38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The agent.end handler does not return {}, unlike agent.start. If Amp's plugin API uses the return value for event flow control (as the agent.start pattern suggests), omitting it here could cause agent.end to behave differently or be ignored in future Amp versions. The test suite only asserts that return {}; exists somewhere in the plugin string, not that it is present in this specific handler.

Suggested change
amp.on('agent.end', async (event) => {
await post(event.status === 'error' ? 'error' : 'stop', {
message: typeof event.message === 'string' ? event.message : undefined,
});
});
amp.on('agent.end', async (event) => {
await post(event.status === 'error' ? 'error' : 'stop', {
message: typeof event.message === 'string' ? event.message : undefined,
});
return {};
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/core/agent-hooks/amp-notifications-plugin.js
Line: 34-38

Comment:
The `agent.end` handler does not return `{}`, unlike `agent.start`. If Amp's plugin API uses the return value for event flow control (as the `agent.start` pattern suggests), omitting it here could cause `agent.end` to behave differently or be ignored in future Amp versions. The test suite only asserts that `return {};` exists somewhere in the plugin string, not that it is present in this specific handler.

```suggestion
  amp.on('agent.end', async (event) => {
    await post(event.status === 'error' ? 'error' : 'stop', {
      message: typeof event.message === 'string' ? event.message : undefined,
    });
    return {};
  });
```

How can I resolve this? If you propose a fix, please make it concise.

}
8 changes: 6 additions & 2 deletions src/main/core/agent-hooks/event-enricher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { db } from '@main/db/client';
import { conversations } from '@main/db/schema';
import type { RawHookRequest } from './hook-server';

function readString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function normalizePayload(
providerId: string,
body: Record<string, unknown>
Expand All @@ -15,8 +19,8 @@ function normalizePayload(
lastAssistantMessage: (body.last_assistant_message ?? body.lastAssistantMessage) as
| string
| undefined,
title: body.title as string | undefined,
message: body.message as string | undefined,
title: readString(body.title),
message: readString(body.message) ?? readString(body.error_type),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using error_type as a fallback for message may surface a raw technical identifier (e.g. "network_error", "timeout") as user-facing text in places that render payload.message. If error_type is an enum value rather than a human-readable string, consider only falling back to it in a dedicated field (e.g. errorType) rather than aliasing it onto message.

Suggested change
message: readString(body.message) ?? readString(body.error_type),
message: readString(body.message),
errorType: readString(body.error_type),
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/core/agent-hooks/event-enricher.ts
Line: 23

Comment:
Using `error_type` as a fallback for `message` may surface a raw technical identifier (e.g. `"network_error"`, `"timeout"`) as user-facing text in places that render `payload.message`. If `error_type` is an enum value rather than a human-readable string, consider only falling back to it in a dedicated field (e.g. `errorType`) rather than aliasing it onto `message`.

```suggestion
    message: readString(body.message),
    errorType: readString(body.error_type),
```

How can I resolve this? If you propose a fix, please make it concise.

};

if (!payload.notificationType && providerId === 'codex' && body.type === 'agent-turn-complete') {
Expand Down
67 changes: 67 additions & 0 deletions src/main/core/agent-hooks/hook-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function makeWriter(fs: MemoryFs): HookConfigWriter {
return new HookConfigWriter(fs, makeExecutionContext());
}

function readRequiredFile(fs: MemoryFs, path: string): string {
const content = fs.files.get(path);
if (content === undefined) throw new Error(`Expected ${path} to be written`);
return content;
}

describe('HookConfigWriter', () => {
beforeEach(() => {
mockResolveCommandPath.mockReset();
Expand Down Expand Up @@ -100,4 +106,65 @@ describe('HookConfigWriter', () => {
expect(fs.files.has('.opencode/plugins/emdash-notifications.js')).toBe(false);
expect(fs.files.has('.gitignore')).toBe(false);
});

it('writes Claude hooks for Notification, Stop, UserPromptSubmit, and StopFailure', async () => {
mockResolveCommandPath.mockResolvedValue('/usr/local/bin/claude');
const fs = new MemoryFs();
const writer = makeWriter(fs);

await writer.writeForProvider('claude');

const settings = JSON.parse(readRequiredFile(fs, '.claude/settings.local.json')) as {
hooks: Record<string, unknown>;
};
expect(Object.keys(settings.hooks).sort()).toEqual([
'Notification',
'Stop',
'StopFailure',
'UserPromptSubmit',
]);
for (const key of ['Notification', 'Stop', 'UserPromptSubmit', 'StopFailure']) {
expect(JSON.stringify(settings.hooks[key])).toContain('EMDASH_HOOK_PORT');
}
});

it('skips Claude hooks when claude is unavailable', async () => {
mockResolveCommandPath.mockResolvedValue(undefined);
const fs = new MemoryFs();
const writer = makeWriter(fs);

await writer.writeForProvider('claude');

expect(fs.files.has('.claude/settings.local.json')).toBe(false);
expect(fs.files.has('.gitignore')).toBe(false);
});

it('writes the Amp notifications plugin and ignores it in git', async () => {
mockResolveCommandPath.mockResolvedValue('/usr/local/bin/amp');
const fs = new MemoryFs();
const writer = makeWriter(fs);

await writer.writeForProvider('amp');

const plugin = readRequiredFile(fs, '.amp/plugins/emdash-notifications.js');

expect(plugin).toContain('EMDASH_HOOK_PORT');
expect(plugin).toContain('@i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now');
expect(plugin).toContain("amp.on('agent.start'");
expect(plugin).toContain("amp.on('agent.end'");
expect(plugin).toContain("event.status === 'error' ? 'error' : 'stop'");
expect(plugin).toContain('return {};');
expect(fs.files.get('.gitignore')).toBe('.amp/plugins/emdash-notifications.js\n');
});

it('skips the Amp plugin when amp is unavailable', async () => {
mockResolveCommandPath.mockResolvedValue(undefined);
const fs = new MemoryFs();
const writer = makeWriter(fs);

await writer.writeForProvider('amp');

expect(fs.files.has('.amp/plugins/emdash-notifications.js')).toBe(false);
expect(fs.files.has('.gitignore')).toBe(false);
});
});
47 changes: 31 additions & 16 deletions src/main/core/agent-hooks/hook-config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as toml from 'smol-toml';
import type { AgentProviderId } from '@shared/agent-provider-registry';
import type { AgentEventType } from '@shared/events/agentEvents';
import { resolveCommandPath } from '@main/core/dependencies/probe';
import type { IExecutionContext } from '@main/core/execution-context/types';
import type { FileSystemProvider } from '@main/core/fs/types';
import { log } from '@main/lib/logger';
import {
makeAmpPluginContent,
makeClaudeHookCommand,
makeCodexNotifyCommand,
makeOpenCodePluginContent,
Expand All @@ -17,13 +19,16 @@ 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 AMP_PLUGIN_PATH = '.amp/plugins/emdash-notifications.js';
const GITIGNORE_PATH = '.gitignore';
type HookConfigWriteOptions = { writeGitIgnoreEntries?: boolean };

const HOOK_EVENT_MAP = [
{ eventType: 'notification', hookKey: 'Notification' },
{ eventType: 'stop', hookKey: 'Stop' },
] satisfies { eventType: string; hookKey: string }[];
{ eventType: 'working', hookKey: 'UserPromptSubmit' },
{ eventType: 'error', hookKey: 'StopFailure' },
] satisfies { eventType: AgentEventType; hookKey: string }[];

export class HookConfigWriter {
constructor(
Expand Down Expand Up @@ -68,29 +73,31 @@ export class HookConfigWriter {
}

async writePiExtension(): Promise<boolean> {
if (!(await resolveCommandPath('pi', this.exec))) return false;
return this.writeStaticHookFile('pi', PI_EMDASH_EXTENSION_PATH, piEmdashExtension);
}

const existing = await this.fs
.read(PI_EMDASH_EXTENSION_PATH)
.then((r) => r.content)
.catch(() => undefined);
if (existing === piEmdashExtension) return true;
async writeOpenCodePlugin(): Promise<boolean> {
return this.writeStaticHookFile('opencode', OPENCODE_PLUGIN_PATH, makeOpenCodePluginContent());
}

await this.fs.write(PI_EMDASH_EXTENSION_PATH, piEmdashExtension);
return true;
async writeAmpPlugin(): Promise<boolean> {
return this.writeStaticHookFile('amp', AMP_PLUGIN_PATH, makeAmpPluginContent());
}

async writeOpenCodePlugin(): Promise<boolean> {
if (!(await resolveCommandPath('opencode', this.exec))) return false;
private async writeStaticHookFile(
command: string,
path: string,
content: string
): Promise<boolean> {
if (!(await resolveCommandPath(command, this.exec))) return false;

const pluginContent = makeOpenCodePluginContent();
const existing = await this.fs
.read(OPENCODE_PLUGIN_PATH)
.read(path)
.then((r) => r.content)
.catch(() => undefined);
if (existing === pluginContent) return true;
if (existing === content) return true;

await this.fs.write(OPENCODE_PLUGIN_PATH, pluginContent);
await this.fs.write(path, content);
return true;
}

Expand Down Expand Up @@ -131,11 +138,19 @@ export class HookConfigWriter {
}
return;
}

if (providerId === 'amp') {
const wroteConfig = await this.writeAmpPlugin();
if (wroteConfig && writeGitIgnoreEntries) {
await this.ensureGitIgnoreEntries([AMP_PLUGIN_PATH]);
}
return;
}
}

async writeAll(options: HookConfigWriteOptions = {}): Promise<void> {
await Promise.all(
(['claude', 'codex', 'pi', 'opencode'] as const).map((providerId) =>
(['claude', 'codex', 'pi', 'opencode', 'amp'] as const).map((providerId) =>
this.writeForProvider(providerId, options).catch((err: Error) => {
log.warn(`Failed to write ${providerId} hook config`, { error: String(err) });
})
Expand Down
4 changes: 2 additions & 2 deletions src/main/core/conversations/impl/local-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export class LocalConversationProvider implements ConversationProvider {
const ptyId = makePtyId(conversation.providerId, conversation.id);
const port = agentHookService.getPort();
const token = agentHookService.getToken();
const provider = getProvider(conversation.providerId);
const pty = spawnLocalPty({
id: sessionId,
command: resolved.command,
Expand All @@ -132,7 +133,7 @@ export class LocalConversationProvider implements ConversationProvider {
env: {
...buildAgentEnv({
hook: port > 0 ? { port, ptyId, token } : undefined,
providerVars: providerEnv,
providerVars: { ...provider?.runtimeEnv, ...providerEnv },
}),
...this.taskEnvVars,
},
Expand All @@ -141,7 +142,6 @@ export class LocalConversationProvider implements ConversationProvider {
});

const hookActive = port > 0;
const provider = getProvider(conversation.providerId);
const useHooksOnly = hookActive && provider?.supportsHooks;

if (!useHooksOnly) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class ConversationManagerStore {
if (event.taskId !== this.taskId) return;
const conversationStore = this.conversations.get(event.conversationId);
if (!conversationStore) return;
if (event.type === 'working') {
conversationStore.setWorking();
return;
}
if (event.type === 'notification') {
const nt = event.payload.notificationType;
if (!isAttentionNotification(nt)) return;
Expand Down
4 changes: 4 additions & 0 deletions src/shared/agent-provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type AgentProviderDefinition = {
invertInDark?: boolean;
terminalOnly?: boolean;
supportsHooks?: boolean;
/** Extra env vars injected when this provider is launched. */
runtimeEnv?: Record<string, string>;
};

export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
Expand Down Expand Up @@ -206,6 +208,8 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'ampcode.png',
alt: 'Amp CLI',
terminalOnly: true,
supportsHooks: true,
runtimeEnv: { PLUGINS: 'all' },
Comment on lines +211 to +212
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Broad PLUGINS env var may load unintended plugins

PLUGINS: 'all' is injected into every Amp session. If Amp's plugin system interprets this as "load all discovered plugins" (beyond just those in .amp/plugins/), users who have other Amp plugins installed could see them activated unexpectedly when running Amp through Emdash. Since the plugin API is explicitly marked as WIP and experimental, it's worth confirming that all is scoped only to the project-local .amp/plugins/ directory before shipping.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/shared/agent-provider-registry.ts
Line: 211-212

Comment:
**Broad `PLUGINS` env var may load unintended plugins**

`PLUGINS: 'all'` is injected into every Amp session. If Amp's plugin system interprets this as "load all discovered plugins" (beyond just those in `.amp/plugins/`), users who have other Amp plugins installed could see them activated unexpectedly when running Amp through Emdash. Since the plugin API is explicitly marked as WIP and experimental, it's worth confirming that `all` is scoped only to the project-local `.amp/plugins/` directory before shipping.

How can I resolve this? If you propose a fix, please make it concise.

},
{
id: 'opencode',
Expand Down
2 changes: 1 addition & 1 deletion src/shared/events/agentEvents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineEvent } from '@shared/ipc/events';

export type AgentEventType = 'notification' | 'stop' | 'error';
export type AgentEventType = 'notification' | 'stop' | 'error' | 'working';

export type NotificationType =
| 'permission_prompt'
Expand Down
Loading