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:
scrossle
2026-02-12 21:16:02 -08:00
parent c94353f0bc
commit 8adae7cdbb
8 changed files with 542 additions and 53 deletions

300
hooks/SilentLauncher.cs Normal file
View 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;
}
}

View File

@@ -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

Binary file not shown.

79
hooks/silent-npx.cjs Normal file
View 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);
});

View File

@@ -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
View 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 */ }
}

View File

@@ -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})`);

View File

@@ -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