fix: Windows TTY crash and LETTA_HOME shell expansion

#47: Skip /dev/tty entirely on Windows — it resolves to C:\dev\tty
which doesn't exist. The .on('error') handler from v2.1.1 catches the
async error on Linux, but on Windows we shouldn't even attempt it.

#48: Expand common shell syntax ($HOME, ${HOME}, ~) in LETTA_HOME.
When set via Claude Code settings.json, env vars aren't shell-expanded,
so "$HOME" becomes a literal directory name. expandPath() now resolves
these to os.homedir(). Fixes both getDurableStateDir() call sites and
the splash screen display.

Fixes #47, fixes #48.

Written by Cameron ◯ Letta Code

"Defensive programming is the art of expecting the unexpected." - Unknown
This commit is contained in:
Cameron
2026-04-15 17:13:09 -07:00
parent 382a2832e5
commit 8143af47ee
2 changed files with 28 additions and 8 deletions

View File

@@ -115,12 +115,27 @@ export type LogFn = (message: string) => void;
// Default no-op logger
const noopLog: LogFn = () => {};
/**
* Expand common shell syntax in a path value.
* Handles $HOME, ${HOME}, and ~ when set via settings.json (no shell expansion).
*/
export function expandPath(value: string): string {
const home = os.homedir();
if (value === '$HOME' || value === '${HOME}') return home;
if (value.startsWith('$HOME/')) return path.join(home, value.slice(6));
if (value.startsWith('${HOME}/')) return path.join(home, value.slice(8));
if (value === '~') return home;
if (value.startsWith('~/')) return path.join(home, value.slice(2));
return value;
}
/**
* Get durable state directory path
* If LETTA_HOME is set, use that as the base instead of cwd
*/
export function getDurableStateDir(cwd: string): string {
const base = process.env.LETTA_HOME || cwd;
const raw = process.env.LETTA_HOME || cwd;
const base = process.env.LETTA_HOME ? expandPath(raw) : raw;
return path.join(base, '.letta', 'claude');
}

View File

@@ -32,6 +32,7 @@ import {
getMode,
getTempStateDir,
getSdkToolsMode,
expandPath,
} from './conversation_utils.js';
import { buildLettaApiUrl } from './letta_api_url.js';
@@ -64,7 +65,8 @@ interface Conversation {
// Durable storage in .letta directory
// If LETTA_HOME is set, use that as the base instead of cwd
function getDurableStateDir(cwd: string): string {
const base = process.env.LETTA_HOME || cwd;
const raw = process.env.LETTA_HOME || cwd;
const base = process.env.LETTA_HOME ? expandPath(raw) : raw;
return path.join(base, '.letta', 'claude');
}
@@ -253,12 +255,15 @@ async function main(): Promise<void> {
}
// Try to open TTY for user-visible output (bypasses Claude's capture)
// Skip on Windows — /dev/tty resolves to C:\dev\tty which doesn't exist
let tty: fs.WriteStream | null = null;
try {
tty = fs.createWriteStream('/dev/tty');
tty.on('error', () => { tty = null; }); // Handle async ENXIO when /dev/tty unavailable
} catch {
// TTY not available (e.g., non-interactive session)
if (process.platform !== 'win32') {
try {
tty = fs.createWriteStream('/dev/tty');
tty.on('error', () => { tty = null; }); // Handle async ENXIO when /dev/tty unavailable
} catch {
// TTY not available (e.g., non-interactive session)
}
}
const writeTty = (text: string) => {
@@ -305,7 +310,7 @@ async function main(): Promise<void> {
writeTty(` Server: ${baseUrl}\n`);
}
if (process.env.LETTA_HOME) {
writeTty(` Home: ${process.env.LETTA_HOME}\n`);
writeTty(` Home: ${expandPath(process.env.LETTA_HOME)}\n`);
}
writeTty('\n');
writeTty(' Learn about configuration settings:\n');