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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ pnpm-lock.yaml
.happy/

**/*.log
.release-notes-temp.md
.release-notes-temp.md
development.md
development-railway.md
28 changes: 28 additions & 0 deletions src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,34 @@ export class ApiSessionClient extends EventEmitter {
});
}

/**
* Send session limit alert to the Happy app
*/
sendSessionLimitAlert(limitMessage: string) {
const content: MessageContent = {
role: 'agent',
content: {
type: 'output',
data: {
type: 'session_limit_alert',
message: limitMessage,
timestamp: new Date().toISOString(),
sessionId: this.sessionId
}
},
meta: {
sentFrom: 'cli'
}
};

logger.debug(`[SESSION_LIMIT] Sending session limit alert: ${limitMessage}`);
const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content));
this.socket.emit('message', {
sid: this.sessionId,
message: encrypted
});
}

/**
* Send a ping message to keep the connection alive
*/
Expand Down
5 changes: 5 additions & 0 deletions src/claude/claudeLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
if (message.type !== 'summary') {
session.client.sendClaudeSessionMessage(message)
}
},
onSessionLimit: (limitMessage) => {
logger.warn(`[SESSION_LIMIT] Claude session limit reached: ${limitMessage}`);
// Send a special session limit event to the Happy app
session.client.sendSessionLimitAlert(limitMessage);
}
});

