Skip to content

Commit 5775351

Browse files
ScriptedAlchemyUbuntu
andauthored
fix(claude): drop the manifest hooks pointer Claude Code reports as a duplicate; pin every Claude hook_event_name (#470)
* fix(claude): drop the manifest hooks pointer and pin every Claude hook_event_name Claude Code loads `hooks/hooks.json` on its own; `manifest.hooks` is only for additional documents. The `claude` target has named `./hooks/hooks.json` there since b256d44 and #450 added the same pointer to the unified `plugin` bundle. Claude Code 2.1.259 records a `hook-load-failed` plugin error for it: "Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file ... The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files" (reproduced with an isolated CLAUDE_CONFIG_DIR against a built Claude pack). #450's stated root cause does not hold: Claude Code never scans `hooks/` for other documents, so `hooks/hooks-cursor.json` is invisible to it. The live "native hook_event_name must equal postToolUse" errors came from the Claude wrapper path (`event-route-tool-after.mjs`, per the session transcript) during a rebuild/reinstall window of a directory marketplace, whose `${CLAUDE_PLUGIN_ROOT}` is the build output itself; only a Cursor-built wrapper bakes that camelCase constant. - claude.ts / plugin.ts: emit no `hooks` field on `.claude-plugin/plugin.json`. - tests/claude-hook-event-name.test.ts: for every supported Claude event route, the planned wrapper bakes the pinned PascalCase name, the hooks document is keyed by it, and a real Claude envelope passes validateNativeEventEnvelope; the live PostToolUse:Bash envelope is accepted by the Claude wrapper and rejected with the exact observed message only under a Cursor-baked validation; the unified bundle keeps `.mjs`/`.cursor.mjs` spellings apart; no Claude manifest pointer. - docs (en+zh hooks.mdx, installation.mdx) state the loader behavior. * chore(changeset): reference #470 --------- Co-authored-by: Ubuntu <zack@ubuntu-main.local>
1 parent 9d4fbd8 commit 5775351

12 files changed

Lines changed: 298 additions & 123 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

‎.changeset/claude-hook-manifest-pointer.md‎

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎packages/agent-bundle/src/adapters/claude.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3155,13 +3155,18 @@ export const planClaudeArtifacts = (
31553155
const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document);
31563156
const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument);
31573157

3158+
// Claude Code loads the conventional `hooks/hooks.json` on its own; the
3159+
// manifest `hooks` field is only for *additional* documents. Naming the
3160+
// conventional file there makes Claude Code (2.1.259 observed) record a
3161+
// `hook-load-failed` plugin error, "Duplicate hooks file detected ... The
3162+
// standard hooks/hooks.json is loaded automatically, so manifest.hooks
3163+
// should only reference additional hook files", so no pointer is emitted.
31583164
const plugin = {
31593165
author: { name: model.metadata.name },
31603166
...manifestMetadata.document,
31613167
...(channels.document === undefined ? {} : { channels: channels.document }),
31623168
...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }),
31633169
description: model.metadata.description ?? model.metadata.name,
3164-
...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }),
31653170
name: model.metadata.name,
31663171
...(userConfig.document === undefined ? {} : { userConfig: userConfig.document }),
31673172
version: model.metadata.version,

