fix(windows): add Win10 compat and deduplicate worker spawn logic

- Add dual P/Invoke for UpdateProcThreadAttribute (Win11 26300+) vs
  UpdateProcThreadAttributeList (Win10/older) with EntryPointNotFoundException
  fallback in SilentLauncher.cs
- Extract shared spawnSilentWorker() utility into conversation_utils.ts,
  replacing ~50 duplicated lines in send_messages_to_letta.ts and
  sync_letta_memory.ts
- Add build.ps1 for reproducible silent-launcher.exe builds
- Rebuild silent-launcher.exe with updated source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
scrossle
2026-02-13 21:28:23 -08:00
parent 8adae7cdbb
commit 36898f1318
6 changed files with 131 additions and 108 deletions

View File

@@ -53,10 +53,26 @@ class SilentLauncher
static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount,
int dwFlags, ref IntPtr lpSize);
// Win11 26300+ renamed UpdateProcThreadAttributeList to UpdateProcThreadAttribute.
// Declare both entry points and try the new name first, falling back to the old one.
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true, EntryPoint = "UpdateProcThreadAttribute")]
static extern bool UpdateProcThreadAttributeList(IntPtr lpAttributeList, uint dwFlags,
static extern bool UpdateProcThreadAttribute_New(IntPtr lpAttributeList, uint dwFlags,
IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true, EntryPoint = "UpdateProcThreadAttributeList")]
static extern bool UpdateProcThreadAttribute_Old(IntPtr lpAttributeList, uint dwFlags,
IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
static bool UpdateProcThreadAttributeSafe(IntPtr attrList, uint flags,
IntPtr attr, IntPtr val, IntPtr size, IntPtr prev, IntPtr retSize)
{
try { return UpdateProcThreadAttribute_New(attrList, flags, attr, val, size, prev, retSize); }
catch (EntryPointNotFoundException)
{
return UpdateProcThreadAttribute_Old(attrList, flags, attr, val, size, prev, retSize);
}
}
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
@@ -227,7 +243,7 @@ class SilentLauncher
IntPtr hPCBoxed = Marshal.AllocHGlobal(IntPtr.Size);
Marshal.WriteIntPtr(hPCBoxed, hPC);
UpdateProcThreadAttributeList(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
UpdateProcThreadAttributeSafe(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
hPCBoxed, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
// PseudoConsole + CREATE_NO_WINDOW: no flash, no pipe I/O

29
hooks/build.ps1 Normal file
View File

@@ -0,0 +1,29 @@
# Build silent-launcher.exe from SilentLauncher.cs
# Requires .NET Framework csc.exe (ships with Windows)
#
# Usage: powershell -ExecutionPolicy Bypass -File hooks/build.ps1
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$cs = Join-Path $scriptDir 'SilentLauncher.cs'
$exe = Join-Path $scriptDir 'silent-launcher.exe'
# Find csc.exe from the .NET Framework directory
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path $csc)) {
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe'
}
if (-not (Test-Path $csc)) {
Write-Error "csc.exe not found. Ensure .NET Framework 4.x is installed."
exit 1
}
Write-Host "Building silent-launcher.exe ..."
& $csc /nologo /out:$exe /platform:anycpu /target:winexe $cs
if ($LASTEXITCODE -eq 0) {
Write-Host "Built: $exe"
} else {
Write-Error "Build failed (exit code $LASTEXITCODE)"
exit $LASTEXITCODE
}

Binary file not shown.

View File

@@ -5,6 +5,12 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { fileURLToPath } from 'url';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
@@ -583,3 +589,77 @@ ${locationInfo}
${formattedBlocks}
</letta_memory_blocks>`;
}
// ============================================
// Silent Worker Spawning
// ============================================
// Windows compatibility: npx needs to be npx.cmd on Windows
const NPX_CMD = process.platform === 'win32' ? 'npx.cmd' : 'npx';
/**
* Spawn a background worker process that survives the parent hook's exit.
*
* On Windows, uses silent-launcher.exe (PseudoConsole + CREATE_NO_WINDOW)
* to avoid console window flashes. Falls back gracefully when the launcher
* or tsx CLI is not available.
*
* On other platforms, spawns via npx tsx as a detached process.
*/
export function spawnSilentWorker(
workerScript: string,
payloadFile: string,
cwd: string,
): ChildProcess {
const isWindows = process.platform === 'win32';
let child: ChildProcess;
if (isWindows) {
// On Windows, spawn workers through silent-launcher.exe (a winexe).
// detached:true is safe on a winexe (no console flash).
// The worker gets its own PseudoConsole, so it survives the main
// script's PseudoConsole being closed by the parent launcher.
const silentLauncher = path.join(__dirname, '..', 'hooks', 'silent-launcher.exe');
const tsxCli = path.join(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs');
// Clear SL_ env vars so the worker's launcher instance gets a clean slate
const workerEnv = { ...process.env };
delete workerEnv.SL_STDIN_FILE;
delete workerEnv.SL_STDOUT_FILE;
if (fs.existsSync(silentLauncher) && fs.existsSync(tsxCli)) {
child = spawn(silentLauncher, ['node', tsxCli, workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd,
env: workerEnv,
windowsHide: true,
});
} else if (fs.existsSync(tsxCli)) {
// Fallback: direct node (may be killed when PseudoConsole closes)
child = spawn(process.execPath, [tsxCli, workerScript, payloadFile], {
stdio: 'ignore',
cwd,
env: workerEnv,
windowsHide: true,
});
} else {
// Fallback: use npx through shell (may flash console window)
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
stdio: 'ignore',
cwd,
env: workerEnv,
shell: true,
windowsHide: true,
});
}
} else {
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd,
env: process.env,
});
}
child.unref();
return child;
}

View File

@@ -24,7 +24,6 @@
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { getAgentId } from './agent_config.js';
import {
@@ -33,6 +32,7 @@ import {
saveSyncState,
getOrCreateConversation,
getSyncStateFile,
spawnSilentWorker,
SyncState,
LogFn,
getMode,
@@ -42,9 +42,6 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Windows compatibility: npx needs to be npx.cmd on Windows
const NPX_CMD = process.platform === 'win32' ? 'npx.cmd' : 'npx';
// Configuration
const TEMP_STATE_DIR = '/tmp/letta-claude-sync'; // Temp state (logs, etc.)
const LOG_FILE = path.join(TEMP_STATE_DIR, 'send_messages.log');
@@ -591,55 +588,7 @@ Write your response as if speaking directly to Claude Code.
// Spawn worker as detached background process
const workerScript = path.join(__dirname, 'send_worker.ts');
const isWindows = process.platform === 'win32';
let child;
if (isWindows) {
// On Windows, spawn workers through silent-launcher.exe (a winexe).
// detached:true is safe on a winexe (no console flash).
// The worker gets its own PseudoConsole, so it survives the main
// script's PseudoConsole being closed by the parent launcher.
const silentLauncher = path.join(__dirname, '..', 'hooks', 'silent-launcher.exe');
const tsxCli = path.join(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs');
// Clear SL_ env vars so the worker's launcher instance gets a clean slate
const workerEnv = { ...process.env };
delete workerEnv.SL_STDIN_FILE;
delete workerEnv.SL_STDOUT_FILE;
if (fs.existsSync(silentLauncher) && fs.existsSync(tsxCli)) {
child = spawn(silentLauncher, ['node', tsxCli, workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd: hookInput.cwd,
env: workerEnv,
windowsHide: true,
});
} else if (fs.existsSync(tsxCli)) {
// Fallback: direct node (may be killed when PseudoConsole closes)
child = spawn(process.execPath, [tsxCli, workerScript, payloadFile], {
stdio: 'ignore',
cwd: hookInput.cwd,
env: workerEnv,
windowsHide: true,
});
} else {
// Fallback: use npx through shell (may flash console window)
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
stdio: 'ignore',
cwd: hookInput.cwd,
env: workerEnv,
shell: true,
windowsHide: true,
});
}
} else {
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd: hookInput.cwd,
env: process.env,
});
}
child.unref();
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned background worker (PID: ${child.pid})`);
log('Hook completed (worker running in background)');

