Problem
On Windows, Opcode's spinner never stops and Claude's response never appears after sending a prompt. This affects native Windows users (without WSL).
Relation to existing issues:
Root cause
When Opcode spawns claude.exe, Claude in turn spawns child processes (Node.js workers, etc.) that inherit the stdout pipe write-handle. When Claude itself exits, those grandchild processes still hold the handle open. The stdout reader in spawn_claude_process never receives EOF — so claude-complete is never emitted and the spinner runs indefinitely.
This is a fundamental behavioral difference between Windows and Unix: on Unix, process groups and signals provide clean separation; on Windows, inherited handles persist until every process holding them exits.
Proposed solution — Platform-Abstracted Output Adapter
Rather than a platform-specific workaround, we propose a Ports & Adapters pattern (hexagonal architecture) for session output handling. This separates the what (session lifecycle events) from the how (platform I/O mechanics), making the codebase cleanly extensible to other platforms:
SessionOutputAdapter (trait / port)
├── StdoutAdapter → Unix: full real-time streaming (existing behavior, unchanged)
└── FileWatchAdapter → Windows: reads stdout until session init, then defers to child.wait()
FileWatchAdapter (Windows) behaviour:
- Reads stdout only until the
system:init message arrives (captures the session ID)
- Intentionally drops the reader — breaking the deadlock caused by inherited handles
- Calls
child.wait() to block until Claude actually exits
- Emits
SessionEvent::Complete — frontend reloads the full conversation from the JSONL file Claude already writes to ~/.claude/projects/.../session.jsonl
Platform selection is compile-time via #[cfg(windows)] / #[cfg(not(windows))] — zero impact on Unix builds.
Why this approach:
- The Unix path is completely unchanged — no regression risk for macOS/Linux users
- The adapter boundary makes future platform support straightforward to extend
- Claude already writes the authoritative conversation to disk — reading from JSONL on completion is reliable and complete
- Unlike the WSL bridge in Windows Support for Claudia - Community Fix Available 4.2 FINAL #78, this requires no additional tooling or WSL installation
Additional fixes found during investigation
| Issue |
Fix |
Status |
tauri dev fails: "cannot determine which binary to run" |
Add default-run = "opcode" to Cargo.toml |
Fixed |
thinking content blocks not visible in new entries |
displayableMessages filter was missing content.type === "thinking" |
Fixed |
| Spurious loading spinner cycles at session init |
loadSessionHistory silenced during mount; completion guard prevents double-firing |
Partially mitigated — minor cosmetic flicker may still occur, low-priority |
Environment
- Windows 11 Pro, Claude Code 2.1.72
- Opcode 0.2.1, built from source
Related: #78, #71, #314
Problem
On Windows, Opcode's spinner never stops and Claude's response never appears after sending a prompt. This affects native Windows users (without WSL).
Relation to existing issues:
Root cause
When Opcode spawns
claude.exe, Claude in turn spawns child processes (Node.js workers, etc.) that inherit the stdout pipe write-handle. When Claude itself exits, those grandchild processes still hold the handle open. The stdout reader inspawn_claude_processnever receives EOF — soclaude-completeis never emitted and the spinner runs indefinitely.This is a fundamental behavioral difference between Windows and Unix: on Unix, process groups and signals provide clean separation; on Windows, inherited handles persist until every process holding them exits.
Proposed solution — Platform-Abstracted Output Adapter
Rather than a platform-specific workaround, we propose a Ports & Adapters pattern (hexagonal architecture) for session output handling. This separates the what (session lifecycle events) from the how (platform I/O mechanics), making the codebase cleanly extensible to other platforms:
FileWatchAdapter(Windows) behaviour:system:initmessage arrives (captures the session ID)child.wait()to block until Claude actually exitsSessionEvent::Complete— frontend reloads the full conversation from the JSONL file Claude already writes to~/.claude/projects/.../session.jsonlPlatform selection is compile-time via
#[cfg(windows)]/#[cfg(not(windows))]— zero impact on Unix builds.Why this approach:
Additional fixes found during investigation
tauri devfails: "cannot determine which binary to run"default-run = "opcode"toCargo.tomlthinkingcontent blocks not visible in new entriesdisplayableMessagesfilter was missingcontent.type === "thinking"loadSessionHistorysilenced during mount; completion guard prevents double-firingEnvironment
Related: #78, #71, #314