Skip to content

Chapter 4: Session Manager

In Chapter 3: ACP, we saw how the TypeScript TUI and the Rust backend speak to each other over a JSON-RPC protocol. The TUI sends a session/new request, the backend sends back a session ID. The TUI sends session/prompt, the backend streams back the agent's response.

But where do those messages actually land inside the Rust backend? Who decides which session gets which prompt? Who creates the session in the first place?

That's the Session Manager.


Motivation: Why Do We Need a Session Manager?

Imagine a hotel front desk. Guests check in, get assigned rooms, and the concierge routes messages and room service to the right room. Without the front desk, guests would wander the hallways, deliveries would go to random rooms, and nobody would know who's checked in.

The Session Manager is that front desk. It solves three problems:

  1. Multi-session coordination — kiro-cli can run multiple conversations at once (a main session plus subagent sessions). Someone needs to track which sessions exist and route messages to the right one.
  2. Lifecycle management — Sessions are created, loaded from disk, swapped to different agents, and terminated. Each transition has side effects (loading configs, spawning MCP servers, cleaning up processes).
  3. State isolation — Each session has its own agent config, conversation history, tool permissions, and MCP servers. The Session Manager ensures one session's state never leaks into another.

A Concrete Use Case

Let's trace what happens when you open kiro-cli and start typing:

  1. TUI sends session/new — "I need a new conversation in /home/user/my-project."
  2. Session Manager receives the request — It loads agent configs, resolves which agent to use, creates a SessionDb (the on-disk persistence handle), spawns an AcpSession actor with the right config, and returns the session ID.
  3. TUI sends session/prompt — "Fix the login bug."
  4. Session Manager routes the prompt — It looks up the session by ID in its internal HashMap, finds the AcpSessionHandle, and forwards the prompt to that session's Agent Loop.

The TUI never talks to the Agent Loop directly. Every request goes through the Session Manager first.

TUI ──session/new──► SessionManager ──creates──► AcpSession ──► AgentLoop
TUI ──session/prompt──► SessionManager ──routes──► AcpSession ──► AgentLoop

Key Concepts

The Session Registry

At its core, the Session Manager is a HashMap from session IDs to session handles:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs
pub struct SessionManager {
    sessions: HashMap<SessionId, AcpSessionHandle>,
    agent_configs: Vec<LoadedAgentConfig>,
    // ... orchestration state, code intelligence, etc.
}

Every active session — whether it's the main conversation or a spawned subagent — lives in this map. When a request arrives, the manager looks up the target session by ID and delegates to it.

Session Lifecycle

A session moves through these states:

State What's happening
Create session/new → Manager builds an AcpSession with agent config, MCP servers, tool permissions, and persistence
Load session/load → Manager reads an existing SessionDb from ~/.kiro/sessions/cli/{id}.json, replays history
Active Session is running — accepting prompts, streaming responses, calling tools
Swap /agent command → Manager hot-swaps the agent config without destroying the session
Terminate Session exits → Manager removes it from the registry, shuts down MCP servers, releases the lock file

Session Persistence (SessionDb)

Each session is backed by three files on disk:

~/.kiro/sessions/cli/
├── {session_id}.json   ← metadata (cwd, timestamps, agent name, permissions)
├── {session_id}.jsonl  ← append-only event log (prompts, responses, tool calls)
└── {session_id}.lock   ← PID lock file (prevents concurrent access)

The SessionDb struct in crates/chat-cli-v2/src/agent/session/mod.rs manages these files. It uses atomic writes (write-to-temp-then-rename) for the metadata file and an RAII lock guard that auto-releases on drop:

// crates/chat-cli-v2/src/agent/session/mod.rs
pub struct SessionDb {
    _lock_guard: SessionLockGuard,
    session: Mutex<SessionData>,
    sessions_dir: PathBuf,
}

The lock file prevents two kiro-cli processes from opening the same session simultaneously. If a process crashes, the stale lock is detected by checking whether the owning PID is still alive.

The Actor Pattern

The Session Manager runs as a tokio actor — a long-lived async task that processes messages from a channel one at a time:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs (simplified)
let (tx, mut session_rx) = mpsc::channel(25);

tokio::spawn(async move {
    loop {
        let Some(request) = session_rx.recv().await else {
            break;
        };
        session_manager.handle_request(request).await;
    }
});

External code never touches SessionManager directly. Instead, it holds a SessionManagerHandle — a thin wrapper around the channel sender — and sends typed request messages:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs
#[derive(Clone, Debug)]
pub struct SessionManagerHandle {
    tx: mpsc::Sender<SessionManagerRequest>,
}

Each request carries a oneshot::Sender for the response, so the caller can await the result. This pattern serializes all mutations through a single task, eliminating the need for locks on the session registry.


How It Works: Step by Step

Let's walk through the session/new flow in detail.

sequenceDiagram
    participant TUI as TUI (TypeScript)
    participant ACP as ACP Layer
    participant SM as SessionManager
    participant Session as AcpSession
    participant DB as SessionDb (disk)

    TUI->>ACP: session/new {cwd: "/home/user/project"}
    ACP->>SM: StartSession(config)
    SM->>SM: Resolve agent config
    SM->>DB: Create session files (.json, .jsonl, .lock)
    SM->>Session: AcpSessionBuilder.start_session()
    Session-->>SM: (handle, ready_rx)
    SM-->>ACP: StartSessionResult {handle, agent_name, ...}
    ACP-->>TUI: {sessionId: "abc-123"}