Expand Down
1 change: 1 addition & 0 deletions src/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const RawJSONLinesSchema = z.discriminatedUnion("type", [
z.object({
uuid: z.string(),
type: z.literal("assistant"),
isApiErrorMessage: z.boolean().optional(), // Used for detecting API errors like session limits
message: z.object({// Entire message used in getMessageKey()
usage: UsageSchema.optional(), // Used in apiSession.ts
content: z.any() // Used in tests
Expand Down
174 changes: 174 additions & 0 deletions src/claude/utils/__tests__/sessionLimitDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createSessionScanner } from '../sessionScanner';
import { RawJSONLines } from '../../types';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

describe('Session Limit Detection', () => {
let testDir: string;
let projectDir: string;
let mockOnMessage: any;
let mockOnSessionLimit: any;

beforeEach(async () => {
testDir = join(tmpdir(), `session-limit-test-${Date.now()}`);

// Set CLAUDE_CONFIG_DIR to use our test directory
process.env.CLAUDE_CONFIG_DIR = join(testDir, '.claude');

// Create the project directory structure that getProjectPath() generates
// getProjectPath converts the working directory path to a project ID by replacing special chars with '-'
const workingDir = join(testDir, 'project');
await mkdir(workingDir, { recursive: true });

const projectId = workingDir.replace(/[\\\/\.:]/g, '-');
projectDir = join(testDir, '.claude', 'projects', projectId);
await mkdir(projectDir, { recursive: true });

mockOnMessage = vi.fn();
mockOnSessionLimit = vi.fn();
});

afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
delete process.env.CLAUDE_CONFIG_DIR;
});

it('should detect 5-hour limit messages in Claude sessions', async () => {
const sessionId = 'test-session-123';
const sessionFile = join(projectDir, `${sessionId}.jsonl`);

// Create realistic session file content with user message followed by limit error
// This matches the actual format found in real Claude logs
const userMessage: RawJSONLines = {
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/test/path',
sessionId,
version: '1.0.113',
gitBranch: '',
type: 'user',
message: { role: 'user', content: 'Test message' },
uuid: 'user-message-uuid',
timestamp: '2025-09-15T21:41:29.355Z'
};

const limitMessage: RawJSONLines = {
parentUuid: 'user-message-uuid',
isSidechain: false,
userType: 'external',
cwd: '/test/path',
sessionId,
version: '1.0.113',
gitBranch: '',
type: 'assistant',
uuid: 'limit-message-uuid',
timestamp: '2025-09-15T21:41:29.746Z',
message: {
id: 'limit-msg-id',
container: null,
model: '<synthetic>',
role: 'assistant',
stop_reason: 'stop_sequence',
stop_sequence: '',
type: 'message',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
},
content: [{ type: 'text', text: '5-hour limit reached ∙ resets 5pm' }]
},
isApiErrorMessage: true
};

// Write both messages to the session file
const fileContent = JSON.stringify(userMessage) + '\n' + JSON.stringify(limitMessage) + '\n';
await writeFile(sessionFile, fileContent);

const workingDir = join(testDir, 'project');
const scanner = await createSessionScanner({
sessionId: null,
workingDirectory: workingDir,
onMessage: mockOnMessage,
onSessionLimit: mockOnSessionLimit
});

// Trigger the scanner to process the session
scanner.onNewSession(sessionId);

// Wait longer for the sync mechanism to process the files
// The scanner runs sync every 3 seconds, so we need to wait for at least one cycle
await new Promise(resolve => setTimeout(resolve, 1000));

// Verify the session limit was detected
expect(mockOnSessionLimit).toHaveBeenCalledWith('5-hour limit reached ∙ resets 5pm');
expect(mockOnMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'assistant',
isApiErrorMessage: true
}));

await scanner.cleanup();
});

it('should not trigger session limit for regular assistant messages', async () => {
const sessionId = 'test-session-456';
const sessionFile = join(projectDir, `${sessionId}.jsonl`);

// Create a mock session file with a regular message
const regularMessage: RawJSONLines = {
parentUuid: 'parent-uuid',
isSidechain: false,
userType: 'external',
cwd: '/test/path',
sessionId,
version: '1.0.113',
gitBranch: '',
type: 'assistant',
uuid: 'regular-message-uuid',
timestamp: '2025-09-15T21:41:29.746Z',
message: {
id: 'regular-msg-id',
container: null,
model: 'claude-3-5-sonnet-20241022',
role: 'assistant',
stop_reason: 'end_turn',
stop_sequence: null,
type: 'message',
usage: {
input_tokens: 10,
output_tokens: 25,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: 'standard'
},
content: [{ type: 'text', text: 'Hello! How can I help you today?' }]
}
// Note: no isApiErrorMessage field for regular messages
};

await writeFile(sessionFile, JSON.stringify(regularMessage) + '\n');

const workingDir = join(testDir, 'project');
const scanner = await createSessionScanner({
sessionId: null,
workingDirectory: workingDir,
onMessage: mockOnMessage,
onSessionLimit: mockOnSessionLimit
});

scanner.onNewSession(sessionId);

// Wait a bit for the scanner to process
await new Promise(resolve => setTimeout(resolve, 1000));

// Verify the session limit was NOT triggered
expect(mockOnSessionLimit).not.toHaveBeenCalled();
expect(mockOnMessage).toHaveBeenCalledWith(regularMessage);

await scanner.cleanup();
});
});
17 changes: 17 additions & 0 deletions src/claude/utils/sessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function createSessionScanner(opts: {
sessionId: string | null,
workingDirectory: string
onMessage: (message: RawJSONLines) => void
onSessionLimit?: (limitMessage: string) => void
}) {

// Resolve project directory
Expand Down Expand Up @@ -51,6 +52,22 @@ export async function createSessionScanner(opts: {
continue;
}
processedMessageKeys.add(key);

// Check for session limit errors
if (file.type === 'assistant' && file.isApiErrorMessage && opts.onSessionLimit) {
const content = file.message.content;
if (Array.isArray(content)) {
for (const item of content) {
if (item.type === 'text' && typeof item.text === 'string') {
if (item.text.includes('hour limit reached') || item.text.includes('5-hour limit')) {
logger.debug(`[SESSION_SCANNER] Detected session limit: ${item.text}`);
opts.onSessionLimit(item.text);
}
}
}
}
}

opts.onMessage(file);
}
}
Expand Down
Loading