Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
a929750
fix: tmux parsing/targeting + review feedback
leeroybrun Jan 12, 2026
13db0d0
fix: address bot review (abort scope + avoid encrypt when offline)
leeroybrun Jan 12, 2026
e20c1e7
chore: address bot nits (tmux session info + test name)
leeroybrun Jan 12, 2026
d2a1d53
fix(daemon): do not apply CLI active profile to GUI-spawned sessions
leeroybrun Jan 13, 2026
c47df5d
feat(session): persist profileId in session metadata via daemon spawn
leeroybrun Jan 13, 2026
3967b46
fix(pr107): harden daemon spawn + align profile schema
leeroybrun Jan 13, 2026
50232ae
fix(security): redact spawn secrets from daemon logs
leeroybrun Jan 13, 2026
4734279
fix(pr107): redact profile secrets in doctor + align tmux tmpDir
leeroybrun Jan 13, 2026
ac5bbf1
feat(tmux): optionally persist profile env into tmux session
leeroybrun Jan 13, 2026
a0fa6eb
refactor(profiles): remove unwired startup script and local env cache
leeroybrun Jan 13, 2026
ef028b1
refactor(profiles): drop provider config objects
leeroybrun Jan 15, 2026
5488da6
feat(runtime): support bun for daemon-spawned subprocesses
leeroybrun Jan 15, 2026
b3323a6
fix(tmux): correct env, tmpdir, and session selection
leeroybrun Jan 15, 2026
c90f32d
fix(socket): restore offline buffering for sends
leeroybrun Jan 15, 2026
07797fd
fix(logging): gate debug output and redact templates
leeroybrun Jan 15, 2026
3bc1d92
refactor(rpc): accept arbitrary env var maps for spawn
leeroybrun Jan 15, 2026
f7f710c
fix(codex): harden MCP command detection
leeroybrun Jan 15, 2026
55f4abc
refactor(offline): make offline session stub safer
leeroybrun Jan 15, 2026
893c609
feat(tmux): support per-instance socket path
leeroybrun Jan 15, 2026
0445c63
test(tmux): add opt-in real tmux integration tests
leeroybrun Jan 15, 2026
f5b90fd
Merge remote-tracking branch 'upstream/main' into slopus/pr/tmux-fix-…
leeroybrun Jan 15, 2026
9932157
test(claude): align sessionScanner path mapping
leeroybrun Jan 15, 2026
8030586
docs(tmux): clarify env passing
leeroybrun Jan 16, 2026
0f1b506
fix(tmux): remove TMUX_UPDATE_ENVIRONMENT and improve tmux/session tests
leeroybrun Jan 16, 2026
2e455de
fix(claude): omit undefined profileId in metadata
leeroybrun Jan 16, 2026
9e90bf9
fix(doctor): mask env templates with defaults
leeroybrun Jan 16, 2026
d3b5dc0
test(tmux): use os.tmpdir in integration suite
leeroybrun Jan 16, 2026
adda3be
fix(daemon): cleanup CODEX_HOME temp dirs
leeroybrun Jan 16, 2026
000e0cd
Merge remote-tracking branch 'upstream/main' into slopus/pr/tmux-fix-…
leeroybrun Jan 17, 2026
48c24bc
test(claude): stabilize sessionScanner timing
leeroybrun Jan 17, 2026
05e2df6
test(tmux): relax unit test timeouts
leeroybrun Jan 17, 2026
ec80701
refactor(env): allow suppressing undefined var warnings
leeroybrun Jan 17, 2026
84640ae
feat(rpc): add preview-env handler
leeroybrun Jan 17, 2026
7523fe6
fix(tmux): trim identifiers and align parsers
leeroybrun Jan 17, 2026
63b463b
fix(env): implement default assignment semantics
leeroybrun Jan 17, 2026
2ea57ce
fix(ripgrep): improve arg parsing and exit codes
leeroybrun Jan 17, 2026
02dc2ea
test(codex): fix elicitation payload and assertions
leeroybrun Jan 17, 2026
34372fd
test(rpc): tighten preview-env test typing
leeroybrun Jan 17, 2026
d4199ea
refactor(persistence): remove any in legacy profile migration
leeroybrun Jan 17, 2026
d751c40
test(doctor): expand maskValue coverage
leeroybrun Jan 17, 2026
8ef7213
test(spawn): restore runtime override between specs
leeroybrun Jan 17, 2026
32445c6
refactor(offline): tighten offline session stub contract
leeroybrun Jan 17, 2026
b6adb15
test(tmux): tighten integration test typing and cleanup
leeroybrun Jan 17, 2026
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
12 changes: 4 additions & 8 deletions bin/happy-dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import { homedir } from 'os';
const hasNoWarnings = process.execArgv.includes('--no-warnings');
const hasNoDeprecation = process.execArgv.includes('--no-deprecation');

// Set development environment variables
process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev');
process.env.HAPPY_VARIANT = 'dev';

