Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/claude-hook-manifest-no-pointer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Stop emitting a `hooks` pointer in `.claude-plugin/plugin.json` for the `claude` and unified `plugin` targets: Claude Code loads `hooks/hooks.json` on its own and reports a manifest pointer at that same file as a duplicate hooks file (`hook-load-failed`, observed on Claude Code 2.1.259). The generated Claude wrappers keep comparing `hook_event_name` against the pinned PascalCase spellings (`PreToolUse`, `PostToolUse`, `Stop`, ... for every supported Claude event), now covered by a per-event regression test; `native hook_event_name must equal postToolUse` on a Claude session identifies a Cursor-built wrapper under the Claude plugin root. (#470)
5 changes: 0 additions & 5 deletions .changeset/claude-hook-manifest-pointer.md

This file was deleted.

7 changes: 6 additions & 1 deletion packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3155,13 +3155,18 @@ export const planClaudeArtifacts = (
const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document);
const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument);

// Claude Code loads the conventional `hooks/hooks.json` on its own; the
// manifest `hooks` field is only for *additional* documents. Naming the
// conventional file there makes Claude Code (2.1.259 observed) record a
// `hook-load-failed` plugin error, "Duplicate hooks file detected ... The
// standard hooks/hooks.json is loaded automatically, so manifest.hooks
// should only reference additional hook files", so no pointer is emitted.
const plugin = {
author: { name: model.metadata.name },
...manifestMetadata.document,
...(channels.document === undefined ? {} : { channels: channels.document }),
...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }),
description: model.metadata.description ?? model.metadata.name,
...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }),
name: model.metadata.name,
...(userConfig.document === undefined ? {} : { userConfig: userConfig.document }),
version: model.metadata.version,
Expand Down
49 changes: 12 additions & 37 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,17 @@ const pluginName = 'plugin';
* convention, so the Claude document owns that slot; Codex's manifest carries
* explicit pointers, so its MCP document relocates under `.codex-plugin/`.
*
* Hooks ship once: Codex documents discovering `hooks/hooks.json` at the
* plugin root, exporting `CLAUDE_PLUGIN_ROOT` into hook processes as a
* compatibility alias and running commands through a real shell, and its
* hook envelope and output contract match Claude's — so one Claude-format
* hook document plus one runtime-host-detecting wrapper per hook serves
* both hosts. Claude Code's `.claude-plugin/plugin.json` names that same
* `./hooks/hooks.json` so the host does not also load `hooks/hooks-cursor.json`
* (Cursor's camelCase `hook_event_name` values) from the shared `hooks/`
* directory. Per-host `nativeHooks` passthrough stays with the host targets.
* Hooks ship once: both hosts document discovering `hooks/hooks.json` at the
* plugin root, Codex documents exporting `CLAUDE_PLUGIN_ROOT` into hook
* processes as a compatibility alias and running commands through a real
* shell, and its hook envelope and output contract match Claude's - so one
* Claude-format hook document plus one runtime-host-detecting wrapper per
* hook serves both hosts. Claude Code loads exactly that conventional file
* and never scans `hooks/` for other documents, so `hooks/hooks-cursor.json`
* is invisible to it; the Claude manifest therefore carries no `hooks`
* pointer (naming the conventional file again is reported by Claude Code as
* a duplicate hooks file, see the Claude adapter). Per-host `nativeHooks`
* passthrough stays with the host targets.
*
* The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared
* `skills/` as-is, the conventional root `mcp.json`, and - because
Expand Down Expand Up @@ -363,7 +365,7 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions)
'- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.',
]
: []),
'- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex; named by `.claude-plugin/plugin.json`), plus `hooks-cursor.json` with per-hook Cursor wrappers (`<name>.cursor.mjs`).',
'- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`<name>.cursor.mjs`).',
'- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.',
'- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.',
'',
Expand Down Expand Up @@ -421,32 +423,6 @@ const mergeEntries = (
return [...merged.values()];
};

