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
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ type HookServer = Readonly<{ port: number; stop: () => void }>;

export function createClaudeStartHookServerTask(params: {
startHookServer: () => Promise<HookServer>;
generateHookSettingsFile: (port: number) => string;
generateHookSettingsFile: (port: number) => Promise<string>;
}): StartupTask<ClaudeStartupArtifacts> {
return {
id: 'claude.start_hook_server',
phase: 'preSpawn',
run: async ({ artifacts }) => {
const hookServer = await params.startHookServer();
artifacts.hookServer = hookServer;
artifacts.hookSettingsPath = params.generateHookSettingsFile(hookServer.port);
artifacts.hookSettingsPath = await params.generateHookSettingsFile(hookServer.port);
},
};
}

64 changes: 56 additions & 8 deletions apps/cli/src/backends/claude/utils/claudeSettings.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* Utilities for reading Claude's settings.json configuration
*
*
* Handles reading Claude's settings.json file to respect user preferences
* like includeCoAuthoredBy setting for commit message generation.
*/

import { existsSync, readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { logger } from '@/ui/logger';
Expand All @@ -15,6 +16,9 @@ export interface ClaudeSettings {
[key: string]: any;
}

/** Maximum time to wait for settings file read before giving up. */
const SETTINGS_READ_TIMEOUT_MS = 5_000;

/**
* Get the path to Claude's settings.json file
*/
Expand All @@ -26,36 +30,80 @@ function getClaudeSettingsPath(claudeConfigDirOverride?: string | null): string
}

/**
* Read Claude's settings.json file from the default location
*
* Read Claude's settings.json file synchronously.
*
* WARNING: This can block indefinitely if the settings file is on a slow or
* inaccessible filesystem (e.g. iCloud Drive symlinks in a LaunchAgent
* context on macOS). Prefer readClaudeSettingsAsync() for startup-critical
* code paths.
*
* @returns Claude settings object or null if file doesn't exist or can't be read
*/
export function readClaudeSettings(claudeConfigDirOverride?: string | null): ClaudeSettings | null {
try {
const settingsPath = getClaudeSettingsPath(claudeConfigDirOverride);

if (!existsSync(settingsPath)) {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
}

const settingsContent = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(settingsContent) as ClaudeSettings;

logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`);
logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`);

return settings;
} catch (error) {
logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`);
return null;
}
}

/**
* Read Claude's settings.json file asynchronously with a timeout.
*
* This is the preferred method for startup-critical code paths. It will not
* block indefinitely if the settings file is on a slow or inaccessible
* filesystem (e.g. symlinks to iCloud Drive in LaunchAgent/daemon context
* on macOS where TCC entitlements may prevent filesystem access).
*
* @returns Claude settings object or null if file doesn't exist, can't be read, or times out
*/
export async function readClaudeSettingsAsync(claudeConfigDirOverride?: string | null): Promise<ClaudeSettings | null> {
try {
const settingsPath = getClaudeSettingsPath(claudeConfigDirOverride);

if (!existsSync(settingsPath)) {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
Comment on lines +78 to +80
Copy link
Contributor

Choose a reason for hiding this comment

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

existsSync() is still synchronous and could block on slow filesystems (same issue as readFileSync). consider using fs.promises.access() with the abort signal instead

Suggested change
if (!existsSync(settingsPath)) {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
try {
await access(settingsPath, { signal: AbortSignal.timeout(SETTINGS_READ_TIMEOUT_MS) });
} catch {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
}

}

const settingsContent = await readFile(settingsPath, {
encoding: 'utf-8',
signal: AbortSignal.timeout(SETTINGS_READ_TIMEOUT_MS),
});
const settings = JSON.parse(settingsContent) as ClaudeSettings;

logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`);
logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`);

return settings;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
logger.debug(`[ClaudeSettings] Timed out reading settings after ${SETTINGS_READ_TIMEOUT_MS}ms — file may be on a slow or inaccessible filesystem`);
} else {
logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`);
}
return null;
}
}

/**
* Check if Co-Authored-By lines should be included in commit messages
* based on Claude's settings
*
*
* @returns true if Co-Authored-By should be included, false otherwise
*/
export function shouldIncludeCoAuthoredBy(): boolean {
Expand Down
15 changes: 9 additions & 6 deletions apps/cli/src/backends/claude/utils/generateHookSettings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Generate temporary settings file with Claude hooks for session tracking
*
*
* Creates a settings.json file that configures Claude's SessionStart hook
* to notify our HTTP server when sessions change (new session, resume, compact, etc.)
*/
Expand All @@ -10,7 +10,7 @@ import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs';
import { configuration } from '@/configuration';
import { logger } from '@/ui/logger';
import { projectPath } from '@/projectPath';
import { readClaudeSettings, type ClaudeSettings } from './claudeSettings';
import { readClaudeSettingsAsync, type ClaudeSettings } from './claudeSettings';
import { isBun } from '@/utils/runtime';

export interface GenerateHookSettingsOptions {
Expand All @@ -25,11 +25,14 @@ export interface GenerateHookSettingsOptions {

/**
* Generate a temporary settings file with SessionStart hook configuration
*
*
* Uses async file I/O with timeout to read base settings, preventing
* indefinite hangs on slow or inaccessible filesystems.
*
* @param port - The port where Happy server is listening
* @returns Path to the generated settings file
*/
export function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): string {
export async function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

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

changing this to async breaks the existing caller at apps/cli/src/backends/claude/runClaude.ts:513 which doesn't await the call

// runClaude.ts line 513 (not updated in this PR):
const hookSettingsPath = generateHookSettingsFile(hookServer.port, {

needs to be:

const hookSettingsPath = await generateHookSettingsFile(hookServer.port, {

Copy link
Contributor

Choose a reason for hiding this comment

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

tests in generateHookSettings.test.ts need updating - all 4 test cases call this function without await

lines 26, 38, 65, and 102 all need:

  • test functions changed to async
  • calls changed to await generateHookSettingsFile(...)

const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks');
mkdirSync(hooksDir, { recursive: true });

Expand Down Expand Up @@ -78,7 +81,7 @@ export function generateHookSettingsFile(port: number, options: GenerateHookSett
];
}

const baseSettings: ClaudeSettings = readClaudeSettings(options.claudeConfigDir) ?? {};
const baseSettings: ClaudeSettings = await readClaudeSettingsAsync(options.claudeConfigDir) ?? {};
const baseHooks =
baseSettings && typeof baseSettings === 'object' && baseSettings.hooks && typeof baseSettings.hooks === 'object'
? (baseSettings.hooks as Record<string, unknown>)
Expand Down Expand Up @@ -124,7 +127,7 @@ export function generateHookSettingsFile(port: number, options: GenerateHookSett

/**
* Clean up the temporary hook settings file
*
*
* @param filepath - Path to the settings file to remove
*/
export function cleanupHookSettingsFile(filepath: string): void {
Expand Down