From 351e04ce20c358ce4be67286e2c066f3a314ea8f Mon Sep 17 00:00:00 2001 From: uitlaber Date: Sat, 20 Jun 2026 01:31:25 +0500 Subject: [PATCH 1/2] fix(agent-claude-code): root hook commands at $CLAUDE_PROJECT_DIR so they survive subdir cwd The PostToolUse/activity hooks were registered with a bare relative path (.claude/metadata-updater.sh, .claude/activity-updater.sh and their .cjs variants). That path only resolves when the agent's cwd is the worktree root; when the agent runs a command from a sub-directory (e.g. cwd=/services/api) the hook fails with '/bin/sh: .claude/activity-updater.sh: No such file or directory'. The hook errors are non-blocking, so the agent keeps working, but every tool call spams the failure. Fix: root the hook commands at $CLAUDE_PROJECT_DIR, which Claude Code sets to the worktree root and exposes to hooks (the updater scripts already use ${CLAUDE_PROJECT_DIR:-$(pwd)} internally). The command string is still identical across worktrees, preserving the symlink-safe 'same settings.json everywhere' intent (so shared/symlinked .claude/ dirs don't clobber each other), and now it also resolves from any sub-directory. The hook upsert keys off the '*-updater.{sh,cjs}' identifier, so existing relative/absolute entries migrate. Tests updated to assert the env-rooted form (incl. the Windows .cjs case and the 'identical across worktrees' guarantee) plus a regression test that no generated hook command is cwd-relative. --- .../agent-claude-code/src/index.test.ts | 47 +++++++++++++++---- .../plugins/agent-claude-code/src/index.ts | 20 +++++--- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index eb9fa28125..72fc1dc6a4 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -889,7 +889,7 @@ describe("METADATA_UPDATER_SCRIPT content", () => { // ========================================================================= // setupWorkspaceHooks / postLaunchSetup — hook path (symlink safety) // ========================================================================= -describe("hook setup — relative path (symlink-safe)", () => { +describe("hook setup — $CLAUDE_PROJECT_DIR path (symlink-safe, sub-cwd-safe)", () => { const agent = create(); /** Extract the hook command from the settings.json that was written */ @@ -902,15 +902,18 @@ describe("hook setup — relative path (symlink-safe)", () => { return parsed.hooks.PostToolUse[0].hooks[0].command; } - it("setupWorkspaceHooks writes a relative hook command (not absolute)", async () => { + it("setupWorkspaceHooks writes a $CLAUDE_PROJECT_DIR-rooted hook command (not a bare relative or hard absolute path)", async () => { await agent.setupWorkspaceHooks!( "/Users/equinox/.worktrees/integrator/integrator-5", {} as WorkspaceHooksConfig, ); const hookCommand = getWrittenHookCommand(); - expect(hookCommand).toBe(".claude/metadata-updater.sh"); - expect(hookCommand).not.toMatch(/^\//); + expect(hookCommand).toBe('"$CLAUDE_PROJECT_DIR/.claude/metadata-updater.sh"'); + // Resolves from any sub-cwd (env-rooted), and never embeds the literal worktree path. + expect(hookCommand).toContain("$CLAUDE_PROJECT_DIR"); + expect(hookCommand).not.toContain("/Users/equinox"); + expect(hookCommand.startsWith(".claude/")).toBe(false); }); it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => { @@ -950,7 +953,7 @@ describe("hook setup — relative path (symlink-safe)", () => { expect(content1).toBe(content2); }); - it("updates an existing absolute hook path to relative", async () => { + it("migrates an existing hard-absolute hook path to the $CLAUDE_PROJECT_DIR form", async () => { mockExistsSync.mockReturnValue(true); mockReadFile.mockResolvedValue( JSON.stringify({ @@ -977,8 +980,32 @@ describe("hook setup — relative path (symlink-safe)", () => { {} as WorkspaceHooksConfig, ); + // upsert matches by the `metadata-updater.sh` identifier and replaces the stale + // absolute command with the env-rooted one. const hookCommand = getWrittenHookCommand(); - expect(hookCommand).toBe(".claude/metadata-updater.sh"); + expect(hookCommand).toBe('"$CLAUDE_PROJECT_DIR/.claude/metadata-updater.sh"'); + }); + + it("no generated hook command is cwd-relative (regression: broke when agent cwd was a worktree subdir)", async () => { + await agent.setupWorkspaceHooks!( + "/Users/equinox/.worktrees/integrator/integrator-5", + {} as WorkspaceHooksConfig, + ); + const settingsWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + const settings = JSON.parse(settingsWrite![1] as string) as Record; + const commands = Object.values(settings.hooks as Record) + .flat() + .flatMap((g) => (g as { hooks: Array<{ command: string }> }).hooks) + .map((h) => h.command); + expect(commands.length).toBeGreaterThan(0); + for (const cmd of commands) { + expect(cmd).toContain("$CLAUDE_PROJECT_DIR/.claude/"); + // never a bare relative path (the bug), never the literal worktree path. + expect(/(^|\s)\.claude\//.test(cmd)).toBe(false); + expect(cmd).not.toContain("/Users/equinox"); + } }); it("still writes the script file to the correct absolute filesystem path", async () => { @@ -1020,9 +1047,9 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => { return JSON.parse(settingsWrite![1] as string) as Record; } - /** Activity-updater command paths (unix vs win32) */ - const ACTIVITY_CMD_UNIX = ".claude/activity-updater.sh"; - const ACTIVITY_CMD_WIN = "node .claude/activity-updater.cjs"; + /** Activity-updater command paths (unix vs win32), rooted at $CLAUDE_PROJECT_DIR. */ + const ACTIVITY_CMD_UNIX = '"$CLAUDE_PROJECT_DIR/.claude/activity-updater.sh"'; + const ACTIVITY_CMD_WIN = 'node "$CLAUDE_PROJECT_DIR/.claude/activity-updater.cjs"'; /** * Every Claude Code hook event the script knows how to translate into an @@ -1326,7 +1353,7 @@ describe("setupWorkspaceHooks on win32", () => { await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); const hookCommand = getWrittenHookCommand(); - expect(hookCommand).toBe("node .claude/metadata-updater.cjs"); + expect(hookCommand).toBe('node "$CLAUDE_PROJECT_DIR/.claude/metadata-updater.cjs"'); expect(hookCommand).not.toContain(".sh"); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index cbbf92ed15..fda2ae3175 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -1009,8 +1009,10 @@ async function setupHookInWorkspace(workspacePath: string): Promise { await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8"); // .cjs forces CJS regardless of workspace package.json "type"; node // invocation is required on Windows because shebangs aren't honoured. - metadataCommand = "node .claude/metadata-updater.cjs"; - activityCommand = "node .claude/activity-updater.cjs"; + // $CLAUDE_PROJECT_DIR (set by Claude Code to the worktree root) keeps the + // command identical across worktrees while resolving from any sub-cwd. + metadataCommand = 'node "$CLAUDE_PROJECT_DIR/.claude/metadata-updater.cjs"'; + activityCommand = 'node "$CLAUDE_PROJECT_DIR/.claude/activity-updater.cjs"'; } else { const metadataPath = join(claudeDir, "metadata-updater.sh"); const activityPath = join(claudeDir, "activity-updater.sh"); @@ -1018,8 +1020,10 @@ async function setupHookInWorkspace(workspacePath: string): Promise { await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8"); await chmod(metadataPath, 0o755); await chmod(activityPath, 0o755); - metadataCommand = ".claude/metadata-updater.sh"; - activityCommand = ".claude/activity-updater.sh"; + // $CLAUDE_PROJECT_DIR (set by Claude Code to the worktree root) keeps the + // command identical across worktrees while resolving from any sub-cwd. + metadataCommand = '"$CLAUDE_PROJECT_DIR/.claude/metadata-updater.sh"'; + activityCommand = '"$CLAUDE_PROJECT_DIR/.claude/activity-updater.sh"'; } let existingSettings: Record = {}; @@ -1210,8 +1214,12 @@ function createClaudeCodeAgent(): Agent { }, async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { - // Relative path so that symlinked .claude/ dirs across worktrees - // all produce the same settings.json (last writer doesn't clobber). + // Hook commands use $CLAUDE_PROJECT_DIR (the worktree root, set by Claude + // Code) rather than the literal workspace path: the command string is then + // identical across worktrees, so symlinked .claude/ dirs all produce the + // same settings.json (last writer doesn't clobber), AND it resolves + // correctly when the agent's cwd is a sub-directory of the worktree (a + // bare relative path like `.claude/...` broke there with "No such file"). await setupHookInWorkspace(workspacePath); }, From cedbddd3ae5644fc0b679afc9138b59a6defa7f5 Mon Sep 17 00:00:00 2001 From: uitlaber Date: Sat, 20 Jun 2026 01:31:37 +0500 Subject: [PATCH 2/2] chore: changeset for agent-claude-code hook cwd fix --- .changeset/hooks-cwd-relative-path.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hooks-cwd-relative-path.md diff --git a/.changeset/hooks-cwd-relative-path.md b/.changeset/hooks-cwd-relative-path.md new file mode 100644 index 0000000000..1b7ffc866b --- /dev/null +++ b/.changeset/hooks-cwd-relative-path.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-plugin-agent-claude-code": patch +--- + +Root Claude Code hook commands at `$CLAUDE_PROJECT_DIR` so they resolve when the agent's cwd is a worktree sub-directory (a bare relative `.claude/*-updater.{sh,cjs}` path previously failed with "No such file or directory" on every tool call).