if (!hasNoWarnings || !hasNoDeprecation) {
// Re-execute with the flags
const __filename = fileURLToPath(import.meta.url);
const scriptPath = join(dirname(__filename), '../dist/index.mjs');

// Set development environment variables
process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev');
process.env.HAPPY_VARIANT = 'dev';

try {
execFileSync(
process.execPath,
Expand All @@ -33,9 +33,5 @@ if (!hasNoWarnings || !hasNoDeprecation) {
}
} else {
// Already have the flags, import normally
// Set development environment variables
process.env.HAPPY_HOME_DIR = join(homedir(), '.happy-dev');
process.env.HAPPY_VARIANT = 'dev';

await import('../dist/index.mjs');
}
1 change: 0 additions & 1 deletion docs/bug-fix-plan-2025-01-15-athundt.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ describe('claudeLocal --continue handling', () => {
addListener: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
on: vi.fn(),
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
stdin: { on: vi.fn(), end: vi.fn() }
Expand Down
14 changes: 13 additions & 1 deletion scripts/__tests__/ripgrep_launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ describe('Ripgrep Launcher Runtime Compatibility', () => {
expect(content).toContain('brew install ripgrep');
expect(content).toContain('winget install BurntSushi.ripgrep');
expect(content).toContain('Search functionality unavailable');
expect(content).toContain('Missing arguments: expected JSON-encoded argv');
}).not.toThrow();
});
});

it('does not treat signal termination as success', () => {
expect(() => {
const fs = require('fs');
const path = require('path');
const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8');

expect(content).not.toContain('result.status || 0');
expect(content).toContain('result.signal');
}).not.toThrow();
});
});
4 changes: 3 additions & 1 deletion scripts/claude_version_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ function detectSourceFromPath(resolvedPath) {
// Windows-specific detection (detect by path patterns, not current platform)
if (normalizedPath.includes('appdata') || normalizedPath.includes('program files') || normalizedPath.endsWith('.exe')) {
// Windows npm
if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules')) {
if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules') &&
normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('claude-code') &&
!normalizedPath.includes('.claude-code-')) {
return 'npm';
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/claude_version_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ describe('Claude Version Utils - Cross-Platform Detection', () => {
expect(result).toBe('npm');
});

it('should detect npm with different scoped packages', () => {
it('should not classify unrelated scoped npm packages as npm', () => {
const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js');
expect(result).toBe('npm');
expect(result).toBe('PATH');
});

it('should detect npm through Homebrew', () => {
Expand Down
9 changes: 9 additions & 0 deletions scripts/env-wrapper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ if (!variant || !VARIANTS[variant]) {
process.exit(1);
}

if (!command) {
console.error('Usage: node scripts/env-wrapper.js <stable|dev> <command> [...args]');
console.error('');
console.error('Examples:');
console.error(' node scripts/env-wrapper.js stable daemon start');
console.error(' node scripts/env-wrapper.js dev auth login');
process.exit(1);
}

const config = VARIANTS[variant];

// Create home directory if it doesn't exist
Expand Down
13 changes: 10 additions & 3 deletions scripts/ripgrep_launcher.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function findSystemRipgrep() {
try {
const result = execFileSync(cmd, args, {
encoding: 'utf8',
stdio: 'ignore'
stdio: ['ignore', 'pipe', 'ignore']
});

if (result) {
Expand Down Expand Up @@ -93,7 +93,9 @@ function createRipgrepWrapper(binaryPath) {
stdio: 'inherit',
cwd: process.cwd()
});
return result.status || 0;
if (typeof result.status === 'number') return result.status;
if (result.signal) return 1;
return 0;
}
};
}
Expand Down Expand Up @@ -170,6 +172,11 @@ const args = process.argv.slice(2);
// Parse the JSON-encoded arguments
let parsedArgs;
try {
if (!args[0]) {
console.error('Missing arguments: expected JSON-encoded argv as the first parameter.');
console.error('Example: node scripts/ripgrep_launcher.cjs \'["--version"]\'');
process.exit(1);
}
parsedArgs = JSON.parse(args[0]);
} catch (error) {
console.error('Failed to parse arguments:', error.message);
Expand All @@ -183,4 +190,4 @@ try {
} catch (error) {
console.error('Ripgrep error:', error.message);
process.exit(1);
}
}
7 changes: 6 additions & 1 deletion scripts/test-continue-fix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@ echo
echo "2. Testing session finder with current directory..."
node -e "
const { resolve, join } = require('path');
const { readdirSync, statSync, readFileSync } = require('fs');
const { readdirSync, statSync, readFileSync, existsSync } = require('fs');
const { homedir } = require('os');

const workingDirectory = process.cwd();
const projectId = resolve(workingDirectory).replace(/[\\\\\\/\.:]/g, '-');
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
const projectDir = join(claudeConfigDir, 'projects', projectId);

if (!existsSync(projectDir)) {
console.log('ERROR: Project directory does not exist:', projectDir);
process.exit(1);
}

const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$/i;

const files = readdirSync(projectDir)
Expand Down
108 changes: 59 additions & 49 deletions src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,64 +176,74 @@ describe('Api server error handling', () => {
connectionState.reset();
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

// Mock axios to return 500 error
mockPost.mockRejectedValue({
response: { status: 500 },
isAxiosError: true
});

const result = await api.getOrCreateSession({
tag: 'test-tag',
metadata: testMetadata,
state: null
});

expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
consoleSpy.mockRestore();
try {
// Mock axios to return 500 error
mockPost.mockRejectedValue({
response: { status: 500 },
isAxiosError: true
});

const result = await api.getOrCreateSession({
tag: 'test-tag',
metadata: testMetadata,
state: null
});

expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
} finally {
consoleSpy.mockRestore();
}
});

it('should return null when server returns 503 Service Unavailable', async () => {
connectionState.reset();
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

// Mock axios to return 503 error
mockPost.mockRejectedValue({
response: { status: 503 },
isAxiosError: true
});

const result = await api.getOrCreateSession({
tag: 'test-tag',
metadata: testMetadata,
state: null
});

expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
consoleSpy.mockRestore();
try {
// Mock axios to return 503 error
mockPost.mockRejectedValue({
response: { status: 503 },
isAxiosError: true
});

const result = await api.getOrCreateSession({
tag: 'test-tag',
metadata: testMetadata,
state: null
});

expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
} finally {
consoleSpy.mockRestore();
}
});

it('should re-throw non-connection errors', async () => {
// Mock axios to throw a different type of error (e.g., authentication error)
const authError = new Error('Invalid API key');
(authError as any).code = 'UNAUTHORIZED';
mockPost.mockRejectedValue(authError);

await expect(
api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null })
).rejects.toThrow('Failed to get or create session: Invalid API key');

// Should not show the offline mode message
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
consoleSpy.mockRestore();

try {
// Mock axios to throw a different type of error (e.g., authentication error)
const authError = new Error('Invalid API key');
(authError as any).code = 'UNAUTHORIZED';
mockPost.mockRejectedValue(authError);

await expect(
api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null })
).rejects.toThrow('Failed to get or create session: Invalid API key');

// Should not show the offline mode message
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining('⚠️ Happy server unreachable')
);
} finally {
consoleSpy.mockRestore();
}
});
});

