mirror of
https://github.com/letta-ai/claude-subconscious.git
synced 2026-04-25 17:04:56 +02:00
fix(windows): eliminate console window flashes using PseudoConsole
On Windows 11 / Windows Terminal, hook execution caused visible console window popups. This replaces the simple npx wrapper (silent-npx.js) with a PseudoConsole (ConPTY) + CREATE_NO_WINDOW approach that runs scripts in a headless console session. New files: - SilentLauncher.cs: C# launcher creating a PseudoConsole with CREATE_NO_WINDOW, stdin/stdout via temp files, and --import tsx/esm for single-process execution - silent-launcher.exe: Compiled as AnyCPU winexe (works on x64 and ARM64) - stdio-preload.cjs: Node.js --require script for temp file I/O - silent-npx.cjs: Cross-platform shim that delegates to silent-launcher.exe on Windows, runs tsx directly elsewhere Also fixes: - Letta API message sync: bumped limit from 50 to 300 and added date sorting (API does not guarantee newest-first ordering) - Background workers on Windows: spawn through silent-launcher.exe with detached:true so workers survive PseudoConsole closure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
300
hooks/SilentLauncher.cs
Normal file
300
hooks/SilentLauncher.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
// Silent launcher: PseudoConsole + CREATE_NO_WINDOW (no STARTF_USESTDHANDLES)
|
||||
// Stdin/stdout delivered via temp files + --require preload.
|
||||
// This is the ONLY combination that eliminates Windows Terminal popup on Win11 26300.
|
||||
class SilentLauncher
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
static extern bool CreateProcess(
|
||||
string lpApplicationName, StringBuilder lpCommandLine,
|
||||
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
|
||||
bool bInheritHandles, uint dwCreationFlags,
|
||||
IntPtr lpEnvironment, string lpCurrentDirectory,
|
||||
ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe,
|
||||
ref SECURITY_ATTRIBUTES lpPipeAttributes, uint nSize);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool ReadFile(IntPtr hFile, byte[] lpBuffer, uint nNumberOfBytesToRead,
|
||||
out uint lpNumberOfBytesRead, IntPtr lpOverlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool WriteFile(IntPtr hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite,
|
||||
out uint lpNumberOfBytesWritten, IntPtr lpOverlapped);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr GetStdHandle(int nStdHandle);
|
||||
|
||||
// ---- PseudoConsole ----
|
||||
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
|
||||
static extern int CreatePseudoConsole(COORD size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
|
||||
static extern void ClosePseudoConsole(IntPtr hPC);
|
||||
|
||||
// ---- Thread Attribute List ----
|
||||
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
|
||||
static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount,
|
||||
int dwFlags, ref IntPtr lpSize);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true, EntryPoint = "UpdateProcThreadAttribute")]
|
||||
static extern bool UpdateProcThreadAttributeList(IntPtr lpAttributeList, uint dwFlags,
|
||||
IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
|
||||
static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
|
||||
|
||||
// ---- Environment ----
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
static extern IntPtr GetEnvironmentStrings();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
static extern bool FreeEnvironmentStrings(IntPtr lpszEnvironmentBlock);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern uint GetLastError();
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct COORD { public short X; public short Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct SECURITY_ATTRIBUTES
|
||||
{
|
||||
public int nLength; public IntPtr lpSecurityDescriptor; public bool bInheritHandle;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct STARTUPINFO
|
||||
{
|
||||
public int cb; public string lpReserved; public string lpDesktop; public string lpTitle;
|
||||
public int dwX; public int dwY; public int dwXSize; public int dwYSize;
|
||||
public int dwXCountChars; public int dwYCountChars; public int dwFillAttribute;
|
||||
public uint dwFlags; public ushort wShowWindow; public ushort cbReserved2;
|
||||
public IntPtr lpReserved2; public IntPtr hStdInput; public IntPtr hStdOutput; public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct STARTUPINFOEX
|
||||
{
|
||||
public STARTUPINFO StartupInfo;
|
||||
public IntPtr lpAttributeList;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess; public IntPtr hThread; public uint dwProcessId; public uint dwThreadId;
|
||||
}
|
||||
|
||||
const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
|
||||
const uint CREATE_NO_WINDOW = 0x08000000;
|
||||
const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
|
||||
const uint INFINITE = 0xFFFFFFFF;
|
||||
const int STD_OUTPUT_HANDLE = -11;
|
||||
const int STD_INPUT_HANDLE = -10;
|
||||
static readonly IntPtr PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = (IntPtr)0x00020016;
|
||||
|
||||
// Build a Unicode environment block with extra vars prepended
|
||||
static IntPtr BuildEnvironmentBlock(string extraVars)
|
||||
{
|
||||
// Get current environment
|
||||
IntPtr envPtr = GetEnvironmentStrings();
|
||||
// Parse into string: each var is null-terminated, block ends with double null
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(extraVars); // already formatted as "KEY=VALUE\0KEY=VALUE\0"
|
||||
int offset = 0;
|
||||
while (true)
|
||||
{
|
||||
string entry = Marshal.PtrToStringUni(envPtr + offset * 2);
|
||||
if (string.IsNullOrEmpty(entry)) break;
|
||||
sb.Append(entry);
|
||||
sb.Append('\0');
|
||||
offset += entry.Length + 1;
|
||||
}
|
||||
FreeEnvironmentStrings(envPtr);
|
||||
sb.Append('\0'); // double null terminator
|
||||
string block = sb.ToString();
|
||||
IntPtr blockPtr = Marshal.StringToHGlobalUni(block);
|
||||
return blockPtr;
|
||||
}
|
||||
|
||||
static int Main(string[] args)
|
||||
{
|
||||
if (args.Length == 0) return 1;
|
||||
|
||||
// Read all stdin from parent into memory
|
||||
IntPtr parentStdin = GetStdHandle(STD_INPUT_HANDLE);
|
||||
var allData = new MemoryStream();
|
||||
byte[] readBuf = new byte[4096];
|
||||
uint bytesRead;
|
||||
while (ReadFile(parentStdin, readBuf, (uint)readBuf.Length, out bytesRead, IntPtr.Zero) && bytesRead > 0)
|
||||
{
|
||||
allData.Write(readBuf, 0, (int)bytesRead);
|
||||
}
|
||||
byte[] stdinData = allData.ToArray();
|
||||
|
||||
// Create temp files for stdin and stdout
|
||||
string tempDir = Path.GetTempPath();
|
||||
string stdinFile = Path.Combine(tempDir, "sl-stdin-" + System.Diagnostics.Process.GetCurrentProcess().Id + ".tmp");
|
||||
string stdoutFile = Path.Combine(tempDir, "sl-stdout-" + System.Diagnostics.Process.GetCurrentProcess().Id + ".tmp");
|
||||
File.WriteAllBytes(stdinFile, stdinData);
|
||||
File.WriteAllText(stdoutFile, ""); // create empty
|
||||
|
||||
// Find preload path (same directory as this exe)
|
||||
string exeDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
|
||||
string preloadPath = Path.Combine(exeDir, "stdio-preload.cjs");
|
||||
|
||||
// Build command line: node --require preload --import tsx/esm script.ts
|
||||
// Use tsx as a loader (--import) instead of tsx CLI (which spawns a child process).
|
||||
// This runs everything in ONE process so --require preload captures script output.
|
||||
var cmdLine = new StringBuilder();
|
||||
cmdLine.Append(args[0]); // "node"
|
||||
cmdLine.Append(" --require \"");
|
||||
cmdLine.Append(preloadPath);
|
||||
cmdLine.Append('"');
|
||||
|
||||
// Find tsx ESM loader: derive from tsx CLI path (args[1])
|
||||
// args[1] is like ".../node_modules/tsx/dist/cli.mjs"
|
||||
// We need ".../node_modules/tsx/dist/esm/index.mjs" as file:// URL
|
||||
string tsxCliPath = args[1];
|
||||
string tsxDir = Path.GetDirectoryName(tsxCliPath); // .../tsx/dist
|
||||
string tsxEsmPath = Path.Combine(tsxDir, "esm", "index.mjs");
|
||||
// Convert to file:// URL (forward slashes, no quotes needed)
|
||||
string tsxEsmUrl = "file:///" + tsxEsmPath.Replace('\\', '/');
|
||||
cmdLine.Append(" --import ");
|
||||
cmdLine.Append(tsxEsmUrl);
|
||||
|
||||
// Add the script path (args[2+])
|
||||
for (int i = 2; i < args.Length; i++)
|
||||
{
|
||||
cmdLine.Append(' ');
|
||||
string arg = args[i];
|
||||
if (arg.Contains(" ") || arg.Contains("\""))
|
||||
{
|
||||
cmdLine.Append('"');
|
||||
cmdLine.Append(arg.Replace("\"", "\\\""));
|
||||
cmdLine.Append('"');
|
||||
}
|
||||
else cmdLine.Append(arg);
|
||||
}
|
||||
|
||||
// Build environment block with SL_STDIN_FILE and SL_STDOUT_FILE
|
||||
string extraEnv = "SL_STDIN_FILE=" + stdinFile + "\0" + "SL_STDOUT_FILE=" + stdoutFile + "\0";
|
||||
IntPtr envBlock = BuildEnvironmentBlock(extraEnv);
|
||||
|
||||
// Create pipes for PseudoConsole (required by API)
|
||||
var saNonInherit = new SECURITY_ATTRIBUTES();
|
||||
saNonInherit.nLength = Marshal.SizeOf(saNonInherit);
|
||||
saNonInherit.bInheritHandle = false;
|
||||
saNonInherit.lpSecurityDescriptor = IntPtr.Zero;
|
||||
|
||||
IntPtr ptyInR, ptyInW, ptyOutR, ptyOutW;
|
||||
CreatePipe(out ptyInR, out ptyInW, ref saNonInherit, 0);
|
||||
CreatePipe(out ptyOutR, out ptyOutW, ref saNonInherit, 0);
|
||||
|
||||
IntPtr hPC;
|
||||
var ptySize = new COORD { X = 120, Y = 30 };
|
||||
int hr = CreatePseudoConsole(ptySize, ptyInR, ptyOutW, 0, out hPC);
|
||||
if (hr != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(envBlock);
|
||||
return 1;
|
||||
}
|
||||
CloseHandle(ptyInR);
|
||||
CloseHandle(ptyOutW);
|
||||
|
||||
// Set up thread attribute list with pseudoconsole
|
||||
IntPtr attrSize = IntPtr.Zero;
|
||||
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attrSize);
|
||||
IntPtr attrList = Marshal.AllocHGlobal((int)attrSize);
|
||||
InitializeProcThreadAttributeList(attrList, 1, 0, ref attrSize);
|
||||
|
||||
IntPtr hPCBoxed = Marshal.AllocHGlobal(IntPtr.Size);
|
||||
Marshal.WriteIntPtr(hPCBoxed, hPC);
|
||||
UpdateProcThreadAttributeList(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
|
||||
hPCBoxed, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero);
|
||||
|
||||
// PseudoConsole + CREATE_NO_WINDOW: no flash, no pipe I/O
|
||||
// Stdin/stdout via temp files + preload
|
||||
var si = new STARTUPINFOEX();
|
||||
si.StartupInfo.cb = Marshal.SizeOf(si);
|
||||
// NO STARTF_USESTDHANDLES — that's what causes flashes
|
||||
si.lpAttributeList = attrList;
|
||||
|
||||
uint flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT;
|
||||
|
||||
PROCESS_INFORMATION pi;
|
||||
bool created = CreateProcess(null, cmdLine, IntPtr.Zero, IntPtr.Zero,
|
||||
false, flags,
|
||||
envBlock, null, ref si, out pi);
|
||||
|
||||
Marshal.FreeHGlobal(envBlock);
|
||||
|
||||
if (!created)
|
||||
{
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
Marshal.FreeHGlobal(attrList);
|
||||
Marshal.FreeHGlobal(hPCBoxed);
|
||||
ClosePseudoConsole(hPC);
|
||||
return 1;
|
||||
}
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
// Drain PTY output (required to prevent deadlock, but output is empty with CREATE_NO_WINDOW)
|
||||
var ptyDrainThread = new Thread(() => {
|
||||
byte[] buf = new byte[4096]; uint n;
|
||||
while (ReadFile(ptyOutR, buf, (uint)buf.Length, out n, IntPtr.Zero) && n > 0) {}
|
||||
});
|
||||
ptyDrainThread.IsBackground = true;
|
||||
ptyDrainThread.Start();
|
||||
|
||||
// Wait for child to exit
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
uint exitCode;
|
||||
GetExitCodeProcess(pi.hProcess, out exitCode);
|
||||
// Read captured stdout and relay to parent
|
||||
IntPtr parentStdout = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
try
|
||||
{
|
||||
byte[] stdoutData = File.ReadAllBytes(stdoutFile);
|
||||
if (stdoutData.Length > 0)
|
||||
{
|
||||
uint written;
|
||||
WriteFile(parentStdout, stdoutData, (uint)stdoutData.Length, out written, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Cleanup
|
||||
ClosePseudoConsole(hPC);
|
||||
ptyDrainThread.Join(2000);
|
||||
CloseHandle(ptyOutR);
|
||||
CloseHandle(ptyInW);
|
||||
CloseHandle(pi.hProcess);
|
||||
DeleteProcThreadAttributeList(attrList);
|
||||
Marshal.FreeHGlobal(attrList);
|
||||
Marshal.FreeHGlobal(hPCBoxed);
|
||||
|
||||
// Clean up temp files
|
||||
try { File.Delete(stdinFile); } catch { }
|
||||
try { File.Delete(stdoutFile); } catch { }
|
||||
|
||||
return (int)exitCode;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.js\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.ts\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.ts\"",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.js\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
@@ -23,7 +23,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.js\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/pretool_sync.ts\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/pretool_sync.ts\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
@@ -35,7 +35,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.js\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
@@ -47,7 +47,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.js\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/send_messages_to_letta.ts\"",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/send_messages_to_letta.ts\"",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
|
||||
BIN
hooks/silent-launcher.exe
Normal file
BIN
hooks/silent-launcher.exe
Normal file
Binary file not shown.
79
hooks/silent-npx.cjs
Normal file
79
hooks/silent-npx.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cross-platform launcher for Claude Subconscious hooks.
|
||||
*
|
||||
* On Windows: delegates to silent-launcher.exe which creates a headless
|
||||
* PseudoConsole (ConPTY) + CREATE_NO_WINDOW to eliminate console window
|
||||
* flashes on Windows 11 / Windows Terminal.
|
||||
*
|
||||
* On other platforms: runs tsx directly via node — no console issue.
|
||||
*
|
||||
* Called from hooks.json as:
|
||||
* node hooks/silent-npx.cjs tsx scripts/<script>.ts
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const args = process.argv.slice(2); // e.g. ['tsx', 'path/to/script.ts']
|
||||
|
||||
let child;
|
||||
|
||||
if (args[0] === 'tsx') {
|
||||
const scriptArgs = args.slice(1); // everything after 'tsx'
|
||||
const pluginRoot = path.resolve(__dirname, '..');
|
||||
const tsxCli = path.join(pluginRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
|
||||
if (isWindows) {
|
||||
const silentLauncher = path.join(__dirname, 'silent-launcher.exe');
|
||||
|
||||
if (fs.existsSync(silentLauncher) && fs.existsSync(tsxCli)) {
|
||||
// PseudoConsole + CREATE_NO_WINDOW: popup-free execution
|
||||
child = spawn(silentLauncher, ['node', tsxCli, ...scriptArgs], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
} else if (fs.existsSync(tsxCli)) {
|
||||
// Fallback: run tsx CLI directly (may flash on Windows Terminal)
|
||||
child = spawn(process.execPath, [tsxCli, ...scriptArgs], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
} else {
|
||||
// Last resort: npx through shell
|
||||
child = spawn('npx', args, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Non-Windows: no console window issues
|
||||
if (fs.existsSync(tsxCli)) {
|
||||
child = spawn(process.execPath, [tsxCli, ...scriptArgs], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} else {
|
||||
child = spawn('npx', args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-tsx command: use npx
|
||||
child = spawn('npx', args, {
|
||||
stdio: 'inherit',
|
||||
shell: isWindows,
|
||||
windowsHide: isWindows,
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error('Failed to start subprocess:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Detect platform
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Run npx with the remaining arguments
|
||||
const child = spawn('npx', process.argv.slice(2), {
|
||||
windowsHide: isWindows, // Hide console window on Windows only
|
||||
stdio: 'inherit', // Pass through stdout/stderr
|
||||
shell: isWindows // Use shell on Windows to resolve npx.cmd
|
||||
});
|
||||
|
||||
// Forward exit code
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
child.on('error', (err) => {
|
||||
console.error('Failed to start subprocess:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
50
hooks/stdio-preload.cjs
Normal file
50
hooks/stdio-preload.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Preload: delivers stdin from temp file via unshift, captures stdout to temp file.
|
||||
// Loaded via --require in the node command line.
|
||||
// Used with PseudoConsole + CREATE_NO_WINDOW where pipe I/O is not available.
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const stdoutFile = process.env.SL_STDOUT_FILE;
|
||||
const stdinFile = process.env.SL_STDIN_FILE;
|
||||
|
||||
// --- STDIN: Read from temp file, unshift onto existing Socket ---
|
||||
if (stdinFile) {
|
||||
try {
|
||||
const data = fs.readFileSync(stdinFile);
|
||||
if (data.length > 0) {
|
||||
const sock = process.stdin;
|
||||
sock.pause();
|
||||
sock.unshift(data);
|
||||
process.nextTick(() => sock.push(null));
|
||||
}
|
||||
} catch (e) { /* stdin file may not exist */ }
|
||||
}
|
||||
|
||||
// --- STDOUT/STDERR: Capture all writes to temp file ---
|
||||
if (stdoutFile) {
|
||||
try {
|
||||
const fd = fs.openSync(stdoutFile, 'a');
|
||||
|
||||
const origWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = function(chunk, encoding, callback) {
|
||||
try {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8');
|
||||
fs.writeSync(fd, buf);
|
||||
} catch (e) { /* ignore write errors */ }
|
||||
return origWrite(chunk, encoding, callback);
|
||||
};
|
||||
|
||||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = function(chunk, encoding, callback) {
|
||||
try {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8');
|
||||
fs.writeSync(fd, buf);
|
||||
} catch (e) { /* ignore write errors */ }
|
||||
return origErrWrite(chunk, encoding, callback);
|
||||
};
|
||||
|
||||
process.on('exit', () => {
|
||||
try { fs.closeSync(fd); } catch(e) {}
|
||||
});
|
||||
} catch (e) { /* stdout setup error */ }
|
||||
}
|
||||
@@ -592,14 +592,53 @@ 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';
|
||||
const child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: hookInput.cwd,
|
||||
env: process.env,
|
||||
// Windows requires shell: true for detached processes to work properly
|
||||
...(isWindows && { shell: true, windowsHide: true }),
|
||||
});
|
||||
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();
|
||||
log(`Spawned background worker (PID: ${child.pid})`);
|
||||
|
||||
|
||||
@@ -216,7 +216,9 @@ async function fetchAssistantMessages(
|
||||
return { messages: [], lastMessageId: null };
|
||||
}
|
||||
|
||||
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages?limit=50`;
|
||||
// Use a high limit because Letta returns multiple entries per logical message
|
||||
// (hidden_reasoning + assistant_message pairs), so limit=50 may not reach newest messages
|
||||
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages?limit=300`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
@@ -232,19 +234,22 @@ async function fetchAssistantMessages(
|
||||
}
|
||||
|
||||
const allMessages: LettaMessage[] = await response.json();
|
||||
debug(`Fetched ${allMessages.length} total messages from conversation`);
|
||||
|
||||
// Filter to assistant messages only
|
||||
// NOTE: API returns messages newest-first
|
||||
const assistantMessages = allMessages.filter(msg => msg.message_type === 'assistant_message');
|
||||
debug(`Found ${assistantMessages.length} assistant messages`);
|
||||
// Filter to assistant messages only, then sort by date descending (newest first)
|
||||
// The API does NOT guarantee newest-first ordering — newer messages can appear at the end
|
||||
const assistantMessages = allMessages
|
||||
.filter(msg => msg.message_type === 'assistant_message')
|
||||
.sort((a, b) => {
|
||||
const da = a.date ? new Date(a.date).getTime() : 0;
|
||||
const db = b.date ? new Date(b.date).getTime() : 0;
|
||||
return db - da; // newest first
|
||||
});
|
||||
|
||||
// Find the index of the last seen message
|
||||
// Since messages are newest-first, new messages are BEFORE lastSeenIndex (indices 0 to lastSeenIndex-1)
|
||||
let endIndex = assistantMessages.length; // Default: return all messages
|
||||
if (lastSeenMessageId) {
|
||||
const lastSeenIndex = assistantMessages.findIndex(msg => msg.id === lastSeenMessageId);
|
||||
debug(`lastSeenMessageId=${lastSeenMessageId}, lastSeenIndex=${lastSeenIndex}`);
|
||||
if (lastSeenIndex !== -1) {
|
||||
// Only return messages newer than the last seen one (before it in the array)
|
||||
endIndex = lastSeenIndex;
|
||||
@@ -343,7 +348,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
const lastBlockValues = state?.lastBlockValues || null;
|
||||
const lastSeenMessageId = state?.lastSeenMessageId || null;
|
||||
|
||||
|
||||
// Fetch agent data and messages in parallel
|
||||
const [agent, messagesResult] = await Promise.all([
|
||||
fetchAgent(apiKey, agentId),
|
||||
@@ -351,7 +356,7 @@ async function main(): Promise<void> {
|
||||
]);
|
||||
|
||||
const { messages: newMessages, lastMessageId } = messagesResult;
|
||||
|
||||
|
||||
// Detect which blocks have changed since last sync
|
||||
const changedBlocks = detectChangedBlocks(agent.blocks || [], lastBlockValues);
|
||||
|
||||
@@ -437,14 +442,53 @@ async function main(): Promise<void> {
|
||||
// Spawn background worker
|
||||
const workerScript = path.join(__dirname, 'send_worker.ts');
|
||||
const isWindows = process.platform === 'win32';
|
||||
const child = spawn(NPX_CMD, ['tsx', workerScript, payloadFile], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd,
|
||||
env: process.env,
|
||||
// Windows requires shell: true for detached processes to work properly
|
||||
...(isWindows && { shell: true, windowsHide: true }),
|
||||
});
|
||||
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();
|
||||
} catch (promptError) {
|
||||
// Don't fail the sync if prompt sending fails - just log warning
|
||||
|
||||
Reference in New Issue
Block a user