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
49 changes: 49 additions & 0 deletions scripts/session_hook_forwarder.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Session Hook Forwarder
*
* This script is executed by Claude's SessionStart hook.
* It reads JSON data from stdin and forwards it to Happy's hook server.
*
* Usage: echo '{"session_id":"..."}' | node session_hook_forwarder.cjs <port>
*/

const http = require('http');

const port = parseInt(process.argv[2], 10);

if (!port || isNaN(port)) {
process.exit(1);
}

const chunks = [];

process.stdin.on('data', (chunk) => {
chunks.push(chunk);
});

process.stdin.on('end', () => {
const body = Buffer.concat(chunks);

const req = http.request({
host: '127.0.0.1',
port: port,
method: 'POST',
path: '/hook/session-start',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length
}
}, (res) => {
res.resume(); // Drain response
});

req.on('error', () => {
// Silently ignore errors - don't break Claude
});

req.end(body);
});

process.stdin.resume();

47 changes: 25 additions & 22 deletions src/claude/claudeLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
import { resolve, join } from "node:path";
import { createInterface } from "node:readline";
import { mkdirSync, existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { logger } from "@/ui/logger";
import { claudeCheckSession } from "./utils/claudeCheckSession";
import { getProjectPath } from "./utils/path";
Expand All @@ -21,33 +20,35 @@ export async function claudeLocal(opts: {
onSessionFound: (id: string) => void,
onThinkingChange?: (thinking: boolean) => void,
claudeEnvVars?: Record<string, string>,
claudeArgs?: string[]
allowedTools?: string[]
claudeArgs?: string[],
allowedTools?: string[],
/** Path to temporary settings file with SessionStart hook (required for session tracking) */
hookSettingsPath: string
}) {

// Ensure project directory exists
const projectDir = getProjectPath(opts.path);
mkdirSync(projectDir, { recursive: true });

// Determine session ID strategy:
// - If resuming an existing session: use --resume (Claude keeps the same session ID)
// - If starting fresh: generate UUID and pass via --session-id
// Check if claudeArgs contains --continue or --resume (user passed these flags)
const hasContinueFlag = opts.claudeArgs?.includes('--continue');
const hasResumeFlag = opts.claudeArgs?.includes('--resume');
const hasUserSessionControl = hasContinueFlag || hasResumeFlag;

// Determine if we have an existing session to resume
// Session ID will always be provided by hook (SessionStart) when Claude starts
let startFrom = opts.sessionId;
if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
startFrom = null;
}

// Generate new session ID if not resuming
const newSessionId = startFrom ? null : randomUUID();
const effectiveSessionId = startFrom || newSessionId!;

// Notify about session ID immediately (we know it upfront now!)
if (newSessionId) {
logger.debug(`[ClaudeLocal] Generated new session ID: ${newSessionId}`);
opts.onSessionFound(newSessionId);
// Log session strategy
if (startFrom) {
logger.debug(`[ClaudeLocal] Will resume existing session: ${startFrom}`);
} else if (hasUserSessionControl) {
logger.debug(`[ClaudeLocal] User passed ${hasContinueFlag ? '--continue' : '--resume'} flag, session ID will be determined by hook`);
} else {
logger.debug(`[ClaudeLocal] Resuming session: ${startFrom}`);
opts.onSessionFound(startFrom!);
logger.debug(`[ClaudeLocal] Fresh start, session ID will be provided by hook`);
}

// Thinking state
Expand All @@ -70,12 +71,10 @@ export async function claudeLocal(opts: {
await new Promise<void>((r, reject) => {
const args: string[] = []

if (startFrom) {
// Resume existing session (Claude preserves the session ID)
// Only add --resume if we have an existing session and user didn't pass their own flags
// For fresh starts, let Claude create its own session ID (reported via hook)
if (!hasUserSessionControl && startFrom) {
args.push('--resume', startFrom)
} else {
// New session with our generated UUID
args.push('--session-id', newSessionId!)
}

args.push('--append-system-prompt', systemPrompt);
Expand All @@ -93,6 +92,10 @@ export async function claudeLocal(opts: {
args.push(...opts.claudeArgs)
}

// Add hook settings for session tracking (always passed)
args.push('--settings', opts.hookSettingsPath);
logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`);

if (!claudeCliPath || !existsSync(claudeCliPath)) {
throw new Error('Claude local launcher not found. Please ensure HAPPY_PROJECT_ROOT is set correctly for development.');
}
Expand Down Expand Up @@ -205,5 +208,5 @@ export async function claudeLocal(opts: {
updateThinking(false);
}

return effectiveSessionId;
return startFrom;
}
11 changes: 11 additions & 0 deletions src/claude/claudeLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
}
}
});

// Register callback to notify scanner when session ID is found via hook
// This is important for --continue/--resume where session ID is not known upfront
const scannerSessionCallback = (sessionId: string) => {
scanner.onNewSession(sessionId);
};
session.addSessionFoundCallback(scannerSessionCallback);


// Handle abort
Expand Down Expand Up @@ -101,6 +108,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
claudeArgs: session.claudeArgs,
mcpServers: session.mcpServers,
allowedTools: session.allowedTools,
hookSettingsPath: session.hookSettingsPath,
});

// Consume one-time Claude flags after spawn
Expand Down Expand Up @@ -132,6 +140,9 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
session.client.rpcHandlerManager.registerHandler('abort', async () => { });
session.client.rpcHandlerManager.registerHandler('switch', async () => { });
session.queue.setOnMessage(null);

// Remove session found callback
session.removeSessionFoundCallback(scannerSessionCallback);

// Cleanup
await scanner.cleanup();
Expand Down
3 changes: 3 additions & 0 deletions src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export async function claudeRemote(opts: {
allowedTools: string[],
signal?: AbortSignal,
canCallTool: (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }) => Promise<PermissionResult>,
/** Path to temporary settings file with SessionStart hook (required for session tracking) */
hookSettingsPath: string,

// Dynamic parameters
nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>,
Expand Down Expand Up @@ -124,6 +126,7 @@ export async function claudeRemote(opts: {
pathToClaudeCodeExecutable: (() => {
return resolve(join(projectPath(), 'scripts', 'claude_remote_launcher.cjs'));
})(),
settingsPath: opts.hookSettingsPath,
}

// Track thinking state
Expand Down
1 change: 1 addition & 0 deletions src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
path: session.path,
allowedTools: session.allowedTools ?? [],
mcpServers: session.mcpServers,
hookSettingsPath: session.hookSettingsPath,
canCallTool: permissionHandler.handleToolCall,
isAborted: (toolCallId: string) => {
return permissionHandler.isAborted(toolCallId);
Expand Down
5 changes: 4 additions & 1 deletion src/claude/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface LoopOptions {
messageQueue: MessageQueue2<EnhancedMode>
allowedTools?: string[]
onSessionReady?: (session: Session) => void
/** Path to temporary settings file with SessionStart hook (required for session tracking) */
hookSettingsPath: string
}

export async function loop(opts: LoopOptions) {
Expand All @@ -49,7 +51,8 @@ export async function loop(opts: LoopOptions) {
logPath: logPath,
messageQueue: opts.messageQueue,
allowedTools: opts.allowedTools,
onModeChange: opts.onModeChange
onModeChange: opts.onModeChange,
hookSettingsPath: opts.hookSettingsPath
});

// Notify that session is ready
Expand Down
45 changes: 42 additions & 3 deletions src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ import { configuration } from '@/configuration';
import { notifyDaemonSessionStarted } from '@/daemon/controlClient';
import { initialMachineMetadata } from '@/daemon/run';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { startHookServer } from '@/claude/utils/startHookServer';
import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings';
import { registerKillSessionHandler } from './registerKillSessionHandler';
import { projectPath } from '../projectPath';
import { resolve } from 'node:path';
import { Session } from './session';

export interface StartOptions {
model?: string
Expand Down Expand Up @@ -130,6 +133,31 @@ export async function runClaude(credentials: Credentials, options: StartOptions
const happyServer = await startHappyServer(session);
logger.debug(`[START] Happy MCP server started at ${happyServer.url}`);

// Variable to track current session instance (updated via onSessionReady callback)
// Used by hook server to notify Session when Claude changes session ID
let currentSession: Session | null = null;

// Start Hook server for receiving Claude session notifications
const hookServer = await startHookServer({
onSessionHook: (sessionId, data) => {
logger.debug(`[START] Session hook received: ${sessionId}`, data);

// Update session ID in the Session instance
if (currentSession) {
Copy link
Contributor

Choose a reason for hiding this comment

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

hmmm how is this going to work? What if we don't have a session?

Also dynamic import - lets not do that for the type above :D

const previousSessionId = currentSession.sessionId;
if (previousSessionId !== sessionId) {
logger.debug(`[START] Claude session ID changed: ${previousSessionId} -> ${sessionId}`);
currentSession.onSessionFound(sessionId);
}
}
}
});
logger.debug(`[START] Hook server started on port ${hookServer.port}`);

// Generate hook settings file for Claude
const hookSettingsPath = generateHookSettingsFile(hookServer.port);
logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`);

// Print log file path
const logPath = logger.logFilePath;
logger.infoDeveloper(`Session: ${response.id}`);
Expand Down Expand Up @@ -320,6 +348,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions
// Stop Happy MCP server
happyServer.stop();

// Stop Hook server and cleanup settings file
hookServer.stop();
cleanupHookSettingsFile(hookSettingsPath);

logger.debug('[START] Cleanup complete, exiting');
process.exit(0);
} catch (error) {
Expand Down Expand Up @@ -361,8 +393,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions
controlledByUser: newMode === 'local'
}));
},
onSessionReady: (_sessionInstance) => {
// Intentionally unused
onSessionReady: (sessionInstance) => {
// Store reference for hook server callback
currentSession = sessionInstance;
},
mcpServers: {
'happy': {
Expand All @@ -372,7 +405,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions
},
session,
claudeEnvVars: options.claudeEnvVars,
claudeArgs: options.claudeArgs
claudeArgs: options.claudeArgs,
hookSettingsPath
});

// Send session death message
Expand All @@ -394,6 +428,11 @@ export async function runClaude(credentials: Credentials, options: StartOptions
happyServer.stop();
logger.debug('Stopped Happy MCP server');

// Stop Hook server and cleanup settings file
hookServer.stop();
cleanupHookSettingsFile(hookSettingsPath);
logger.debug('Stopped Hook server and cleaned up settings file');

// Exit
process.exit(0);
}
4 changes: 3 additions & 1 deletion src/claude/sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ export function query(config: {
model,
fallbackModel,
strictMcpConfig,
canCallTool
canCallTool,
settingsPath
} = {}
} = config

Expand Down Expand Up @@ -304,6 +305,7 @@ export function query(config: {
}
if (strictMcpConfig) args.push('--strict-mcp-config')
if (permissionMode) args.push('--permission-mode', permissionMode)
if (settingsPath) args.push('--settings', settingsPath)

if (fallbackModel) {
if (model && fallbackModel === model) {
Expand Down
2 changes: 2 additions & 0 deletions src/claude/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export interface QueryOptions {
fallbackModel?: string
strictMcpConfig?: boolean
canCallTool?: CanCallToolCallback
/** Path to a settings JSON file to pass to Claude via --settings */
settingsPath?: string
}

/**
Expand Down
Loading