A headless terminal emulator that keeps isatty() returning true for spawned processes.
- Creates a real pseudo-terminal (PTY) via Windows ConPTY
- Spawned processes see
isatty(stdin) = true,isatty(stdout) = true - No visible console window needed - output is captured programmatically
- ANSI escape codes pass through correctly
- v2.5.0 - Now supports tray icon using --sys-tray argument. If you need a console for a long running process to see logs, or outputs just show from system tray and hide it back.
- v2.5.0 - Right-click tray icon to show/hide console on demand with full color output and VT sequences output support.
Many CLI tools check isatty() to decide behavior:
- Claude CLI: Requires TTY for interactive mode, else crashes
- Git: Colors output only when TTY detected
- Any NODE JS app using INK library for TUI
- Pythonw: Interactive mode depends on TTY
Without a real PTY, hiding a console window breaks these tools because redirected STDIN/STDOUT report isatty() = false.
Kind of! One still has to be somewhat technically inclined.
When you want to start a CLI app headless at Windows starts using task scheduler, you usually create a bat file then a vbs script to spawn that bat file headless.
You can now simple use headless-tty to spawn cmd, or powershell etc, then call whatver you want from there. A few examples:
headless-tty.exe -- python main.py: run a python file main.py without console, I mean you could use pythonw, but you still have to call it from somewhere.headless-tty.exe -- cmd /c "ipconfig -all >%temp%\ipconfig.txt && notepad %temp%\ipconfig.txt": Prints ipconfig to a file in temporary folder, then opens it in notepad, without ever showing console.headless-tty.exe -- powershell ".\myscript.ps1": run a powershell script without console.headless-tty.exe --sys-tray -- python -u main.py: Run a long running python script but now with tray icon if you want to see outputs later, hide it away when not in use.
These are basic example of a non cpp dev using this.
- Windows 10 version 1809+ (ConPTY support)
- clang
- CMake 3.16+
# Using the build script
build.bat# Run cmd.exe (default)
headless-tty.exe
# Run a specific command
headless-tty.exe claude
# Pass arguments to command
headless-tty.exe cmd /c dirRun processes in the background with a system tray icon. Right-click the tray icon to show/hide a console window on demand.
# Run with system tray icon
headless-tty.exe --sys-tray -- python -u main.py
# Run a long-running process in tray
headless-tty.exe --sys-tray -- node server.jsHow it works:
- The process runs completely hidden
- A tray icon appears in your system tray (bottom-right)
- Right-click the icon to "Show Console" or "Hide Console"
- The console lets you see output and type commands
- Closing the console window (X button) exits everything
- If the child process exits, the tray icon disappears automatically
Use with pythonw (example use case, not limited to) to launch claude code cli in headless mode but keep session alive
import subprocess
headless_tty_exe = "headless-tty.exe"
cmd = [
str(headless_tty_exe),
"--",
"claude"
]
headless_process = subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NEW_CONSOLE
)You can also use the ConPTY wrapper as a library in your own C++ projects:
#include <headless_tty/pty.hpp>
int main() {
headless_tty::HeadlessTTY tty;
headless_tty::Config config;
config.size = { 120, 40 };
config.command = L"claude.exe";
// Set callback for output
tty.set_output_callback([](const uint8_t* data, size_t len) {
// Process output bytes
fwrite(data, 1, len, stdout);
});
if (!tty.start(config)) {
std::cerr << "Failed: " << tty.get_last_error() << std::endl;
return 1;
}
// Wait for process to exit
int exitCode = tty.wait();
return exitCode;
}| Option | Description |
|---|---|
--sys-tray |
Run with system tray icon (right-click for menu) |
--help, -h |
Show help message |
Low-level ConPTY wrapper.
| Method | Description |
|---|---|
initialize(size) |
Create the pseudo console |
spawn(cmd, args, cwd) |
Spawn a process attached to the PTY |
write(data, len) |
Write input to the PTY |
set_output_callback(cb) |
Set callback for PTY output |
start_reading() |
Start background read thread |
stop() |
Terminate process and cleanup |
is_running() |
Check if process is still running |
wait(timeout) |
Wait for process to exit |
resize(size) |
Resize the PTY |
High-level wrapper that manages the full lifecycle.
| Method | Description |
|---|---|
start(config) |
Initialize and spawn process |
write(str) |
Send input to process |
set_output_callback(cb) |
Set callback for output |
stop() |
Stop the process |
is_running() |
Check if running |
wait(timeout) |
Wait for exit |
Note: - All mermaid flow charts has been implemented by using AI, then checked by the developer.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f2937', 'primaryTextColor': '#f3f4f6', 'primaryBorderColor': '#4b5563', 'lineColor': '#9ca3af', 'secondaryColor': '#374151', 'tertiaryColor': '#111827'}}}%%
flowchart TB
subgraph parent["Parent Process"]
app["Your Application"]
stdin["STDIN"]
stdout["STDOUT"]
end
subgraph htty["headless-tty.exe"]
fwd["Input Forwarder"]
cb["Output Callback"]
mon["Monitor Thread"]
subgraph pty["Windows ConPTY"]
pc["Pseudo Console"]
pin["Input Pipe"]
pout["Output Pipe"]
end
end
subgraph child["Child Process"]
proc["powershell.exe / cmd.exe / pythonw.exe"]
tty["isatty() = true"]
end
app -->|"spawns"| htty
stdin -->|"raw bytes"| fwd
fwd -->|"write"| pin
pin --> pc
pc --> proc
proc --> tty
pc --> pout
pout -->|"read"| cb
cb -->|"raw bytes"| stdout
mon -->|"waits on"| proc
proc -->|"exit"| mon
mon -->|"closes"| pc
style stdin fill:#1a332a,stroke:#2d5a47
style mon fill:#2a2a3a,stroke:#4d4d6a
style fwd fill:#1a332a,stroke:#2d5a47
style pin fill:#1e3a2f,stroke:#2d5a47
style stdout fill:#332a2a,stroke:#5a3d3d
style cb fill:#332a2a,stroke:#5a3d3d
style pout fill:#3a2a2a,stroke:#5a3d3d
style pc fill:#1a2a3a,stroke:#3d4d5a
style proc fill:#1a2a3a,stroke:#3d4d5a
style tty fill:#2a3a1a,stroke:#4d5a3d
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f2937', 'primaryTextColor': '#f3f4f6', 'primaryBorderColor': '#4b5563', 'lineColor': '#9ca3af'}}}%%
sequenceDiagram
participant App as Your App
participant HTT as HeadlessTTY
participant Win as Windows Kernel
participant Child as Child Process
App->>HTT: start(config)
rect rgb(26, 42, 58)
Note over HTT,Win: PTY Initialization
HTT->>Win: CreatePipe() x2
Win-->>HTT: Input & Output pipes
HTT->>Win: CreatePseudoConsole(size, pipes)
Win-->>HTT: HPCON handle
end
rect rgb(30, 51, 42)
Note over HTT,Child: Process Spawn
HTT->>Win: InitializeProcThreadAttributeList()
HTT->>Win: UpdateProcThreadAttribute(PSEUDOCONSOLE)
HTT->>Win: CreateProcessW(command)
Win->>Child: Launch with PTY attached
HTT->>Win: CreateJobObject()
HTT->>Win: AssignProcessToJobObject()
Note over HTT,Child: Child auto-terminates if parent dies
end
HTT->>HTT: start_reading()
HTT->>HTT: start monitor_thread()
Note over HTT: Monitor waits on child process handle
HTT-->>App: success
rect rgb(51, 42, 42)
loop While Running
App->>HTT: write(input)
HTT->>Child: via PTY pipe
Child->>HTT: output via PTY pipe
HTT->>App: output_callback(data)
end
end
rect rgb(42, 42, 51)
Note over HTT,Child: Child Exit Detection
Child->>HTT: process exits
HTT->>HTT: monitor_thread detects exit
HTT->>Win: ClosePseudoConsole()
Note over HTT: Pipes break, read_loop exits
HTT-->>App: is_running() = false
end
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f2937', 'primaryTextColor': '#f3f4f6', 'primaryBorderColor': '#4b5563', 'lineColor': '#9ca3af'}}}%%
flowchart LR
subgraph input["Input Path"]
direction LR
si["STDIN"] --> fw["Forwarder Thread"]
fw --> wp["Write Pipe"]
wp --> pty1["PTY"]
pty1 --> ci["Child STDIN"]
end
subgraph output["Output Path"]
direction LR
co["Child STDOUT"] --> pty2["PTY"]
pty2 --> rp["Read Pipe"]
rp --> rt["Read Thread"]
rt --> so["STDOUT"]
end
subgraph termination["Termination Path"]
direction LR
ce["Child Exit"] --> mt["Monitor Thread"]
mt --> cp["ClosePseudoConsole"]
cp --> pb["Pipes Break"]
pb --> pe["Parent Exit"]
end
style si fill:#1a332a,stroke:#2d5a47
style fw fill:#1a332a,stroke:#2d5a47
style wp fill:#1e3a2f,stroke:#2d5a47
style pty1 fill:#1a2a3a,stroke:#3d4d5a
style ci fill:#1a332a,stroke:#2d5a47
style co fill:#332a2a,stroke:#5a3d3d
style pty2 fill:#1a2a3a,stroke:#3d4d5a
style rp fill:#3a2a2a,stroke:#5a3d3d
style rt fill:#332a2a,stroke:#5a3d3d
style so fill:#332a2a,stroke:#5a3d3d
style ce fill:#2a2a3a,stroke:#4d4d6a
style mt fill:#2a2a3a,stroke:#4d4d6a
style cp fill:#2a2a3a,stroke:#4d4d6a
style pb fill:#2a2a3a,stroke:#4d4d6a
style pe fill:#2a2a3a,stroke:#4d4d6a
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f2937', 'primaryTextColor': '#f3f4f6', 'primaryBorderColor': '#4b5563', 'lineColor': '#9ca3af', 'secondaryColor': '#374151'}}}%%
stateDiagram-v2
[*] --> Uninitialized
Uninitialized --> Initializing: start()
state Initializing {
[*] --> CreatePipes
CreatePipes --> CreatePTY
CreatePTY --> SetupAttributes
SetupAttributes --> SpawnProcess
SpawnProcess --> CreateJobObject
CreateJobObject --> [*]
}
Initializing --> Running: success
Initializing --> [*]: failure
state Running {
[*] --> Active
Active --> Active: read/write
Active --> Monitoring: monitor_thread watches child
Monitoring --> Active
}
Running --> Stopping: stop() called
Running --> Stopping: parent killed (job object kills child)
Running --> Stopping: child exits (monitor closes PTY)
state Stopping {
[*] --> TerminateProcess
TerminateProcess --> JoinThreads
JoinThreads --> CloseHandles
CloseHandles --> [*]
}
Stopping --> [*]
classDef initState fill:#1a2a3a,stroke:#3d4d5a
classDef runState fill:#1e3a2f,stroke:#2d5a47
classDef stopState fill:#3a2a2a,stroke:#5a3d3d
class Initializing initState
class Running runState
class Stopping stopState
Bidirectional Process Termination
The process lifecycle is managed bidirectionally:
-
Parent killed -> Child dies: A Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEensures the child process (and all its descendants) are terminated when headless-tty exits, even if killed forcefully. -
Child exits -> Parent exits: A monitor thread watches the child process handle. When the child exits (e.g., user closes notepad), the monitor calls
ClosePseudoConsole()which terminates conhost and breaks the pipes, causing headless-tty to exit cleanly.- Limitation: Although it works great for win32 apps as well as UWP apps (we are only talking about GUI here, all CLI apps works perfectly), there's a caveat in the UWP app, that it spawns the multiple PID. If you forcefully kill any child PID in the middle of the chain, you may leave orphan processes.
This prevents orphaned processes in both directions.
To disable parent->child termination, remove from pty.cpp:
m_hJob = CreateJobObjectW(NULL, NULL);
if (m_hJob) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = {};
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(m_hJob, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli));
AssignProcessToJobObject(m_hJob, m_hProcess);
}To disable child->parent termination, remove monitor_loop() and related code.
This tiny executable file must be spawned with UAC elevation. Mostly needed if you want an INK - https://github.com/vadimdemedes/ink app such as gemini cli or claude code... you will need their pid and this to send message.
Such apps, while will get the message from emulator, but won't process return key to send.
Implementation pseudocode:
pseudocode: messenger_wrapper.py
import os, time, hmac, hashlib, subprocess, secrets
from pathlib import Path
class MessengerAuth:
def __init__(self, target_pid, target_name):
self.secret = secrets.token_bytes(32) # Raw bytes
self.target_pid = target_pid
self.target_name = target_name
self.pipe_name = f"\\\\.\\pipe\\InjectorAuth_{os.getpid()}"
def start_pipe_server(self):
# Create named pipe, serve on connect:
# Send: f"{self.secret.hex()}\n{self.target_pid}\n{self.target_name}"
# Verify caller binary hash before responding (optional)
pass
def sign(self, command):
ts = str(int(time.time()))
msg = f"{self.target_pid}|{command}|{ts}"
sig = hmac.new(self.secret, msg.encode(), hashlib.sha256).hexdigest()
return ts, sig
def send(self, command):
ts, sig = self.sign(command)
result = subprocess.run([
"messenger.exe",
str(self.target_pid),
command,
ts,
sig
])
return result.returncode
# Usage
auth = MessengerAuth(pid=12345, target_name="claude.exe")
auth.start_pipe_server() # In background thread
auth.send("hello world") # Text + Enter
auth.send("--tab") # Special key
auth.send("--escape")Development - not released
Initial release
- Uses c++17 standard
- Keeps isatty()=True for console TUI apps without showing console.
- Shows console for GUI apps. For example starting
headless-tty.exe -- notepadwill show a console (empty).
Second release
- Uses c++23
- Comes with helper binary messenger.exe with authentication pipeline built in (NO server file, but pseudocode present in Readme.md)
- Added truly headless mode
- Keeps console hidden for GUI apps for example
headless-tty.exe -- notepadshows no console - Keeps console hidden for console apps while keeping isatty()=True, for example
headless-tty.exe -- claudeshows no console but keeps claude code CLI INK app hidden in interactive mode and isatty()=True (or else it would have crashed) - Replaced deprecated call
- Keeps console hidden for GUI apps for example
- Bidirectional process termination
- Killing headless-tty kills the child process (Job Object)
- Closing/killing the child process causes headless-tty to exit cleanly (Monitor Thread)
- Comes with an example showcase file written in python
usage_example.pyto help showcase the Software's potential.
System Tray Mode
- Added
--sys-trayflag for running processes with a system tray icon - Right-click tray icon to show/hide console on demand
- Console supports full color output and VT sequences
- Closing console window exits the application
- Child process exit automatically removes tray icon
- Help message now displays when running with
-hor--helpfrom command line
Meaning of the word "The Software" and "Derivative Work"
"The Software" refers to the source code, object code, documentation, and any other materials contained in this repository, including any modified version of any of it.
"Derivative Work" or "derivatives" means any work that is based on or derived from the Software, such as modified application that has been forked from this repo.
Is the Software free for non commercial use?
Yes!
Can I use it on a computer I normally use for work, and other commercial activities?
Yes! As long as you are not using "the Software" or it's "Derivative Work" for commercial use or sublicensing, it's free for you
and the gross annual income threshold does not apply to you.
Does that mean if my income is more than $50,000 but I use the Software non-commercially, do I have to get another license?
No, as long as you do not use the Software or Derivative work to generate that income, it does not count towards commercial usage and the threshold limit does not apply.
If you want a waiver, please get in touch - #1
Read LICENSE for details, the LICENSE will take precedence over the above given summary in a court of Law, if a conflict presents itself.