Expand Down Expand Up @@ -310,4 +320,4 @@ describe('Api server error handling', () => {
consoleSpy.mockRestore();
});
});
});
});
24 changes: 20 additions & 4 deletions src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,30 @@ export class ApiMachineClient {
}: MachineRpcHandlers) {
// Register spawn session handler
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables } = params || {};
logger.debug(`[API MACHINE] Spawning session with params: ${JSON.stringify(params)}`);
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables, profileId } = params || {};
const envKeys = environmentVariables && typeof environmentVariables === 'object'
? Object.keys(environmentVariables as Record<string, unknown>)
: [];
const maxEnvKeysToLog = 20;
const envKeySample = envKeys.slice(0, maxEnvKeysToLog);
logger.debug('[API MACHINE] Spawning session', {
directory,
sessionId,
machineId,
agent,
approvedNewDirectoryCreation,
profileId,
hasToken: !!token,
environmentVariableCount: envKeys.length,
environmentVariableKeySample: envKeySample,
environmentVariableKeysTruncated: envKeys.length > maxEnvKeysToLog,
});

if (!directory) {
throw new Error('Directory is required');
}

const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables });
const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token, environmentVariables, profileId });

switch (result.type) {
case 'success':
Expand Down Expand Up @@ -327,4 +343,4 @@ export class ApiMachineClient {
logger.debug('[API MACHINE] Socket closed');
}
}
}
}
31 changes: 29 additions & 2 deletions src/api/apiSession.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApiSessionClient } from './apiSession';
import type { RawJSONLines } from '@/claude/types';

// Use vi.hoisted to ensure mock function is available when vi.mock factory runs
const { mockIo } = vi.hoisted(() => ({
Expand All @@ -20,10 +21,12 @@ describe('ApiSessionClient connection handling', () => {

// Mock socket.io client
mockSocket = {
connected: false,
connect: vi.fn(),
on: vi.fn(),
off: vi.fn(),
disconnect: vi.fn()
disconnect: vi.fn(),
emit: vi.fn(),
};

mockIo.mockReturnValue(mockSocket);
Expand Down Expand Up @@ -65,8 +68,32 @@ describe('ApiSessionClient connection handling', () => {
expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
});

it('emits messages even when disconnected (socket.io will buffer)', () => {
mockSocket.connected = false;

const client = new ApiSessionClient('fake-token', mockSession);

const payload: RawJSONLines = {
type: 'user',
uuid: 'test-uuid',
message: {
content: 'hello',
},
} as const;

client.sendClaudeSessionMessage(payload);

expect(mockSocket.emit).toHaveBeenCalledWith(
'message',
expect.objectContaining({
sid: mockSession.id,
message: expect.any(String),
})
);
});

afterEach(() => {
consoleSpy.mockRestore();
vi.restoreAllMocks();
});
});
});
Loading