‎packages/agent-bundle/src/adapters/plugin.ts‎

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,17 @@ const pluginName = 'plugin';
7272
* convention, so the Claude document owns that slot; Codex's manifest carries
7373
* explicit pointers, so its MCP document relocates under `.codex-plugin/`.
7474
*
75-
* Hooks ship once: Codex documents discovering `hooks/hooks.json` at the
76-
* plugin root, exporting `CLAUDE_PLUGIN_ROOT` into hook processes as a
77-
* compatibility alias and running commands through a real shell, and its
78-
* hook envelope and output contract match Claude's — so one Claude-format
79-
* hook document plus one runtime-host-detecting wrapper per hook serves
80-
* both hosts. Claude Code's `.claude-plugin/plugin.json` names that same
81-
* `./hooks/hooks.json` so the host does not also load `hooks/hooks-cursor.json`
82-
* (Cursor's camelCase `hook_event_name` values) from the shared `hooks/`
83-
* directory. Per-host `nativeHooks` passthrough stays with the host targets.
75+
* Hooks ship once: both hosts document discovering `hooks/hooks.json` at the
76+
* plugin root, Codex documents exporting `CLAUDE_PLUGIN_ROOT` into hook
77+
* processes as a compatibility alias and running commands through a real
78+
* shell, and its hook envelope and output contract match Claude's - so one
79+
* Claude-format hook document plus one runtime-host-detecting wrapper per
80+
* hook serves both hosts. Claude Code loads exactly that conventional file
81+
* and never scans `hooks/` for other documents, so `hooks/hooks-cursor.json`
82+
* is invisible to it; the Claude manifest therefore carries no `hooks`
83+
* pointer (naming the conventional file again is reported by Claude Code as
84+
* a duplicate hooks file, see the Claude adapter). Per-host `nativeHooks`
85+
* passthrough stays with the host targets.
8486
*
8587
* The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared
8688
* `skills/` as-is, the conventional root `mcp.json`, and - because
@@ -363,7 +365,7 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions)
363365
'- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.',
364366
]
365367
: []),
366-
'- `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`).',
368+
'- `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`).',
367369
'- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.',
368370
'- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.',
369371
'',
@@ -421,32 +423,6 @@ const mergeEntries = (
421423
return [...merged.values()];
422424
};
423425

424-
/**
425-
* The Claude half is planned hook-free so this adapter can emit one shared
426-
* `hooks/hooks.json`. Stamp the Claude manifest with that path so Claude
427-
* Code loads it instead of also discovering `hooks/hooks-cursor.json`.
428-
*/
429-
const attachClaudeHookManifest = (
430-
entries: TargetArtifactEntry[],
431-
hookSourceInputs: readonly string[],
432-
): void => {
433-
const index = entries.findIndex((entry) => entry.relativePath === claudeArtifactPaths.plugin);
434-
if (index === -1) return;
435-
const existing = entries[index]!;
436-
if (existing.kind !== 'write') {
437-
throw new Error('Agent plugin bundle Claude plugin.json must be a generated write entry.');
438-
}
439-
const parsed: unknown = JSON.parse(existing.content);
440-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
441-
throw new Error('Agent plugin bundle Claude plugin.json must be a JSON object.');
442-
}
443-
entries[index] = Object.freeze({
444-
...existing,
445-
content: `${stableJson({ ...parsed, hooks: `./${bundleHookContract.manifestPath}` })}\n`,
446-
sourceInputs: sourceInputs(...existing.sourceInputs, ...hookSourceInputs),
447-
});
448-
};
449-
450426
const cursorMcpPlanContext = Object.freeze({ codePrefix: 'plugin.cursor', errorDiagnostic });
451427