Step 1: Resolve the Agent Config

When StartSession arrives, the manager resolves which agent to use through a priority chain:

explicit config > CLI --agent flag > persisted session agent > user setting > default

This happens in handle_request for the StartSession variant (line ~300 of session_manager.rs). If the requested agent isn't found, it falls back to the built-in default and records the original request so the TUI can show a warning.

Step 2: Build the Session

The manager constructs an AcpSessionBuilder with everything the session needs:

  • Agent config — system prompt, allowed tools, MCP server definitions
  • Code intelligence — shared per-CWD, lazily initialized
  • Trust settings — from --trust-tools CLI flag
  • Connection context — the JSON-RPC connection to the TUI (cloned for subagents)
  • MCP registry — enterprise governance data, if applicable
// crates/chat-cli-v2/src/agent/acp/session_manager.rs (simplified)
let mut builder = AcpSessionBuilder::default()
    .os(self.os.clone())
    .session_id(config.session_id)
    .cwd(config.cwd.clone())
    .initial_agent_config(Cow::Owned(agent_config))
    .code_intelligence(code_intel)
    .trust_all_tools(self.trust_all_tools);

Step 3: Register and Return

Once the session starts, the manager stores the handle in its registry and returns the result:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs (simplified)
let (handle, ready_rx, model_id, _) = builder.start_session().await?;
self.sessions.insert(session_id.clone(), handle.clone());

The ready_rx is a oneshot channel that resolves when MCP servers finish initializing — the TUI waits on this before showing the input prompt.


Dispatch: Routing Requests to Sessions

Once a session exists, subsequent requests (prompts, mode changes, termination) are routed by session ID. The handle_request method is a large match over SessionManagerRequestData variants:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs (simplified)
match data {
    StartSession { .. } => { /* create/load session */ },
    GetSessionHandle { .. } => {
        let handle = self.sessions.get(&session_id);
        // return handle to caller
    },
    TerminateSession => {
        if let Some(handle) = self.sessions.remove(&session_id) {
            handle.shutdown().await;
        }
    },
    SetMode { mode_id, .. } => {
        // hot-swap agent config on existing session
    },
    // ... 20+ more variants for orchestration, messaging, etc.
}

This is the central dispatch loop. Every ACP request that touches session state flows through here.


Orchestration: Managing Subagent Trees

Beyond single sessions, the Session Manager coordinates orchestrated sessions — subagents spawned by a parent to work on subtasks. This is the "crew" system:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs
orchestrated_sessions: HashMap<String, OrchestratedSession>,
groups: HashMap<String, SessionGroup>,
inbox_store: InboxStore,
permission_store: PermissionStore,

When a parent agent calls spawn_session, the manager:

  1. Creates a new AcpSession marked as a subagent
  2. Registers parent→child permissions (who can message whom)
  3. Runs the task asynchronously — when the subagent finishes, its result is delivered to the parent's inbox
  4. Triggers any pending DAG stages whose dependencies are now satisfied

This enables multi-agent workflows where a coordinator spawns workers, workers report back, and the next wave of workers starts automatically.


Shutdown and Cleanup

When kiro-cli exits, the Session Manager gracefully shuts down all sessions:

// crates/chat-cli-v2/src/agent/acp/session_manager.rs (simplified)
SessionManagerRequestData::Shutdown { resp_sender } => {
    let sessions: Vec<_> = self.sessions.drain().collect();
    for (id, handle) in &sessions {
        timeout(Duration::from_secs(4), handle.shutdown()).await;
    }
}

Each session gets 4 seconds to clean up its MCP server child processes. If a session hangs, the timeout fires and the process moves on. The SessionDb lock guard is dropped automatically, releasing the .lock file so the session can be reopened later.


Summary of Key Files

File Role
crates/chat-cli-v2/src/agent/acp/session_manager.rs The actor: session registry, dispatch loop, orchestration
crates/chat-cli-v2/src/agent/session/mod.rs SessionDb: on-disk persistence, lock files, event log
crates/chat-cli-v2/src/agent/acp/acp_agent.rs AcpSessionBuilder / AcpSessionConfig: session construction
crates/chat-cli-v2/src/agent/acp/schema.rs ACP extension types (ListSessionsRequest, TerminateSessionRequest, etc.)

Recap

The Session Manager is the central coordinator of kiro-cli's V2 backend. Like a hotel front desk:

  • It checks guests in (creates sessions with the right agent config and persistence)
  • It routes messages (dispatches prompts and commands to the correct session by ID)
  • It manages checkouts (terminates sessions, cleans up MCP servers, releases locks)
  • It coordinates group activities (orchestrates subagent trees with DAG-based scheduling)

It runs as a single tokio actor processing requests from a channel, which means all session state mutations are serialized — no locks needed on the registry itself.


What's Next

Before the Session Manager can start an Agent Loop, it needs to know how to configure it — which system prompt to use, which tools to allow, which MCP servers to launch. That configuration comes from agent JSON files.

Next up: Chapter 5: Agent Configuration — the personality blueprint that defines what an agent can do.