/**
* The Claude half is planned hook-free so this adapter can emit one shared
* `hooks/hooks.json`. Stamp the Claude manifest with that path so Claude
* Code loads it instead of also discovering `hooks/hooks-cursor.json`.
*/
const attachClaudeHookManifest = (
entries: TargetArtifactEntry[],
hookSourceInputs: readonly string[],
): void => {
const index = entries.findIndex((entry) => entry.relativePath === claudeArtifactPaths.plugin);
if (index === -1) return;
const existing = entries[index]!;
if (existing.kind !== 'write') {
throw new Error('Agent plugin bundle Claude plugin.json must be a generated write entry.');
}
const parsed: unknown = JSON.parse(existing.content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Agent plugin bundle Claude plugin.json must be a JSON object.');
}
entries[index] = Object.freeze({
...existing,
content: `${stableJson({ ...parsed, hooks: `./${bundleHookContract.manifestPath}` })}\n`,
sourceInputs: sourceInputs(...existing.sourceInputs, ...hookSourceInputs),
});
};

const cursorMcpPlanContext = Object.freeze({ codePrefix: 'plugin.cursor', errorDiagnostic });

const cursorBundleHookContract = createCursorHookContract({
Expand Down Expand Up @@ -497,7 +473,6 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
const hookSourceInputs = model.hooks
.filter((hook) => hook.targets.includes(pluginName))
.map((hook) => hook.provenance.sourcePath);
attachClaudeHookManifest(entries, hookSourceInputs);
entries.push({
content: `${stableJson(hookDocument)}\n`,
kind: 'write',
Expand Down
251 changes: 251 additions & 0 deletions packages/agent-bundle/tests/claude-hook-event-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { readFile } from 'node:fs/promises';

import { expect, it } from '@rstest/core';

import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' };
import { claudeAdapter } from '../src/adapters/claude.ts';
import { pluginAdapter } from '../src/adapters/plugin.ts';
import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts';
import { validateNativeEventEnvelope } from '../src/events/projection.ts';
import type { CanonicalAgentEvent } from '../src/routes/public.ts';

const configPath = '/workspace/agent-bundle.config.ts';

/**
* Every event route the pinned Claude capability table supports, paired with
* the hook identity `normalizeProject` assigns it and a native envelope of the
* shape Claude Code writes to the wrapper's stdin. Fixture-backed rows reuse
* the documented envelopes under `fixtures/events/`; the four inline rows are
* the events Claude Code fires on every session (the maintainer's live
* session below reproduced exactly these).
*/
const claudeEventRoutes: readonly {
readonly hookEvent: NormalizedHookEvent;
readonly native: string | Readonly<Record<string, unknown>>;
readonly route: CanonicalAgentEvent;
}[] = [
{ hookEvent: 'agentIdle', native: 'claude-teammate-idle.json', route: 'agent/idle' },
{ hookEvent: 'agentStart', native: 'claude-subagent-start.json', route: 'agent/start' },
{ hookEvent: 'agentStop', native: 'claude-subagent-stop.json', route: 'agent/stop' },
{ hookEvent: 'compactAfter', native: 'claude-post-compact.json', route: 'compact/after' },
{ hookEvent: 'compactBefore', native: 'claude-pre-compact.json', route: 'compact/before' },
{ hookEvent: 'configChange', native: 'claude-config-change.json', route: 'config/change' },
{ hookEvent: 'fileChange', native: 'claude-file-changed.json', route: 'file/change' },
{ hookEvent: 'permissionDenied', native: 'claude-permission-denied.json', route: 'permission/denied' },
{ hookEvent: 'permissionRequest', native: 'claude-permission-request.json', route: 'permission/request' },
{ hookEvent: 'promptSubmit', native: 'claude-user-prompt-submit.json', route: 'prompt/submit' },
{ hookEvent: 'sessionEnd', native: 'claude-session-end.json', route: 'session/end' },
{
hookEvent: 'sessionStart',
native: {
cwd: '/workspace',
hook_event_name: 'SessionStart',
session_id: 'session-claude-1',
source: 'startup',
transcript_path: '/workspace/.claude/projects/session.jsonl',
},
route: 'session/start',
},
{
hookEvent: 'stop',
native: {
cwd: '/workspace',
hook_event_name: 'Stop',
last_assistant_message: 'Done.',
permission_mode: 'default',
session_id: 'session-claude-1',
stop_hook_active: false,
transcript_path: '/workspace/.claude/projects/session.jsonl',
},
route: 'stop',
},
{ hookEvent: 'stopFailure', native: 'claude-stop-failure.json', route: 'stop/failure' },
{ hookEvent: 'taskComplete', native: 'claude-task-completed.json', route: 'task/complete' },
{ hookEvent: 'taskCreate', native: 'claude-task-created.json', route: 'task/create' },
{
hookEvent: 'afterTool',
native: {
cwd: '/workspace',
hook_event_name: 'PostToolUse',
permission_mode: 'bypassPermissions',
session_id: 'session-claude-1',
tool_input: { command: 'git status --short', description: 'Show the working tree' },
tool_name: 'Bash',
tool_response: { interrupted: false, isImage: false, stderr: '', stdout: ' M README.md\n' },
tool_use_id: 'toolu_01LvwxiKhvU7wJ1Hf2MUJ2hu',
transcript_path: '/workspace/.claude/projects/session.jsonl',
},
route: 'tool/after',
},
{
hookEvent: 'beforeTool',
native: {
cwd: '/workspace',
hook_event_name: 'PreToolUse',
permission_mode: 'bypassPermissions',
session_id: 'session-claude-1',
tool_input: { command: 'git status --short', description: 'Show the working tree' },
tool_name: 'Bash',
tool_use_id: 'toolu_01Taws9XLqrL8XQk4BsTkjps',
transcript_path: '/workspace/.claude/projects/session.jsonl',
},
route: 'tool/before',
},
{ hookEvent: 'toolFailure', native: 'claude-post-tool-use-failure.json', route: 'tool/failure' },
];

const pinnedClaudeRoutes: Readonly<Record<string, { readonly nativeEvent?: string; readonly state: string }>> =
claudeCapabilityTable.hooks.eventRoutes;

const supportedClaudeRoutes = Object.entries(pinnedClaudeRoutes)
.filter(([, capability]) => capability.state === 'supported')
.map(([route]) => route)
.sort();

const routeHook = (
route: CanonicalAgentEvent,
hookEvent: NormalizedHookEvent,
targets: readonly string[],
): NormalizedHook => {
const name = `event-route-${route.replace('/', '-')}`;
return {
event: hookEvent,
eventRoute: { event: route, fallback: 'none', runtime: 'shared' },
id: `hook:${name}`,
name,
provenance: { kind: 'conventional', sourcePath: `/workspace/src/events/${route}.tsx` },
source: `/workspace/src/events/${route}.tsx`,
targets,
tools: [],
};
};

const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({
extensions: {},
hooks,
mcpServers: [],
metadata: {
description: 'Claude hook_event_name regression.',
id: 'plugin:hook-event-name',
name: 'hook-event-name',
provenance: { kind: 'config', sourcePath: configPath },
version: '1.0.0',
},
runtime: { node: '22.12.0' },
scripts: [],
skills: [],
targets: [{
id: `target:${target}`,
name: target,
provenance: { kind: 'config', sourcePath: configPath },
}],
});

const nativeEnvelope = async (
native: string | Readonly<Record<string, unknown>>,
): Promise<Readonly<Record<string, unknown>>> =>
typeof native === 'string'
? JSON.parse(await readFile(new URL(`./fixtures/events/${native}`, import.meta.url), 'utf8')) as Record<string, unknown>
: native;

const writes = (plan: { readonly entries: readonly { readonly kind: string; readonly relativePath: string; readonly content?: string }[] }) =>
Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content!]] : []));

