# How the free-claude-code CLI Session Manager Spawns and Manages Subprocesses

> Learn how the free-claude-code CLI session manager spawns and manages subprocesses using asyncio for efficient execution and robust cleanup.

- Repository: [Ali Khokhar/free-claude-code](https://github.com/Alishahryar1/free-claude-code)
- Tags: internals
- Published: 2026-04-24

---

**The `CLISession` class in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) uses `asyncio.create_subprocess_exec` to launch the Claude CLI as an asynchronous subprocess, streams JSON output in 64KB chunks, and guarantees cleanup through a SIGTERM-to-SIGKILL escalation strategy coupled with a process registry that registers an `atexit` handler to prevent orphaned processes.**

The free-claude-code repository provides a Python async interface to the Claude Code CLI, wrapping the binary in a robust session manager that handles the full lifecycle of subprocess communication. At the heart of this implementation lies the **`CLISession`** class, which orchestrates everything from environment sanitization to guaranteed process termination. Understanding how this CLI session manager handles subprocess spawning reveals a production-grade pattern for managing long-running external tools in async Python applications.

## Environment and Command Preparation

Before spawning the subprocess, `CLISession` normalizes the workspace path and constructs a sanitized environment dictionary. In [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 53-84, the code validates that a directory exists at the workspace path, then builds an environment map that injects a placeholder Anthropic API key if none is present. It also sets critical variables including `ANTHROPIC_API_URL`, `ANTHROPIC_BASE_URL`, `TERM`, and `PYTHONIOENCODING` to ensure the Claude CLI operates correctly within the Python process context.

The command arguments are built dynamically based on runtime conditions. The constructor checks whether the session should resume an existing conversation, request a fork, or restrict access to specific allowed directories and plan locations. This preparation stage ensures the subprocess launches with a clean, predictable environment regardless of the host system's shell configuration.

## Asynchronous Subprocess Creation

The actual subprocess spawning occurs through **`asyncio.create_subprocess_exec`** in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 106-114. Unlike synchronous subprocess calls, this approach creates an async process with captured `stdout` and `stderr` streams, enabling non-blocking I/O. The working directory is explicitly forced to the normalized workspace path to prevent path resolution issues.

Immediately after process creation, the PID is registered with the **process registry** via `register_pid` (line 114). This registration is critical for lifecycle management—it enables the `kill_all_best_effort` function registered as an `atexit` handler in [`process_registry.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/process_registry.py) lines 22-29 to terminate any stray children if the Python interpreter exits unexpectedly.

## Streaming JSON Output Processing

Once spawned, the CLI session manager handles output through an efficient streaming architecture. The reading loop in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 124-158 consumes `stdout` in **64KB chunks**, buffering data until complete newline-delimited JSON objects are available. This chunk size balances memory efficiency with I/O performance when handling large assistant responses.

Each line passes to **`_handle_line_gen`** (lines 198-214), which parses the JSON, extracts the session ID when first encountered, and yields uniform event dictionaries to the caller. The generator yields three event types: parsed JSON events, raw line strings, and error dictionaries. The loop terminates cleanly upon EOF, processing any remaining buffered data before the coroutine exits.

## Graceful Shutdown and Process Cleanup

Guaranteed cleanup represents the most critical aspect of the subprocess lifecycle. When a task receives `asyncio.CancelledError`, the shielded **`stop()`** method triggers (lines 158-167), ensuring cleanup runs even while cancellation propagates through the task stack.

The `stop()` implementation in lines 40-57 follows a strict escalation protocol:

1. Sends `SIGTERM` to the subprocess
2. Waits up to **5 seconds** for graceful termination
3. Falls back to `SIGKILL` on timeout to force immediate shutdown

A `finally` block in lines 192-196 ensures the busy flag clears and `unregister_pid` runs regardless of success or failure. This guarantees the process registry remains accurate even if the subprocess crashes or raises exceptions.

Additionally, [`process_registry.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/process_registry.py) lines 22-29 register **`kill_all_best_effort`** as an `atexit` handler, providing a last-resort safety net that terminates any remaining tracked PIDs when the interpreter exits.

## Practical Implementation Example

The following example demonstrates spawning a session and processing the event stream:

```python
import asyncio
from free_claude_code.cli.session import CLISession

async def run():
    # Create a session bound to a workspace directory

    cli = CLISession(
        workspace_path="~/my-workspace",
        api_url="https://api.anthropic.com/v1",
        allowed_dirs=["./src"],
        plans_directory="./plans",
    )

    # Start a task – this returns an async generator of events

    async for event in cli.start_task(prompt="Write a Python hello-world script"):
        print(event)          # {"type": "assistant_message", …} or {"type":"raw", …}

        if event["type"] == "exit":
            break

    # Clean shutdown with guaranteed process termination

    await cli.stop()

asyncio.run(run())

```

This pattern instantiates the `CLISession` with custom workspace and API settings, consumes the async generator returned by `start_task`, and ensures proper cleanup via `await cli.stop()`.

## Summary

- **Environment sanitization**: The session manager normalizes paths and injects required environment variables (`ANTHROPIC_API_URL`, `PYTHONIOENCODING`, etc.) before spawning.
- **Async subprocess creation**: Uses `asyncio.create_subprocess_exec` with immediate PID registration to enable later cleanup.
- **Efficient streaming**: Reads 64KB chunks from stdout, parsing JSON events through `_handle_line_gen` to minimize memory overhead.
- **Guaranteed termination**: Implements SIGTERM-to-SIGKILL escalation with a 5-second timeout, shielded cancellation handlers, and an `atexit` registered process registry to prevent orphaned Claude CLI processes.

## Frequently Asked Questions

### How does the CLI session manager prevent orphaned processes when Python exits abruptly?

The session registers every spawned PID with a centralized process registry via `register_pid` ([`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) line 114). The registry module ([`cli/process_registry.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/process_registry.py) lines 22-29) installs a `kill_all_best_effort` function as an `atexit` handler, which sends termination signals to any remaining tracked processes when the Python interpreter shuts down, even during uncaught exceptions or SIGINT.

### What signals does the stop() method use to terminate the subprocess?

The `stop()` method in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 40-57 first sends `SIGTERM` to request graceful shutdown, then waits up to five seconds. If the process persists beyond the timeout, it escalates to `SIGKILL` for immediate termination. This two-stage approach allows the Claude CLI to save state or close connections while ensuring the process cannot survive indefinitely.

### How large are the chunks when streaming output from the Claude CLI?

The reading loop in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 124-158 consumes output in **64KB chunks** (65536 bytes). This buffer size provides efficient I/O throughput for large responses while maintaining predictable memory usage during long-running conversations.

### Can I customize the environment variables passed to the Claude CLI subprocess?

Yes. While `CLISession` automatically sets `ANTHROPIC_API_KEY`, `ANTHROPIC_API_URL`, `ANTHROPIC_BASE_URL`, `TERM`, and `PYTHONIOENCODING` in [`cli/session.py`](https://github.com/Alishahryar1/free-claude-code/blob/main/cli/session.py) lines 53-84, these values derive from the constructor parameters (`api_url`, `api_key`) and can be overridden before instantiating the session. The working directory is also configurable via the `workspace_path` parameter, which the subprocess uses as its forced working directory.