Skip to content

Commit 71f76c3

Browse files
committed
fix: session hook cleanup and timeout
1 parent 76a1495 commit 71f76c3

File tree

5 files changed

+90
-11
lines changed

5 files changed

+90
-11
lines changed

scripts/session_hook_forwarder.cjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Session Hook Forwarder
4+
*
5+
* This script is executed by Claude's SessionStart hook.
6+
* It reads JSON data from stdin and forwards it to Happy's hook server.
7+
*
8+
* Usage: echo '{"session_id":"..."}' | node session_hook_forwarder.cjs <port>
9+
*/
10+
11+
const http = require('http');
12+
13+
const port = parseInt(process.argv[2], 10);
14+
15+
if (!port || isNaN(port)) {
16+
process.exit(1);
17+
}
18+
19+
const chunks = [];
20+
21+
process.stdin.on('data', (chunk) => {
22+
chunks.push(chunk);
23+
});
24+
25+
process.stdin.on('end', () => {
26+
const body = Buffer.concat(chunks);
27+
28+
const req = http.request({
29+
host: '127.0.0.1',
30+
port: port,
31+
method: 'POST',
32+
path: '/hook/session-start',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
'Content-Length': body.length
36+
}
37+
}, (res) => {
38+
res.resume(); // Drain response
39+
});
40+
41+
req.on('error', () => {
42+
// Silently ignore errors - don't break Claude
43+
});
44+
45+
req.end(body);
46+
});
47+
48+
process.stdin.resume();
49+

src/claude/claudeLocal.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,5 +227,3 @@ export async function claudeLocal(opts: {
227227

228228
return effectiveSessionId;
229229
}
230-
231-
export type ClaudeLocalResult = string | null;

src/claude/session.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export class Session {
2323

2424
/** Callbacks to be notified when session ID is found/changed */
2525
private sessionFoundCallbacks: ((sessionId: string) => void)[] = [];
26+
27+
/** Keep alive interval reference for cleanup */
28+
private keepAliveInterval: NodeJS.Timeout;
2629

2730
constructor(opts: {
2831
api: ApiClient,
@@ -54,10 +57,19 @@ export class Session {
5457

5558
// Start keep alive
5659
this.client.keepAlive(this.thinking, this.mode);
57-
setInterval(() => {
60+
this.keepAliveInterval = setInterval(() => {
5861
this.client.keepAlive(this.thinking, this.mode);
5962
}, 2000);
6063
}
64+
65+
/**
66+
* Cleanup resources (call when session is no longer needed)
67+
*/
68+
cleanup = (): void => {
69+
clearInterval(this.keepAliveInterval);
70+
this.sessionFoundCallbacks = [];
71+
logger.debug('[Session] Cleaned up resources');
72+
}
6173

6274
onThinkingChange = (thinking: boolean) => {
6375
this.thinking = thinking;
@@ -113,14 +125,21 @@ export class Session {
113125

114126
/**
115127
* Consume one-time Claude flags from claudeArgs after Claude spawn
116-
* Currently handles: --resume (with or without session ID)
128+
* Handles: --resume (with or without session ID), --continue
117129
*/
118130
consumeOneTimeFlags = (): void => {
119131
if (!this.claudeArgs) return;
120132

121133
const filteredArgs: string[] = [];
122134
for (let i = 0; i < this.claudeArgs.length; i++) {
123-
if (this.claudeArgs[i] === '--resume') {
135+
const arg = this.claudeArgs[i];
136+
137+
if (arg === '--continue') {
138+
logger.debug('[Session] Consumed --continue flag');
139+
continue;
140+
}
141+
142+
if (arg === '--resume') {
124143
// Check if next arg looks like a UUID (contains dashes and alphanumeric)
125144
if (i + 1 < this.claudeArgs.length) {
126145
const nextArg = this.claudeArgs[i + 1];
@@ -137,9 +156,10 @@ export class Session {
137156
// --resume at the end of args
138157
logger.debug('[Session] Consumed --resume flag (no session ID)');
139158
}
140-
} else {
141-
filteredArgs.push(this.claudeArgs[i]);
159+
continue;
142160
}
161+
162+
filteredArgs.push(arg);
143163
}
144164

145165
this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined;

src/claude/utils/generateHookSettings.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
* to notify our HTTP server when sessions change (new session, resume, compact, etc.)
66
*/
77

8-
import { join } from 'node:path';
8+
import { join, resolve } from 'node:path';
99
import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs';
1010
import { configuration } from '@/configuration';
1111
import { logger } from '@/ui/logger';
12+
import { projectPath } from '@/projectPath';
1213

1314
/**
1415
* Generate a temporary settings file with SessionStart hook configuration
@@ -24,9 +25,9 @@ export function generateHookSettingsFile(port: number): string {
2425
const filename = `session-hook-${process.pid}.json`;
2526
const filepath = join(hooksDir, filename);
2627

27-
// Node one-liner that reads stdin and POSTs it to our server
28-
// This command is executed by Claude when SessionStart hook fires
29-
const hookCommand = `node -e 'const http=require("http");const chunks=[];process.stdin.on("data",c=>chunks.push(c));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()});req.on("error",()=>{});req.end(body)});process.stdin.resume()'`;
28+
// Path to the hook forwarder script
29+
const forwarderScript = resolve(projectPath(), 'scripts', 'session_hook_forwarder.cjs');
30+
const hookCommand = `node "${forwarderScript}" ${port}`;
3031

3132
const settings = {
3233
hooks: {

src/claude/utils/startHookServer.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,21 @@ export async function startHookServer(options: HookServerOptions): Promise<HookS
4848
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
4949
// Only handle POST to /hook/session-start
5050
if (req.method === 'POST' && req.url === '/hook/session-start') {
51+
// Set timeout to prevent hanging if Claude doesn't close stdin
52+
const timeout = setTimeout(() => {
53+
if (!res.headersSent) {
54+
logger.debug('[hookServer] Request timeout');
55+
res.writeHead(408).end('timeout');
56+
}
57+
}, 5000);
58+
5159
try {
5260
const chunks: Buffer[] = [];
5361
for await (const chunk of req) {
5462
chunks.push(chunk as Buffer);
5563
}
64+
clearTimeout(timeout);
65+
5666
const body = Buffer.concat(chunks).toString('utf-8');
5767
logger.debug('[hookServer] Received session hook:', body);
5868

@@ -74,6 +84,7 @@ export async function startHookServer(options: HookServerOptions): Promise<HookS
7484

7585
res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok');
7686
} catch (error) {
87+
clearTimeout(timeout);
7788
logger.debug('[hookServer] Error handling session hook:', error);
7889
if (!res.headersSent) {
7990
res.writeHead(500).end('error');

0 commit comments

Comments
 (0)