452428
const cursorBundleHookContract = createCursorHookContract({
@@ -497,7 +473,6 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
497473
const hookSourceInputs = model.hooks
498474
.filter((hook) => hook.targets.includes(pluginName))
499475
.map((hook) => hook.provenance.sourcePath);
500-
attachClaudeHookManifest(entries, hookSourceInputs);
501476
entries.push({
502477
content: `${stableJson(hookDocument)}\n`,
503478
kind: 'write',
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
import { expect, it } from '@rstest/core';
4+
5+
import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' };
6+
import { claudeAdapter } from '../src/adapters/claude.ts';
7+
import { pluginAdapter } from '../src/adapters/plugin.ts';
8+
import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts';
9+
import { validateNativeEventEnvelope } from '../src/events/projection.ts';
10+
import type { CanonicalAgentEvent } from '../src/routes/public.ts';
11+
12+
const configPath = '/workspace/agent-bundle.config.ts';
13+
14+
/**
15+
* Every event route the pinned Claude capability table supports, paired with
16+
* the hook identity `normalizeProject` assigns it and a native envelope of the
17+
* shape Claude Code writes to the wrapper's stdin. Fixture-backed rows reuse
18+
* the documented envelopes under `fixtures/events/`; the four inline rows are
19+
* the events Claude Code fires on every session (the maintainer's live
20+
* session below reproduced exactly these).
21+
*/
22+
const claudeEventRoutes: readonly {
23+
readonly hookEvent: NormalizedHookEvent;
24+
readonly native: string | Readonly<Record<string, unknown>>;
25+
readonly route: CanonicalAgentEvent;
26+
}[] = [
27+
{ hookEvent: 'agentIdle', native: 'claude-teammate-idle.json', route: 'agent/idle' },
28+
{ hookEvent: 'agentStart', native: 'claude-subagent-start.json', route: 'agent/start' },
29+
{ hookEvent: 'agentStop', native: 'claude-subagent-stop.json', route: 'agent/stop' },
30+
{ hookEvent: 'compactAfter', native: 'claude-post-compact.json', route: 'compact/after' },
31+
{ hookEvent: 'compactBefore', native: 'claude-pre-compact.json', route: 'compact/before' },
32+
{ hookEvent: 'configChange', native: 'claude-config-change.json', route: 'config/change' },
33+
{ hookEvent: 'fileChange', native: 'claude-file-changed.json', route: 'file/change' },
34+
{ hookEvent: 'permissionDenied', native: 'claude-permission-denied.json', route: 'permission/denied' },
35+
{ hookEvent: 'permissionRequest', native: 'claude-permission-request.json', route: 'permission/request' },
36+
{ hookEvent: 'promptSubmit', native: 'claude-user-prompt-submit.json', route: 'prompt/submit' },
37+
{ hookEvent: 'sessionEnd', native: 'claude-session-end.json', route: 'session/end' },
38+
{
39+
hookEvent: 'sessionStart',
40+
native: {
41+
cwd: '/workspace',
42+
hook_event_name: 'SessionStart',
43+
session_id: 'session-claude-1',
44+
source: 'startup',
45+
transcript_path: '/workspace/.claude/projects/session.jsonl',
46+
},
47+
route: 'session/start',
48+
},
49+
{
50+
hookEvent: 'stop',
51+
native: {
52+
cwd: '/workspace',
53+
hook_event_name: 'Stop',
54+
last_assistant_message: 'Done.',
55+
permission_mode: 'default',
56+
session_id: 'session-claude-1',
57+
stop_hook_active: false,
58+
transcript_path: '/workspace/.claude/projects/session.jsonl',
59+
},
60+
route: 'stop',
61+
},
62+
{ hookEvent: 'stopFailure', native: 'claude-stop-failure.json', route: 'stop/failure' },
63+
{ hookEvent: 'taskComplete', native: 'claude-task-completed.json', route: 'task/complete' },
64+
{ hookEvent: 'taskCreate', native: 'claude-task-created.json', route: 'task/create' },
65+
{
66+
hookEvent: 'afterTool',
67+
native: {
68+
cwd: '/workspace',
69+
hook_event_name: 'PostToolUse',
70+
permission_mode: 'bypassPermissions',
71+
session_id: 'session-claude-1',
72+
tool_input: { command: 'git status --short', description: 'Show the working tree' },
73+
tool_name: 'Bash',
74+
tool_response: { interrupted: false, isImage: false, stderr: '', stdout: ' M README.md\n' },
75+
tool_use_id: 'toolu_01LvwxiKhvU7wJ1Hf2MUJ2hu',
76+
transcript_path: '/workspace/.claude/projects/session.jsonl',
77+
},
78+
route: 'tool/after',
79+
},
80+
{
81+
hookEvent: 'beforeTool',
82+
native: {
83+
cwd: '/workspace',
84+
hook_event_name: 'PreToolUse',
85+
permission_mode: 'bypassPermissions',
86+
session_id: 'session-claude-1',
87+
tool_input: { command: 'git status --short', description: 'Show the working tree' },
88+
tool_name: 'Bash',
89+
tool_use_id: 'toolu_01Taws9XLqrL8XQk4BsTkjps',
90+
transcript_path: '/workspace/.claude/projects/session.jsonl',
91+
},
92+
route: 'tool/before',
93+
},
94+
{ hookEvent: 'toolFailure', native: 'claude-post-tool-use-failure.json', route: 'tool/failure' },
95+
];
96+
97+
const pinnedClaudeRoutes: Readonly<Record<string, { readonly nativeEvent?: string; readonly state: string }>> =
98+
claudeCapabilityTable.hooks.eventRoutes;
99+
100+
const supportedClaudeRoutes = Object.entries(pinnedClaudeRoutes)
101+
.filter(([, capability]) => capability.state === 'supported')
102+
.map(([route]) => route)
103+
.sort();
104+
105+
const routeHook = (
106+
route: CanonicalAgentEvent,
107+
hookEvent: NormalizedHookEvent,
108+
targets: readonly string[],
109+
): NormalizedHook => {
110+
const name = `event-route-${route.replace('/', '-')}`;
111+
return {
112+
event: hookEvent,
113+
eventRoute: { event: route, fallback: 'none', runtime: 'shared' },
114+
id: `hook:${name}`,
115+
name,
116+
provenance: { kind: 'conventional', sourcePath: `/workspace/src/events/${route}.tsx` },
117+
source: `/workspace/src/events/${route}.tsx`,
118+
targets,
119+
tools: [],
120+
};
121+
};
122+
123+
const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({
124+
extensions: {},
125+
hooks,
126+
mcpServers: [],
127+
metadata: {
128+
description: 'Claude hook_event_name regression.',
129+
id: 'plugin:hook-event-name',
130+
name: 'hook-event-name',
131+
provenance: { kind: 'config', sourcePath: configPath },
132+
version: '1.0.0',
133+
},
134+
runtime: { node: '22.12.0' },
135+
scripts: [],
136+
skills: [],
137+
targets: [{
138+
id: `target:${target}`,
139+
name: target,
140+
provenance: { kind: 'config', sourcePath: configPath },
141+
}],
142+
});
143+
144+
const nativeEnvelope = async (
145+
native: string | Readonly<Record<string, unknown>>,
146+
): Promise<Readonly<Record<string, unknown>>> =>
147+
typeof native === 'string'
148+
? JSON.parse(await readFile(new URL(`./fixtures/events/${native}`, import.meta.url), 'utf8')) as Record<string, unknown>
149+
: native;
150+
151+
const writes = (plan: { readonly entries: readonly { readonly kind: string; readonly relativePath: string; readonly content?: string }[] }) =>
152+
Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content!]] : []));
153+
154+
it('covers every event route the pinned Claude capability table supports', () => {
155+
expect(claudeEventRoutes.map((entry) => entry.route).sort()).toEqual(supportedClaudeRoutes);
156+
});
157+
158+
it('bakes the pinned Claude hook_event_name into every Claude event-route wrapper and accepts the native envelope', async () => {
159+
const hooks = claudeEventRoutes.map((entry) => routeHook(entry.route, entry.hookEvent, ['claude']));
160+
const plan = claudeAdapter.plan(model('claude', hooks));
161+
expect(plan.diagnostics).toEqual([]);
162+
163+
const document = JSON.parse(writes(plan)['hooks/hooks.json']!) as { readonly hooks: Record<string, unknown> };
164+
for (const entry of claudeEventRoutes) {
165+
const expectedNativeEvent = pinnedClaudeRoutes[entry.route]?.nativeEvent;
166+
expect(expectedNativeEvent, entry.route).toEqual(expect.any(String));
167+
const wrapper = (plan.hookEntries ?? []).find((candidate) => candidate.hook.eventRoute?.event === entry.route);
168+
expect(wrapper, entry.route).toBeDefined();
169+
// The document key, the baked constant, and the runtime comparison all
170+
// carry Claude's PascalCase spelling; the wrapper compares the envelope's
171+
// hook_event_name against exactly that constant.
172+
expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent);
173+
expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent);
174+
expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`);
175+
expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";');
176+
expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;');
177+
expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })');
178+
179+
const native = await nativeEnvelope(entry.native);
180+
expect(native.hook_event_name, entry.route).toBe(expectedNativeEvent);
181+
expect(
182+
validateNativeEventEnvelope(native, { canonicalEvent: entry.route, nativeEvent: wrapper!.nativeEvent, target: 'claude' }),
183+
entry.route,
184+
).toEqual(native);
185+
}
186+
});
187+
188+
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 () => {
189+
// Regression for the maintainer's Claude Code 2.1.257 session (2026-09-03
190+
// 20:47:53Z): every `PostToolUse:Bash` hook failed with
191+
// "Agent Bundle event route error: native hook_event_name must equal
192+
// postToolUse". Claude sends PascalCase; only a wrapper compiled for the
193+
// `cursor` target bakes the camelCase constant, so the message identifies a
194+
// Cursor-built wrapper installed under a Claude plugin root, not a Claude
195+
// mapping defect.
196+
const live = await nativeEnvelope(claudeEventRoutes.find((entry) => entry.route === 'tool/after')!.native);
197+
const [claudeWrapper] = claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])).hookEntries ?? [];
198+
expect(claudeWrapper?.nativeEvent).toBe('PostToolUse');
199+
expect(validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: claudeWrapper!.nativeEvent, target: 'claude' }))
200+
.toEqual(live);
201+
202+
expect(() => validateNativeEventEnvelope(live, { canonicalEvent: 'tool/after', nativeEvent: 'postToolUse', target: 'cursor' }))
203+
.toThrow('Agent Bundle event route error: native hook_event_name must equal postToolUse');
204+
expect(() => validateNativeEventEnvelope(
205+
{ ...live, hook_event_name: 'PreToolUse', tool_response: undefined },
206+
{ canonicalEvent: 'tool/before', nativeEvent: 'preToolUse', target: 'cursor' },
207+
)).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse');
208+
});
209+
210+
it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => {
211+
const plan = pluginAdapter.plan(model('plugin', [
212+
routeHook('tool/after', 'afterTool', ['plugin']),
213+
routeHook('session/start', 'sessionStart', ['plugin']),
214+
]));
215+
expect(plan.diagnostics).toEqual([]);
216+
217+
const shared = (plan.hookEntries ?? []).find((entry) =>
218+
entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs'));
219+
const cursor = (plan.hookEntries ?? []).find((entry) =>
220+
entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs'));
221+
expect(shared?.nativeEvent).toBe('PostToolUse');
222+
expect(cursor?.nativeEvent).toBe('postToolUse');
223+
expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"');
224+
expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"');
225+
226+
const documents = writes(plan);
227+
expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']);
228+
expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']);
229+
});
230+
231+
it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => {
232+
// Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own
233+
// and `manifest.hooks` is for additional documents only. Naming the
234+
// conventional file records a `hook-load-failed` plugin error, "Duplicate
235+
// hooks file detected ... The standard hooks/hooks.json is loaded
236+
// automatically, so manifest.hooks should only reference additional hook
237+
// files." Claude Code never scans `hooks/` for other documents, so the
238+
// unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it.
239+
const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])])));
240+
expect(claude['hooks/hooks.json']).toBeDefined();
241+
expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks');
242+
243+
const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])])));
244+
expect(bundle['hooks/hooks.json']).toBeDefined();
245+
expect(bundle['hooks/hooks-cursor.json']).toBeDefined();
246+
expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks');
247+
// Codex discovers the same conventional file; Cursor's own contract needs
248+
// the explicit pointer because its document does not live at the default.
249+
expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks');
250+
expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' });
251+
});

0 commit comments

Comments
 (0)