Interactive terminal sidecar for running jobs#194
Open
mikeangstadt wants to merge 54 commits into
Open
Conversation
In packaged macOS builds, Electron strips PATH to a minimal set. Both child_process.spawn and node-pty use posix_spawnp to look up the binary in the parent process's PATH — not the child's env. Since "bash" isn't in Electron's minimal PATH, the spawn fails with "posix_spawnp failed". Use /bin/bash (absolute) in buildClaudePipeline so the lookup doesn't depend on PATH at all. /bin/bash ships at this fixed path on every macOS version. Bump version 0.15.15 → 0.15.16. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Strip -p and --output-format stream-json from args when using the interactive terminal path so Claude stays in its interactive TUI instead of processing the prompt and exiting - Spawn claude directly via absolute path instead of wrapping in a bash pipeline, avoiding posix_spawnp failures in packaged builds - Pass prompt file content as positional arg so Claude starts with context but remains interactive for user input - Enable JSONL capture via PTY onData handler + extractJsonlFromLog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first commit only fixed the formatter branch. The no-formatter fallback still used bare "bash", which fails the same way in packaged builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Strip only -p (print mode) from args when using the interactive terminal so Claude stays in its TUI after the initial response - Keep --output-format stream-json so JSONL extraction, token usage parsing, and completion detection still work on exit - Spawn claude directly via absolute path (not bash pipeline) - Pass prompt file content as positional arg for initial context - Terminal window is a detachable view: open/close freely without affecting the running Claude process Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user clicks "Open terminal" on a running job: 1. Kill the current detached -p process 2. Respawn Claude with --resume (no -p) in a PTY for interactive use When the terminal window closes: 1. Kill the interactive PTY session 2. Respawn Claude with --resume + -p via detached bash pipeline so the job continues unattended The mode switch is transparent — the user can open and close the terminal window freely. The Claude conversation resumes seamlessly via --resume in both directions. Implementation: - Store LoopSpawnConfig on RunningLoop entries for respawn - modeSwitching flag suppresses finalization during kill/respawn - switchToInteractive() and switchToDetached() exports handle the toggle - IPC handler calls switchToInteractive, window.closed calls switchToDetached Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The exit event from the killed process fires after switchToInteractive replaces the runningLoops entry, so reading modeSwitching from the map finds the new entry (which has no flag set). Fix: use a modeSwitchSuppressed closure variable owned by the original handleLoopRequest scope. The RunningLoop entry carries a suppressCompletion callback that sets this variable, so the mode-switch functions can suppress finalization even after the map entry has been replaced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Store loopSpawnConfig for PLAN/EXECUTE (run-loop.sh) path too, preventing crash if terminal button is ever shown for those commands - Improve error message when spawnConfig is missing (old version or boot-recovered job) to explain the cause - Reorder checks: return existing PTY session before checking config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When switching modes, the old process is killed and a new one spawns with a different PID. The job store still had the old PID, so enrichJobSnapshot saw a dead process and marked the job as STOPPED, removing it from the UI. Fix: update the job store PID after spawning the replacement process in both switchToInteractive and switchToDetached. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- --resume prompts user to pick a session and opt in to file inspection. --continue auto-continues the most recent conversation without prompting — correct for both PTY and detached respawn. - Filter stdin marker "-" from detached resume args since --continue picks up from the existing conversation without needing a prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Read session ID from <claudeWorkDir>/session-id.txt to target the exact conversation when switching modes - Fixes concurrent job confusion: --continue picks most recent conversation globally, --resume <id> targets the right one - switchToInteractive: errors if session-id.txt missing (Claude hasn't started yet) - switchToDetached: gracefully skips if no session ID (job done) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Direct claude commands (EVALUATE_*, DECOMPOSE, etc.) emit session_id in the stream-json JSONL output but don't write session-id.txt (that's only written by the run-loop.sh path for PLAN/EXECUTE). Add readSessionId() helper that checks session-id.txt first, then falls back to parsing session_id from the first JSONL line. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The job disappeared from the UI after closing the terminal because the detached resume process had no onceComplete wiring — when it exited, handleProcessCompletion never ran, the job stayed RUNNING with a dead PID, and enrichJobSnapshot marked it STOPPED. Fix: replace the boolean modeSwitchSuppressed with a generation counter. Each mode switch bumps the generation, which invalidates the killed process's exit handler. The replacement process captures the new generation and wires its exit into the original onceComplete, so finalization runs correctly when it eventually exits. Both bumpGeneration and onceComplete are carried forward across mode switches so the job can toggle between interactive and detached any number of times. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user closes the terminal after Claude finished the work, don't spawn a pointless --resume -p (which produces no artifacts). Instead, run onceComplete directly with the PTY's exit code so handleProcessCompletion finds the artifacts that were produced during the interactive session. Only spawn a detached --resume when the PTY is still running (Claude hasn't finished yet and needs to continue unattended). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Artifacts may not be available when the PTY exits — the --resume -p process handles artifact production and finalization. Always spawn it regardless of whether the interactive session already exited. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove all mode-switching infrastructure (switchToInteractive, switchToDetached, LoopSpawnConfig, generation counters). The interactive terminal is now a simple PTY-backed spawn: - When interactiveTerminal is enabled and the command is in INTERACTIVE_TERMINAL_COMMANDS, spawn claude via PTY instead of detached child process. Same args including -p so Claude runs to completion unattended. - The terminal window is a view into the PTY output with stdin forwarding. Closing the window detaches the view — the process keeps running. - onceComplete fires normally when the PTY process exits, triggering handleProcessCompletion for artifact upload and finalization. - No kill/resume cycle, no session ID tracking, no PID juggling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bash pipeline uses stdin redirect (< promptFile) to feed the prompt when args contain "-p -". But PTY spawn has no stdin redirect, so "-" reads nothing from the PTY and Claude exits immediately (6 tokens, 2 turns, 0 work done). Fix: filter out "-" from args and pass the prompt file content as a positional arg to -p. Claude receives the full prompt and runs to completion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove all mode-switching complexity. The interactive terminal is now a completely independent sidecar process: - Initial spawn: always -p via detached bash pipeline (unchanged) - Click "Open terminal": spawn a NEW claude --resume <sessionId> process in a PTY. The original -p process keeps running. - Close terminal: nothing happens. Both processes keep running. - Original -p process exits: onceComplete fires, artifacts upload, finalization runs as normal. The sidecar reads the session ID from claude-output.jsonl (or session-id.txt) and resumes the same conversation. The user can interact with Claude while the background -p process continues. Deleted: switchToInteractive, switchToDetached, LoopSpawnConfig, bumpGeneration, modeSwitchGeneration, suppressCompletion, PID juggling, and all related wiring. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Check hasSession before spawning sidecar PTY. If session exists and is still alive, return it (user reopened terminal). If it exited, clean up and re-spawn. Prevents "PTY session already exists" crash on terminal reopen. - Both the main -p process and the sidecar write to the same claude-output.jsonl so all token usage and results are captured in one stream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Give the interactive sidecar its own log and JSONL files so its token usage, turn counts, and result events don't pollute the main -p process's metrics that feed the output-tailer and cloud upload stream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Start a second output-tailer for the sidecar's claude-output-interactive.jsonl that streams events to the cloud API using "interactive:<loopId>" as the identifier. This keeps the sidecar's token usage and events delineated from the main -p process while ensuring the server sees both streams. Also pass apiBaseUrl and loopToken from the job store to the sidecar spawner so it has the credentials to post events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the headless -p process completes: - Terminal window OPEN: defer finalization until the sidecar exits, so the user's in-progress work (added judges, guidance) is included in the final results - Terminal window CLOSED: finalize immediately, even if the sidecar process is still running in the background - Terminal never opened: finalize immediately (no sidecar exists) Track window open/close state via markTerminalWindowOpen/Closed called from the IPC handler's BrowserWindow lifecycle events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Send SIGSTOP to the headless -p process when the terminal window opens, freezing it so it doesn't race the interactive sidecar. Send SIGCONT when the window closes so it picks up where it left off. This eliminates the race condition where the headless process finalizes with incomplete results while the user is still adding work in the interactive session. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Before handleProcessCompletion runs, append the sidecar's claude-output-interactive.jsonl and symphony-loop-interactive.log into the main files. This ensures parseTokenUsage sees the combined token counts from both sessions, and the full log history is available for diagnostics. The sidecar's real-time tailer still streams with the "interactive:" prefix for delineation on the server. The merge only happens at finalization time for the consolidated result. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The loopId is used for API routing and must be the real ID. Interactive JSONL records already carry a distinct session_id from the --resume spawn, which provides natural delineation on the server side. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sidecar was spawning with just --resume <sessionId>, so Claude output plain text. The JSONL file stayed empty and the tailer had nothing to stream. Add --output-format stream-json and --verbose so Claude emits structured JSONL that the output tailer picks up for live events and audit log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
--output-format stream-json produces nothing in --resume mode, so Claude's interactive JSONL was always empty. Instead, capture events at the WebSocket/PTY level: - user_input: logged when the terminal sends keystrokes - assistant_output: logged from PTY onData (Claude's responses) Both are written as timestamped JSONL to claude-output-interactive.jsonl. The output-tailer streams them to the server under the real loopId. At finalization, they get merged into the main JSONL for combined metrics. Also remove --output-format stream-json from sidecar args since it has no effect in --resume mode and could interfere with the TUI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SIGSTOP/SIGCONT: - Use -pid (process group) instead of pid so Claude's child processes (tool calls, subagents) are also paused/resumed Interactive JSONL events: - Write events in the format the output-tailer recognizes: type "user" and "assistant" with content text blocks - Buffer assistant output chunks and flush after 500ms pause to avoid flooding JSONL with per-character PTY events - Previous custom event types (user_input, assistant_output) were silently dropped by the tailer's summarizeJsonlRecord Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Strip ANSI escape codes from PTY output before logging - Increase flush interval to 2s and require 10+ visible chars, filtering out TUI rendering noise (cursor moves, spinners) - Buffer user keystrokes and only log complete lines (on Enter), not individual characters - Collapse whitespace in assistant output for readable log entries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user types, the PTY echoes each keystroke back as output. The assistant output buffer was capturing these echoes, producing garbled "c a n y o u..." entries in the live stream. Fix: set a 1s typing suppression window after each keystroke. PTY output received during this window is forwarded to the terminal display but not logged as assistant output. Once the user stops typing and Claude responds, the assistant output resumes logging normally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the terminal window closes, instead of SIGCONT-ing the stale original process (which doesn't know about the sidecar's work): 1. Kill the original -p process (SIGTERM to process group) 2. Spawn --resume <sessionId> -p to continue from the sidecar's conversation endpoint — like merging a feature branch into main 3. Wire the replacement's exit into the original onceComplete so the same finalization pipeline (artifact upload, event posting) runs when the resumed process completes The replacementPending flag on RunningLoop suppresses finalization for the killed process's exit. The replacement process inherits the spawn config and onceComplete reference. Falls back to SIGCONT if no spawn config or session ID is available (e.g., job started before this code was deployed). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Raise minimum line length from 10 to 20 chars - Skip key hints (Entertoconfirm, Esctocancel, esctointerrupt) - Skip token counters (52215tokens) - Skip spinner/status symbols only lines - Skip spinner state labels (Thinking, Actioning, Churning, etc.) Keeps: Tool results, Claude text responses, user input, and other meaningful content lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Parse token totals from Claude's TUI counter (e.g. "52215tokens") and compute deltas between readings. Emit usage records with actual token counts (80/20 input/output split) instead of text-length estimates. Also: - Keep spinner labels (Thinking, Actioning, etc.) in live stream - Remove estimated token counts from text events to avoid double-counting with the real TUI-extracted counts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User keystrokes were echoed by the PTY into the assistant line buffer, producing garbled mixed output like "Ican you swap the dependency readdependency judge...". Fix: - Clear the assistant line buffer on Enter so echoed keystrokes are discarded before Claude's response arrives - Extend typing suppression to 3s after Enter to skip the TUI redraw between user input and Claude's actual response User input is captured separately via the keystroke accumulator and emitted as a clean "user" event on Enter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove INTERACTIVE_TERMINAL_COMMANDS gate. The sidecar model is independent of how the original process spawns — it just does --resume <sessionId> on a separate PTY. Every command (including Plan, Execute, Bootstrap) now shows the terminal attach button when interactiveTerminal is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Lower minimum line length from 20 to 3 chars - Remove filters for spinner labels, thinking text, and key hints — only skip pure symbol-only lines and token counter patterns - Reduce typing suppression from 3s/1s to 1.5s/500ms so Claude's response appears faster in the live stream Claude's thinking blocks, tool calls, search results, and text responses now flow through to the server live stream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The --resume -p process on terminal close exits immediately with nothing to do, triggering premature finalization. Remove the resume-on-close machinery entirely. On terminal close: SIGCONT the original process and let it finish its own work. The sidecar's contributions (task changes, guidance) are on disk — the original process picks them up as it continues. Removed: LoopSpawnConfig, spawnConfig on RunningLoop, onceComplete reference, resume spawn logic, SIGKILL cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On terminal close: kill the paused original (stale context) and spawn --resume <sidecarSessionId> -p with a continuation prompt: "Continue executing the original task, incorporating any instructions or changes the user made during the interactive session." Claude loads the sidecar's full conversation history (including user instructions like "swap the dependency judge") and the prompt gives it work to do so -p doesn't exit immediately. This forces Claude to adopt the sidecar's context and continue the task. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SIGKILL'd original's exit event fires onceComplete before the resume process starts, causing premature finalization. Fix: leave the original SIGSTOP'd forever. A paused process never exits, so its onceComplete never fires. The resume process's exit triggers finalization via its own wiring. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hand-rolled ANSI stripping with a proper headless terminal emulator. Feed raw PTY bytes into xterm, read clean text lines from its buffer — no regex hacks, no manual escape handling. - @xterm/headless processes all escape sequences correctly (CSI, OSC, cursor moves, redraws, colors, etc.) - Extract lines by reading the terminal buffer, not parsing bytes - Dedup spinner animation: skip lines that differ only by leading spinner symbol (✳Running... ≡ ✶Running... ≡ ✻Running...) - Token counter extraction from clean text - Debounce extraction to 1s after last data - Typing suppression: skip extraction during user input Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use default import + destructure for CJS compatibility:
import pkg from '@xterm/headless'; const { Terminal } = pkg;
- Replace single lastLine dedup with a Set of recently emitted
lines (stripped of spinner prefixes). Catches repeats even
when other lines are interleaved between spinner frames.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The buffer API requires allowProposedApi: true in xterm v6. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- On Enter: reset lastLineCount to current buffer length so all echoed keystrokes are marked as "already seen" and skipped - Strip leaked escape fragments like [O from line start - Longer typing suppression on Enter (2s) vs regular keys (500ms) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- interactiveTerminalAvailable now uses shouldUseInteractiveTerminal directly — works for all commands including PLAN/EXECUTE - Store spawnConfig for all spawn paths (spawnClaudePipeline and run-loop.sh) so the resume-on-close works for every command - Store onceComplete reference on RunningLoop for resume wiring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
--resume -p exits non-zero even when work completed successfully. The resume is a continuation mechanism — actual success is determined by handleProcessCompletion checking artifacts on disk. Always pass 0 so finalization runs the success path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
shouldUseInteractiveTerminal is scoped inside the try block. Use the getter directly for the job store upsert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Terminal close: just SIGCONT the original. No resume spawn, no kill, no finalization wiring. - Sidecar: try --resume <sessionId> if available, fall back to fresh session if not (handles run-loop.sh where session may be locked by the orchestrating process). - All commands show terminal button via getInteractiveTerminal(). - Remove LoopSpawnConfig, onceComplete from RunningLoop — no longer needed without resume-on-close. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
thadeusb
reviewed
May 14, 2026
shafty023
reviewed
May 14, 2026
Contributor
shafty023
left a comment
There was a problem hiding this comment.
Workflow review findings posted inline.
The original process's session is locked (SIGSTOP'd but alive). --resume fails with "No conversation found" for PLAN/EXECUTE and any command where the session is held by the paused process. Start a fresh Claude session in the same claudeWorkDir instead. The sidecar can read/modify files and tasks on shared disk without needing the original's conversation history. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sidecar: --resume <sessionId> (works for evaluate, needs session ID fix for PLAN/EXECUTE). Falls back to fresh session if no ID. Terminal close: leave original paused, spawn headless --resume <sessionId> -p with continuation prompt. The session includes the sidecar's turns so Claude picks up user guidance. Paused PIDs stack and get SIGKILL'd when the leaf completes. Default: interactiveTerminal = false for slow roll release. Remove the false→true migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mikeangstadt
commented
May 15, 2026
Contributor
Author
mikeangstadt
left a comment
There was a problem hiding this comment.
Code Review — 4 HIGH, 3 MEDIUM findings. See inline comments below.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
claude --resume <sessionId>(or fresh session) alongside the paused original — user can give guidance, modify tasks, swap judges@xterm/headlessfor clean text extraction, streamed to server as JSONL eventsclaudeWorkDir— file/task changes visible to both/bin/bashfix: Use absolute path inbuildClaudePipelineto fixposix_spawnpfailures in packaged macOS buildsinteractiveTerminaldefault: Enabled by default with migration for existing installsclaude-output-interactive.jsonl/symphony-loop-interactive.log, merged at finalizationArchitecture
Test plan
posix_spawnperrors🤖 Generated with Claude Code