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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should be able to get rid of this after the plugin refactor now, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

valid!! was just a little unsure. removed it :D

Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ export type AgentProviderDefinition = {
/** When true, the logo should be colour-inverted in dark mode. */
invertInDark?: boolean;
terminalOnly?: boolean;
supportsHooks?: boolean;
};

export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
Expand Down Expand Up @@ -122,7 +121,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'openai.svg',
alt: 'Codex',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'claude',
Expand All @@ -142,7 +140,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'claude.svg',
alt: 'Claude Code',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'grok',
Expand All @@ -164,7 +161,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
alt: 'Grok CLI',
invertInDark: true,
terminalOnly: true,
supportsHooks: true,
},
{
id: 'devin',
Expand All @@ -183,7 +179,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'devin.png',
alt: 'Devin',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'cursor',
Expand Down Expand Up @@ -219,7 +214,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'gemini.svg',
alt: 'Gemini CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'antigravity',
Expand Down Expand Up @@ -255,7 +249,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'qwen.svg',
alt: 'Qwen Code CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'droid',
Expand All @@ -274,7 +267,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'droid.svg',
alt: 'Factory Droid',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'amp',
Expand All @@ -295,7 +287,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'ampcode.svg',
alt: 'Amp CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'commandcode',
Expand All @@ -317,7 +308,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'commandcode.svg',
alt: 'Command Code CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'opencode',
Expand All @@ -339,7 +329,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
iconDark: 'opencode-dark.svg',
alt: 'OpenCode CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'hermes',
Expand Down Expand Up @@ -379,7 +368,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
alt: 'GitHub Copilot CLI',
invertInDark: true,
terminalOnly: true,
supportsHooks: true,
},
{
id: 'charm',
Expand Down Expand Up @@ -433,7 +421,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'goose.png',
alt: 'Goose CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'kimi',
Expand All @@ -454,7 +441,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'kimi.svg',
alt: 'Kimi CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'kilocode',
Expand All @@ -472,7 +458,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'kilocode.png',
alt: 'Kilocode CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'kiro',
Expand All @@ -494,7 +479,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'kiro.png',
alt: 'Kiro CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'rovo',
Expand Down Expand Up @@ -589,7 +573,6 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [
icon: 'mistral.svg',
alt: 'Mistral Vibe CLI',
terminalOnly: true,
supportsHooks: true,
},
{
id: 'jules',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const HOOK_EVENTS = [
'notification',
'stop',
'error',
'session',
'start',
'tool-use',
Expand Down
12 changes: 5 additions & 7 deletions packages/core/src/agents/plugins/capabilities/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import z from 'zod';
import { definePluginCapability } from '../../../lib/plugins/capability';
import type { PluginFs } from '../../runtime/fs';
import type { CanonicalHookEvent, HookRegistration } from './hooks-types';
import { HOOK_EVENTS, type CanonicalHookEvent, type HookRegistration } from './hooks-types';

export type { HookRegistration };
export type { CanonicalHookEvent, HookEvent, NotificationType } from './hooks-types';
Expand All @@ -20,6 +20,8 @@ export type IHooksBehavior = {
parseHookEvent?(eventType: string, body: Record<string, unknown>): CanonicalHookEvent;
};

const hookEventSchema = z.enum(HOOK_EVENTS);

/**
* hooksDescriptor is used to describe the hooks that an agent supports.
*
Expand All @@ -33,16 +35,12 @@ export const hooksCapability = definePluginCapability<IHooksBehavior>()(
z.object({
kind: z.literal('config'),
scope: z.enum(['global', 'workspace']),
supportedEvents: z.array(
z.enum(['notification', 'stop', 'session', 'start', 'tool-use', 'tool-use-failure'])
),
supportedEvents: z.array(hookEventSchema),
}),
z.object({
kind: z.literal('plugin'),
scope: z.enum(['global', 'workspace']),
supportedEvents: z.array(
z.enum(['notification', 'stop', 'session', 'start', 'tool-use', 'tool-use-failure'])
),
supportedEvents: z.array(hookEventSchema),
}),
z.object({ kind: z.literal('none') }),
]),
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/agents/plugins/helpers/parse-hook-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { defaultHookEventParser } from './parse-hook-event';

describe('defaultHookEventParser', () => {
it('maps tool-use events to working status events', () => {
expect(defaultHookEventParser('tool-use', { message: 'ran shell' })).toEqual({
kind: 'status',
type: 'start',
lastAssistantMessage: undefined,
title: undefined,
message: 'ran shell',
});
});

it('maps tool-use-failure events to error status events', () => {
expect(defaultHookEventParser('tool-use-failure', { title: 'Tool failed' })).toEqual({
kind: 'status',
type: 'error',
lastAssistantMessage: undefined,
title: 'Tool failed',
message: undefined,
});
});
});
9 changes: 7 additions & 2 deletions packages/core/src/agents/plugins/helpers/parse-hook-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function extractProviderSessionId(body: Record<string, unknown>): string
* Event-type routing:
* 'session' | 'session-start' → kind: 'session' (if a session id is present)
* 'start' | 'stop' | 'error' → kind: 'status' with the matching type
* 'tool-use' → kind: 'status', type: 'start'
* 'tool-use-failure' → kind: 'status', type: 'error'
* 'notification' → kind: 'status', type: 'notification', reads
* notification_type / notificationType from body
* everything else → kind: 'ignore'
Expand All @@ -50,11 +52,14 @@ export function defaultHookEventParser(
return { kind: 'ignore' };
}

if (eventType === 'start' || eventType === 'stop' || eventType === 'error') {
const statusType =
eventType === 'tool-use' ? 'start' : eventType === 'tool-use-failure' ? 'error' : eventType;

if (statusType === 'start' || statusType === 'stop' || statusType === 'error') {
const rawLam = body.last_assistant_message ?? body.lastAssistantMessage;
return {
kind: 'status',
type: eventType,
type: statusType,
lastAssistantMessage: typeof rawLam === 'string' ? rawLam : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
message: typeof body.message === 'string' ? body.message : undefined,
Expand Down
31 changes: 31 additions & 0 deletions packages/plugins/src/agents/impl/claude/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { buildClaudeHookConfig } from './hooks';

describe('buildClaudeHookConfig', () => {
it('uses explicit notification_type before message heuristics', () => {
const hooks = buildClaudeHookConfig();

expect(
hooks.parseHookEvent('notification', { notification_type: 'permission_prompt' })
).toEqual({
kind: 'status',
type: 'notification',
notificationType: 'permission_prompt',
lastAssistantMessage: undefined,
title: undefined,
message: undefined,
});
});

it('classifies Claude notification messages when no notification_type is present', () => {
const hooks = buildClaudeHookConfig();

expect(hooks.parseHookEvent('notification', { message: 'Claude needs approval' })).toEqual({
kind: 'status',
type: 'notification',
notificationType: 'permission_prompt',
message: 'Claude needs approval',
title: undefined,
});
});
});
13 changes: 11 additions & 2 deletions packages/plugins/src/agents/impl/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type { CanonicalHookEvent } from '@emdash/core/agents/plugins';
import {
buildNestedJsonHookConfig,
defaultHookEventParser,
makeNotificationHookCommand,
makeStdinHookCommand,
} from '@emdash/core/agents/plugins/helpers';

export const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json';

/**
* Claude's Notification events carry no `notification_type` field.
* Classify by examining the message text:
* Claude's Notification events usually carry no `notification_type` field.
* Classify those by examining the message text:
* /permission|approval/i → permission_prompt
* everything else → idle_prompt (agent waiting / done)
*/
Expand All @@ -18,6 +19,10 @@ function parseClaudeHookEvent(
body: Record<string, unknown>
): CanonicalHookEvent {
if (eventType === 'notification') {
if (body.notification_type || body.notificationType) {
return defaultHookEventParser(eventType, body);
}

const message = typeof body.message === 'string' ? body.message : '';
const notificationType = /permission|approval/i.test(message)
? ('permission_prompt' as const)
Expand All @@ -37,9 +42,13 @@ function parseClaudeHookEvent(
export function buildClaudeHookConfig() {
return {
...buildNestedJsonHookConfig(CLAUDE_SETTINGS_PATH, [
{ hookKey: 'SessionStart', command: makeStdinHookCommand('session') },
{ hookKey: 'UserPromptSubmit', command: makeStdinHookCommand('start') },
{ hookKey: 'PermissionRequest', command: makeNotificationHookCommand('permission_prompt') },
{ hookKey: 'Notification', command: makeStdinHookCommand('notification') },
{ hookKey: 'Stop', command: makeStdinHookCommand('stop') },
{ hookKey: 'StopFailure', command: makeStdinHookCommand('error') },
{ hookKey: 'SessionEnd', command: makeStdinHookCommand('stop') },
]),
parseHookEvent: parseClaudeHookEvent,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/src/agents/impl/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const plugin = definePlugin(
hooks: {
kind: 'config',
scope: 'workspace',
supportedEvents: ['start', 'notification', 'stop'],
supportedEvents: ['start', 'notification', 'stop', 'error', 'session'],
},
hostDependency: {
id: 'claude',
Expand Down
78 changes: 78 additions & 0 deletions packages/plugins/src/agents/impl/copilot/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { PluginFs } from '@emdash/core/agents/plugins';
import { EMDASH_MARKER } from '@emdash/core/agents/plugins/helpers';
import { describe, expect, it } from 'vitest';
import { COPILOT_HOOKS_PATH, buildCopilotHookConfig } from './hooks';

function createMemoryFs(initial: Record<string, string> = {}): PluginFs {
const files = new Map(Object.entries(initial));

return {
async read(path) {
return files.get(path) ?? null;
},
async write(path, content) {
files.set(path, content);
},
async delete(path) {
files.delete(path);
},
async exists(path) {
return files.has(path);
},
async list(path) {
return [...files.keys()].filter((file) => file.startsWith(path));
},
};
}

function copilotConfigWithHooks(hookKeys: string[]): string {
return JSON.stringify({
hooks: Object.fromEntries(
hookKeys.map((hookKey) => [
hookKey,
[
{
type: 'command',
command: 'curl http://127.0.0.1:$EMDASH_HOOK_PORT/hook',
},
],
])
),
});
}

describe('buildCopilotHookConfig', () => {
it('does not treat partial managed hook installs as installed', async () => {
const fs = createMemoryFs({
[COPILOT_HOOKS_PATH]: copilotConfigWithHooks([
'agentStop',
'sessionStart',
'permissionRequest',
]),
});
const hooks = buildCopilotHookConfig();

await expect(hooks.getHooksInstalled(fs)).resolves.toBe(false);
await expect(hooks.readHooks(fs)).resolves.toEqual([]);
});

it('treats a complete managed hook install as installed', async () => {
const fs = createMemoryFs({
[COPILOT_HOOKS_PATH]: copilotConfigWithHooks([
'agentStop',
'sessionEnd',
'sessionStart',
'userPromptSubmitted',
'errorOccurred',
'notification',
'permissionRequest',
]),
});
const hooks = buildCopilotHookConfig();

await expect(hooks.getHooksInstalled(fs)).resolves.toBe(true);
await expect(hooks.readHooks(fs)).resolves.toEqual([
{ event: 'emdash', command: EMDASH_MARKER },
]);
});
});
Loading
Loading