it('covers every event route the pinned Claude capability table supports', () => {
expect(claudeEventRoutes.map((entry) => entry.route).sort()).toEqual(supportedClaudeRoutes);
});

it('bakes the pinned Claude hook_event_name into every Claude event-route wrapper and accepts the native envelope', async () => {
const hooks = claudeEventRoutes.map((entry) => routeHook(entry.route, entry.hookEvent, ['claude']));
const plan = claudeAdapter.plan(model('claude', hooks));
expect(plan.diagnostics).toEqual([]);

const document = JSON.parse(writes(plan)['hooks/hooks.json']!) as { readonly hooks: Record<string, unknown> };
for (const entry of claudeEventRoutes) {
const expectedNativeEvent = pinnedClaudeRoutes[entry.route]?.nativeEvent;
expect(expectedNativeEvent, entry.route).toEqual(expect.any(String));
const wrapper = (plan.hookEntries ?? []).find((candidate) => candidate.hook.eventRoute?.event === entry.route);
expect(wrapper, entry.route).toBeDefined();
// The document key, the baked constant, and the runtime comparison all
// carry Claude's PascalCase spelling; the wrapper compares the envelope's
// hook_event_name against exactly that constant.
expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent);
expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent);
expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`);
expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";');
expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;');
expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })');

const native = await nativeEnvelope(entry.native);
expect(native.hook_event_name, entry.route).toBe(expectedNativeEvent);
expect(
validateNativeEventEnvelope(native, { canonicalEvent: entry.route, nativeEvent: wrapper!.nativeEvent, target: 'claude' }),
entry.route,
).toEqual(native);
}
});

it('accepts the live PostToolUse:Bash envelope under the Claude wrapper and names a Cursor wrapper as the only source of the observed error', async () => {
// Regression for the maintainer's Claude Code 2.1.257 session (2026-09-03
// 20:47:53Z): every `PostToolUse:Bash` hook failed with
// "Agent Bundle event route error: native hook_event_name must equal
// postToolUse". Claude sends PascalCase; only a wrapper compiled for the
// `cursor` target bakes the camelCase constant, so the message identifies a
// Cursor-built wrapper installed under a Claude plugin root, not a Claude
// mapping defect.
const live = await nativeEnvelope(claudeEventRoutes.find((entry) => entry.route === 'tool/after')!.native);
const [claudeWrapper] = claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])).hookEntries ?? [];
expect(claudeWrapper?.nativeEvent).toBe('PostToolUse');
expect(validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: claudeWrapper!.nativeEvent, target: 'claude' }))
.toEqual(live);

expect(() => validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: 'postToolUse', target: 'cursor' }))
.toThrow('Agent Bundle event route error: native hook_event_name must equal postToolUse');
expect(() => validateNativeEventEnvelope(
{ ...live, hook_event_name: 'PreToolUse', tool_response: undefined },
{ canonicalEvent: 'tool/before', nativeEvent: 'preToolUse', target: 'cursor' },
)).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse');
});

it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => {
const plan = pluginAdapter.plan(model('plugin', [
routeHook('tool/after', 'afterTool', ['plugin']),
routeHook('session/start', 'sessionStart', ['plugin']),
]));
expect(plan.diagnostics).toEqual([]);

const shared = (plan.hookEntries ?? []).find((entry) =>
entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs'));
const cursor = (plan.hookEntries ?? []).find((entry) =>
entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs'));
expect(shared?.nativeEvent).toBe('PostToolUse');
expect(cursor?.nativeEvent).toBe('postToolUse');
expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"');
expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"');

const documents = writes(plan);
expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']);
expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']);
});

it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => {
// Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own
// and `manifest.hooks` is for additional documents only. Naming the
// conventional file records a `hook-load-failed` plugin error, "Duplicate
// hooks file detected ... The standard hooks/hooks.json is loaded
// automatically, so manifest.hooks should only reference additional hook
// files." Claude Code never scans `hooks/` for other documents, so the
// unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it.
const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])));
expect(claude['hooks/hooks.json']).toBeDefined();
expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks');

const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])])));
expect(bundle['hooks/hooks.json']).toBeDefined();
expect(bundle['hooks/hooks-cursor.json']).toBeDefined();
expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks');
// Codex discovers the same conventional file; Cursor's own contract needs
// the explicit pointer because its document does not live at the default.
expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks');
expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' });
});
Loading
Loading