using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Iciclecreek.Terminal;
namespace ClaudeDo.Ui.Services;
///
/// Thin wrapper around — the library owns the
/// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see
/// spikes/ConPtyTerminal/MainWindow.axaml.cs, which proved this renders correctly and
/// stays responsive). We only:
/// 1) apply onto the current process environment
/// before launching — Porta.Pty inherits the calling process's environment and there is no
/// per-launch env seam on / —
/// 2) relay the control's own event and
/// method.
///
public sealed class PtyTerminalSession : IDisposable
{
// Guards the set-env + LaunchProcess critical section below: two sessions starting
// back-to-back (e.g. planning sessions for two different tasks) could otherwise interleave
// their SetEnvironmentVariable calls before either LaunchProcess() forks, so one process
// inherits the other's env (e.g. CLAUDEDO_PLANNING_TOKEN, breaking that session's own MCP
// auth). Process-wide env leakage AFTER a launch has forked remains a documented limitation
// — Porta.Pty has no per-launch env seam, so the vars stay set on the whole UI process.
// The wait is bounded (see WaitForLaunchGateAsync) — a hung launch (slow disk, AV scanning
// claude.exe, a Porta.Pty/ConPTY hiccup) must not freeze every other pane open behind it.
private static readonly SemaphoreSlim s_launchGate = new(1, 1);
private static readonly TimeSpan s_launchGateTimeout = TimeSpan.FromSeconds(30);
///
/// Waits on for at most , throwing
/// instead of blocking forever. Never acquires the gate on
/// timeout, so callers must not release it in that case.
///
internal static async Task WaitForLaunchGateAsync(SemaphoreSlim gate, TimeSpan timeout, CancellationToken ct)
{
if (!await gate.WaitAsync(timeout, ct))
throw new TimeoutException("Another terminal launch is still starting up. Please retry in a moment.");
}
private TerminalControl? _control;
private bool _disposed;
public bool IsRunning { get; private set; }
public int? ExitCode { get; private set; }
/// Raised when the child process exits, on the UI thread, with its exit code.
public event EventHandler? ProcessExited;
///
/// Applies 's env vars to the current process, then drives
/// to launch it via .
///
public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default)
{
if (_control is not null) throw new InvalidOperationException("Session already started.");
_control = control;
control.ProcessExited += OnControlProcessExited;
control.Process = descriptor.Exe;
control.Args = new List(descriptor.Args);
control.StartingDirectory = descriptor.Cwd;
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
try
{
foreach (var (key, value) in descriptor.Env)
Environment.SetEnvironmentVariable(key, value);
await control.LaunchProcess();
}
finally
{
s_launchGate.Release();
}
// Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child
// process unless BeginReparent() suppressed it, and Mission Control detaches pane
// views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab
// switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose
// calls Kill() when a pane closes — so EndReparent is deliberately never called.
control.BeginReparent();
IsRunning = true;
}
private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e)
{
IsRunning = false;
ExitCode = e.ExitCode;
ProcessExited?.Invoke(this, e.ExitCode);
}
public void Kill()
{
try { _control?.Kill(); }
catch (Exception) { /* already exited */ }
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_control is not null)
_control.ProcessExited -= OnControlProcessExited;
}
}