A minimal SHELL/COMSPEC wrapper that places every shell invocation inside a
per-call Windows Job Object
with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the wrapper exits (normal or
forced kill), Windows guarantees all descendant processes are terminated.
Designed for OpenCode's built-in bash execution, where orphaned child processes can otherwise accumulate and block subsequent commands.
Status: reference implementation / workaround.
This is not an official OpenCode feature. It may help investigate and mitigate symptoms related to OpenCode issue #29822 and similar orphan‑process problems on Windows.
- Problem
- Why Windows Job Objects
- Architecture
- Requirements
- Build
- OpenCode Setup
- Usage
- Supported Invocation Forms
- Tests
- Logging / Privacy
- Limitations
- Uninstall / Rollback
- Security Notes
- Related Upstream Issues
- License
OpenCode's built-in bash on Windows works by creating a child shell process for each command. When a command times out or is interrupted, the child can leave behind orphaned processes. These orphans:
- Hold ports or file locks.
- Accumulate across successive commands.
- Can prevent the next command from starting correctly.
The same issue can affect any tool that uses CreateProcess without a Job
Object — child processes are not automatically terminated when their creator
exits.
Windows provides a kernel-level mechanism called Job Objects. A Job Object
can be configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, which tells the
kernel: "when this Job handle is closed, terminate every process assigned to
it."
Key properties:
- Kernel-guaranteed — cannot be bypassed by the child process.
- No polling required — termination is instant when the handle closes.
- Works on forced kill — even if
managed_shell.exeisTerminateProcess'd, the Job handle closes and Windows cleans up the job tree. - Per-call isolation — each invocation creates its own Job Object, so killing one command never affects another.
OpenCode built-in bash
│ SHELL=managed_shell.exe (or COMSPEC)
▼
┌─────────────────────────┐
│ managed_shell.exe │
│ │
│ 1. Parse invocation │
│ (-c / -lc → pwsh │
│ /c → cmd) │
│ │
│ 2. Create Job Object │
│ (KILL_ON_JOB_CLOSE) │
│ │
│ 3. CreateProcessW │
│ (CREATE_SUSPENDED) │
│ │
│ 4. AssignProcessTo │
│ JobObject │
│ │
│ 5. ResumeThread │
│ │
│ 6. WaitForSingleObject │
│ (INFINITE) │
│ │
│ 7. Close Job handle │
│ → Windows kills │
│ all descendants │
│ │
│ 8. Return exit code │
└─────────────────────────┘
│
▼
pwsh.exe / cmd.exe
│
▼
Your command
The Job Object is created before the child process, ensuring there is
never a race window where the child runs without job protection. The child
starts in CREATE_SUSPENDED state, gets assigned to the Job, and only then
resumes.
| Component | Requirement |
|---|---|
| OS | Windows 10/11 or Server 2019+ (x64) |
| .NET | .NET Framework 4.8 (runtime only; ships with Windows) |
| Compiler | csc.exe from .NET Framework 4.8 SDK (included with Windows SDK / Visual Studio Build Tools) |
| PowerShell | PowerShell 7+ (pwsh.exe) for SHELL mode |
| OpenCode | Tested with OpenCode 1.17.20 on Windows |
The compiler path is typically:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe
From the repository root:
scripts\build.batOr manually:
"%SystemRoot%\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:exe /platform:anycpu /out:managed_shell.exe src\managed_shell.csThe build script automatically runs a struct-layout validation after compiling.
Output: managed_shell.exe at the repository root.
⚠️ The examples below setSHELLonly for the current session (per-process environment inheritance). This is the recommended approach to avoid system-wide changes.
# Before starting OpenCode
$env:SHELL = "C:\full\path\to\managed_shell.exe"
# Optional: explicitly set pwsh path to skip PATH search
$env:MANAGED_SHELL_PWSH = "C:\Program Files\PowerShell\7\pwsh.exe"
# Then start OpenCode:
opencode webset SHELL=C:\full\path\to\managed_shell.exe
set MANAGED_SHELL_PWSH=C:\Program Files\PowerShell\7\pwsh.exe
opencode web- The environment variables affect only the command shell and its child processes.
- Closing the terminal / logoff restores the default behaviour.
- No system or user environment variables are permanently modified.
set COMSPEC=C:\full\path\to\managed_shell.exeNote: On OpenCode 1.17.20, setting SHELL is the effective method — the
built-in bash does not query COMSPEC. This may change in future versions.
If set, it must be an absolute path to a pwsh.exe that is not
managed_shell.exe itself (to prevent recursion). If the path is relative,
does not exist, or points to self, the wrapper fails closed (exit code 2)
and does not fall back to PATH search.
If not set, the wrapper searches:
PATHforpwsh.exe(major version >= 7, must not be self).%ProgramFiles%\PowerShell\7\pwsh.exe(major version >= 7).
managed_shell.exe -- SHELL/COMSPEC wrapper with Job Object lifecycle
SHELL mode (set SHELL=managed_shell.exe):
managed_shell.exe -c "<command>" -> pwsh -Command <command>
managed_shell.exe -lc "<command>" -> pwsh -Command <command>
managed_shell.exe -l -c "<command>" -> pwsh -Command <command>
COMSPEC mode (set COMSPEC=managed_shell.exe):
managed_shell.exe /c "<command>" -> C:\Windows\System32\cmd.exe /c "<command>"
Test mode:
managed_shell.exe --managed-shell-test spawn <sec> [ec]
managed_shell.exe --managed-shell-test validate-structs
The --managed-shell-test spawn command spawns a ping 127.0.0.1 loop for
<sec> seconds (default 10), then exits with the optional exit code [ec].
This is used by the kill/isolation tests.
All of the following are accepted and equivalent:
| Form | Example |
|---|---|
-c <cmd> |
-c "Write-Output hello" |
-lc <cmd> |
-lc "Write-Output hello" |
-lc -c <cmd> |
-lc -c "Write-Output hello" |
-lc -l -c <cmd> |
-lc -l -c "Write-Output hello" |
-l -c <cmd> |
-l -c "Write-Output hello" |
Any unrecognised flag starting with - (e.g. -x) causes exit code 2.
The wrapper does not silently fall through to COMSPEC mode.
| Form | Example |
|---|---|
/c <cmd> |
/c "echo hello" |
/d /s /c <cmd> |
/d /s /c "echo hello" |
Three test suites cover basic functionality, forced-kill lifecycle, and background-process cleanup.
# From repository root:
pwsh -NoProfile .\tests\test_basic.ps1
pwsh -NoProfile .\tests\test_kill.ps1
pwsh -NoProfile .\tests\test_background.ps1- Empty args / help
- COMSPEC mode: echo, spaces, unicode, pipe, chaining, exit codes
- SHELL mode: basic output, pipeline, chaining, env vars, exit codes
- PowerShell 7
&&chaining, stderr+nonzero exit, long output (>1000 chars) - Python -c nested quoting (conditional on python availability)
- git / rg smoke tests (conditional)
- Struct layout validation (x64: IntPtr=8, JOBOBJECT_BASIC=64, JOBOBJECT_EXTENDED=144, STARTUPINFOW=104, PROCESS_INFORMATION=24)
MANAGED_SHELL_PWSHenv var override (valid/relative/nonexistent/self)- SHELL protocol variants (5 forms)
- Missing command → ec=2, unknown flags → ec=2
- Log security: sensitive data not written to JSONL logs
- Test 1: Spawn wrapper with 30s ping child → kill wrapper → verify child exits within 3s
- Test 2: Spawn two wrappers (15s and 30s) → kill one → verify its child dies but the other survives → wait for surviving instance to exit normally
- Test 1: Wrapper runs PowerShell that starts a background
Start-Sleep 60process → wrapper returns → verify background is terminated byKILL_ON_JOB_CLOSE - Test 2: Two independent wrappers each start background processes → both wrappers exit → both backgrounds are terminated independently
The .github/workflows/windows.yml workflow builds and runs all three suites
on windows-latest with timeouts to prevent hanging.
The wrapper writes a minimal JSONL audit log to:
%TEMP%\managed_shell\managed_shell_YYYY-MM-DD.jsonl
Each line contains:
ts: ISO 8601 timestamppid: managed_shell's own PIDchildPid: PID of the child process (pwsh or cmd)event:"start","exit", or"error"mode:"powershell"or"cmd"detail: integer count (argc or exit code) or error Win32 code
No command text, arguments, environment variables, or secrets are ever
logged. The detail field only carries non-sensitive numeric data.
If the log directory cannot be created, logging is silently disabled — the wrapper still functions normally.
-
Only covers built-in bash — OpenCode's
pty_spawn, Desktop Commander, and SSH/MCP remote commands use separate execution channels and are not affected by theSHELLenvironment variable. -
Windows x64 only — the struct layout and IntPtr assumptions are x64-specific. x86 or ARM64 may require different struct sizes.
-
Requires PowerShell 7+ — PowerShell 5.1 (
powershell.exe) is not supported in SHELL mode. COMSPEC mode usescmd.exeand works on all modern Windows versions. -
Infinite wait —
WaitForSingleObject(INFINITE)means the wrapper waits forever for the command to complete. OpenCode's timeout mechanism (which kills the wrapper) handles the timeout case — the wrapper itself does not implement a timeout. -
Not an official OpenCode feature — this is a third-party workaround. It works with OpenCode 1.17.20 but may break or become unnecessary in future versions.
-
Recursion guard — the wrapper refuses to use itself as the target shell (
IsSelfcheck), but care should be taken not to chain wrappers through other means. -
Log accumulation — audit logs in
%TEMP%\managed_shell\accumulate one file per day with no automatic rotation or size limit. On long-running or frequently-used systems, periodic cleanup (e.g., a scheduled task deleting logs older than N days) is recommended.
Since the setup only modifies per-session environment variables, no permanent changes are made. To restore the default:
- Close the terminal where
SHELL/COMSPECwas set, or unset the variables:$env:SHELL = $null $env:COMSPEC = $null $env:MANAGED_SHELL_PWSH = $null
- Restart OpenCode — it will use its default shell behaviour.
- Delete
managed_shell.exeand log directory%TEMP%\managed_shell\if desired.
If you modified a startup script (not recommended), restore it from backup
or remove the SHELL / MANAGED_SHELL_PWSH lines.
- The wrapper uses
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEwhich gives the kernel authority to terminate all job processes when the handle closes. This is the intended behaviour — it is what makes orphan cleanup reliable. bInheritHandles=falseinCreateProcessWprevents the child from inheriting any handles (console, pipes, etc.) from the parent. The Job handle itself is also non-inheritable —CreateJobObjectWis called with NULL security attributes, so no child process can inherit it throughCreateProcess. Once assigned to the job, a child process cannot remove itself because the job'sKILL_ON_JOB_CLOSElimit is already set and the child lacks the required access to modify it.CREATE_SUSPENDED→AssignProcessToJobObject→ResumeThreadensures the child is assigned to the job before it executes a single instruction.- Log files contain only metadata (timestamps, PIDs, exit codes). No commands, arguments, or secrets are written to disk.
- The wrapper does not use
CREATE_NO_WINDOW. The child process inherits the caller's console, preserving stdout/stderr and interactivity.
- OpenCode #29822 — Child processes not killed after timeout (the primary motivation for this project).
MIT — see LICENSE.
Copyright (c) 2026 Daniel Zhao