View File

@@ -20,7 +20,6 @@
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { getAgentId } from './agent_config.js';
import {
@@ -29,6 +28,7 @@ import {
getOrCreateConversation,
getSyncStateFile,
lookupConversation,
spawnSilentWorker,
SyncState,
Agent,
MemoryBlock,
@@ -44,9 +44,6 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Windows compatibility: npx needs to be npx.cmd on Windows
const NPX_CMD = process.platform === 'win32' ? 'npx.cmd' : 'npx';
// Configuration
const DEBUG = process.env.LETTA_DEBUG === '1';
@@ -441,55 +438,7 @@ async function main(): Promise<void> {
// Spawn background worker
const workerScript = path.join(__dirname, 'send_worker.ts');
const isWindows = process.platform === 'win32';
let child;
if (isWindows) {
// On Windows, spawn workers through silent-launcher.exe (a winexe).
// detached:true is safe on a winexe (no console flash).
// The worker gets its own PseudoConsole, so it survives the main
// script's PseudoConsole being closed by the parent launcher.
const silentLauncher = path.join(__dirname, '..', 'hooks', 'silent-launcher.exe');
const tsxCli = path.join(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs');
// Clear SL_ env vars so the worker's launcher instance gets a clean slate
const workerEnv = { ...process.env };
delete workerEnv.SL_STDIN_FILE;
delete workerEnv.SL_STDOUT_FILE;
if (fs.existsSync(silentLauncher) && fs.existsSync(tsxCli)) {
child = spawn(silentLauncher, ['node', tsxCli, workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd,
env: workerEnv,
windowsHide: true,
});
} else if (fs.existsSync(tsxCli)) {
// Fallback: direct node (may be killed when PseudoConsole closes)
child = spawn(process.execPath, [tsxCli, workerScript, payloadFile], {
stdio: 'ignore',
cwd,
env: workerEnv,
windowsHide: true,
});
} else {
// Fallback: use npx through shell (may flash console window)
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
stdio: 'ignore',
cwd,
env: workerEnv,
shell: true,
windowsHide: true,
});
}
} else {
child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
detached: true,
stdio: 'ignore',
cwd,
env: process.env,
});
}
child.unref();
spawnSilentWorker(workerScript, payloadFile, cwd);
} catch (promptError) {
// Don't fail the sync if prompt sending fails - just log warning
console.error(`Warning: Failed to send prompt to Letta: ${promptError}`);