mirror of
https://github.com/letta-ai/claude-subconscious.git
synced 2026-04-25 17:04:56 +02:00
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:
@@ -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
29
hooks/build.ps1
Normal 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.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user