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
5 changes: 5 additions & 0 deletions .changeset/hooks-cwd-relative-path.md
Original file line number Diff line number Diff line change
@@ -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).
47 changes: 37 additions & 10 deletions packages/plugins/agent-claude-code/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand All @@ -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<string, unknown>;
const commands = Object.values(settings.hooks as Record<string, unknown>)
.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 () => {
Expand Down Expand Up @@ -1020,9 +1047,9 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => {
return JSON.parse(settingsWrite![1] as string) as Record<string, unknown>;
}

/** 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
Expand Down Expand Up @@ -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");
});

Expand Down
20 changes: 14 additions & 6 deletions packages/plugins/agent-claude-code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,17 +1009,21 @@ async function setupHookInWorkspace(workspacePath: string): Promise<void> {
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");
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8");
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<string, unknown> = {};
Expand Down Expand Up @@ -1210,8 +1214,12 @@ function createClaudeCodeAgent(): Agent {
},

async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
// 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);
},

Expand Down