Skip to main content

Overview

The Session Manager orchestrates the Runtime, Agent, and Workspace plugins to create, manage, and destroy agent sessions. It handles the full session lifecycle from creation to cleanup. Core responsibilities:
  • Spawn new sessions (create workspace → runtime → launch agent)
  • Restore crashed/killed sessions
  • List all sessions with live state enrichment
  • Kill sessions and clean up resources
  • Send messages to running sessions
  • Automatic cleanup of completed work
The Session Manager is stateless - all session data is stored in flat metadata files. It rebuilds session state on every operation by reading these files and polling plugins.

Architecture

import { createSessionManager } from '@composio/ao-core';

const sessionManager = createSessionManager({
  config: orchestratorConfig,
  registry: pluginRegistry,
});

// Spawn a new session
const session = await sessionManager.spawn({
  projectId: 'my-app',
  issueId: 'INT-1234',
  prompt: 'Fix the login bug',
});

// List all sessions
const sessions = await sessionManager.list();

// Send a message
await sessionManager.send('my-app-1', 'Please add unit tests');

// Kill a session
await sessionManager.kill('my-app-1');

Methods

spawn

Create a new agent session.
spawn(config: SessionSpawnConfig): Promise<Session>
config.projectId
string
required
Project ID from agent-orchestrator.yaml (e.g., “my-app”).
config.issueId
string
Issue identifier (e.g., “INT-1234”, “#42”). Can be tracker issue or free-text.
config.branch
string
Git branch name. If not specified, inferred from issue or session ID.
config.prompt
string
Initial prompt for the agent. Combined with issue context if available.
config.agent
string
Override agent plugin for this session (e.g., “codex”, “claude-code”).
Returns:
Session
object
The newly created session object with all metadata.
Example:
// Spawn with issue ID (fetches issue from tracker)
const session = await sessionManager.spawn({
  projectId: 'my-app',
  issueId: 'INT-1234',
  prompt: 'Add TypeScript types',
});

// Spawn with explicit branch
const session2 = await sessionManager.spawn({
  projectId: 'my-app',
  branch: 'feature/new-dashboard',
  prompt: 'Build the dashboard UI',
});

// Spawn with agent override
const session3 = await sessionManager.spawn({
  projectId: 'my-app',
  agent: 'codex',  // Use Codex instead of default
  prompt: 'Refactor the API',
});

Spawn Process

The spawn process follows these steps:
  1. Validate project - Check that projectId exists in config
  2. Validate issue - If issueId provided, fetch from tracker (fails fast on auth/network errors)
  3. Reserve session ID - Atomically create unique ID (e.g., “my-app-1”)
  4. Create workspace - Use Workspace plugin to create isolated environment
  5. Generate prompt - Combine user prompt + issue context + project rules
  6. Create runtime - Launch agent process in runtime environment
  7. Write metadata - Persist session state to disk
  8. Post-launch setup - Configure agent (optional plugin hook)
  9. Send initial prompt - For agents with promptDelivery: "post-launch"
If any step fails after workspace creation, the Session Manager automatically cleans up all created resources (workspace, runtime, metadata) to prevent resource leaks.

Branch Name Resolution

Branch names are determined by priority:
if (config.branch) {
  // 1. Explicit branch always wins
  branch = config.branch;
} else if (config.issueId && tracker) {
  // 2. Let tracker generate branch name
  branch = tracker.branchName(config.issueId, project);
} else if (config.issueId) {
  // 3. Generate from issue ID
  branch = `feat/${sanitize(config.issueId)}`;
} else {
  // 4. Fallback to session ID
  branch = `session/${sessionId}`;
}

spawnOrchestrator

Create an orchestrator session that manages other sessions.
spawnOrchestrator(config: OrchestratorSpawnConfig): Promise<Session>
config.projectId
string
required
Project ID from config.
config.systemPrompt
string
System prompt with orchestrator instructions. Automatically written to file to avoid shell truncation.
Returns:
Session
object
The orchestrator session with role: "orchestrator" metadata.
Example:
import { readFileSync } from 'node:fs';

const systemPrompt = readFileSync('./orchestrator-prompt.md', 'utf-8');

const orchestrator = await sessionManager.spawnOrchestrator({
  projectId: 'my-app',
  systemPrompt,
});

// Session ID: "my-app-orchestrator"
// Permissions: always "skip" (no permission prompts)
Orchestrator sessions run in the project directory (not a worktree) and have permissions set to “skip” so they can autonomously run ao CLI commands.

list

List all sessions, optionally filtered by project.
list(projectId?: string): Promise<Session[]>
projectId
string
Filter to sessions from this project only. If omitted, returns all sessions.
Returns:
Session[]
array
Array of session objects enriched with live runtime state and agent activity.
Example:
// List all sessions
const allSessions = await sessionManager.list();

// List sessions for specific project
const appSessions = await sessionManager.list('my-app');

// Display session info
for (const session of allSessions) {
  console.log(`${session.id}: ${session.status} (${session.activity ?? 'unknown'})`);
  if (session.pr) console.log(`  PR: ${session.pr.url}`);
}

Session Enrichment

The list() method enriches sessions with live state:
// 1. Read metadata from disk
const raw = readMetadataRaw(sessionsDir, sessionId);
const session = metadataToSession(sessionId, raw);

// 2. Check runtime liveness (with 2s timeout per session)
if (session.runtimeHandle) {
  const alive = await runtime.isAlive(session.runtimeHandle);
  if (!alive) session.status = 'killed';
}

// 3. Detect agent activity (reads JSONL files)
const activity = await agent.getActivityState(session);
if (activity) {
  session.activity = activity.state;
  session.lastActivityAt = activity.timestamp ?? session.lastActivityAt;
}

// 4. Enrich with agent session info (summary, cost)
const info = await agent.getSessionInfo(session);
if (info) session.agentInfo = info;
Enrichment includes subprocess calls (tmux/ps checks) which can be slow under load. Each session has a 2-second timeout. If enrichment times out, the session keeps its metadata values.

get

Get a single session by ID.
get(sessionId: SessionId): Promise<Session | null>
sessionId
string
required
Session ID to retrieve (e.g., “my-app-1”).
Returns:
Session | null
object
Session object if found, null otherwise.
Example:
const session = await sessionManager.get('my-app-1');

if (!session) {
  console.error('Session not found');
  return;
}

console.log(`Status: ${session.status}`);
console.log(`Branch: ${session.branch}`);
console.log(`Workspace: ${session.workspacePath}`);

restore

Restore a crashed or killed session.
restore(sessionId: SessionId): Promise<Session>
sessionId
string
required
Session ID to restore.
Returns:
Session
object
The restored session with updated restoredAt timestamp.
Throws:
  • SessionNotRestorableError - Session is not in a terminal state or is merged
  • WorkspaceMissingError - Workspace doesn’t exist and can’t be recreated
Example:
try {
  const session = await sessionManager.restore('my-app-1');
  console.log('Session restored:', session.id);
} catch (err) {
  if (err instanceof SessionNotRestorableError) {
    console.error('Cannot restore:', err.reason);
  } else if (err instanceof WorkspaceMissingError) {
    console.error('Workspace missing:', err.path);
  }
}

Restore Process

  1. Find metadata - Check active sessions, fall back to archive
  2. Validate restorability - Must be terminal state (killed/errored) but not merged
  3. Check workspace - Verify workspace exists or attempt recreation
  4. Destroy old runtime - Kill any orphaned runtime processes
  5. Get launch command - Try agent’s restore command, fall back to fresh launch
  6. Create runtime - Launch agent with restored environment
  7. Update metadata - Set status to “spawning”, record restoredAt
Restorable:
  • killed - Agent process exited
  • errored - Session encountered an error
  • done - Session completed
  • terminated - Forcibly terminated
  • cleanup - Cleaned up but can be restored
Non-restorable:
  • merged - PR already merged (cannot reopen)
  • working, pr_open, etc. - Session is still active

kill

Terminate a session and clean up all resources.
kill(sessionId: SessionId): Promise<void>
sessionId
string
required
Session ID to kill.
Example:
await sessionManager.kill('my-app-1');
console.log('Session killed');

Kill Process

  1. Find session - Locate metadata across all projects
  2. Destroy runtime - Stop agent process (tmux kill, docker stop, etc.)
  3. Destroy workspace - Delete worktree or clone (unless it’s the project path)
  4. Archive metadata - Move to archive/ subdirectory with timestamp
Workspaces that match the project path are NOT deleted (orchestrator sessions run in the main repo).

cleanup

Automatically clean up completed sessions.
cleanup(projectId?: string, options?: { dryRun?: boolean }): Promise<CleanupResult>
projectId
string
Limit cleanup to this project.
options.dryRun
boolean
default:"false"
Preview what would be cleaned up without actually killing sessions.
Returns:
CleanupResult
object
interface CleanupResult {
  killed: string[];    // Sessions that were (or would be) killed
  skipped: string[];   // Sessions that were skipped
  errors: Array<{      // Sessions that failed to clean up
    sessionId: string;
    error: string;
  }>;
}
Example:
// Dry run to see what would be cleaned
const preview = await sessionManager.cleanup(undefined, { dryRun: true });
console.log('Would kill:', preview.killed);
console.log('Would skip:', preview.skipped);

// Actually clean up
const result = await sessionManager.cleanup();
console.log('Killed:', result.killed);
if (result.errors.length > 0) {
  console.error('Errors:', result.errors);
}

Cleanup Criteria

Sessions are killed if ANY of these conditions are met:
  1. PR is merged or closed - Work is complete
  2. Issue is completed - Work is done
  3. Runtime is dead - Agent process no longer running
Never cleaned up:
  • Orchestrator sessions (checked by role: "orchestrator" or ID ending in “-orchestrator”)
  • Sessions where checks fail (can’t verify state)

send

Send a message to a running session.
send(sessionId: SessionId, message: string): Promise<void>
sessionId
string
required
Session ID to send message to.
message
string
required
Message text to send to the agent.
Example:
await sessionManager.send('my-app-1', 
  'CI is failing. Run `gh pr checks` to see failures and fix them.'
);
How it works:
// 1. Find session metadata
const raw = readMetadataRaw(sessionsDir, sessionId);

// 2. Build runtime handle (stored or fabricated)
const handle = raw.runtimeHandle 
  ? JSON.parse(raw.runtimeHandle)
  : { id: sessionId, runtimeName: config.defaults.runtime };

// 3. Send via runtime plugin
const runtime = registry.get<Runtime>('runtime', handle.runtimeName);
await runtime.sendMessage(handle, message);
For tmux runtime, this uses tmux send-keys to type the message. For other runtimes, it may use stdin, API calls, or other mechanisms.

Session Object

The Session interface represents a running agent session:
interface Session {
  id: SessionId;              // Unique ID (e.g., "my-app-1")
  projectId: string;          // Config key (e.g., "my-app")
  status: SessionStatus;      // Current lifecycle status
  activity: ActivityState | null;  // Agent activity (active, ready, idle, etc.)
  branch: string | null;      // Git branch name
  issueId: string | null;     // Issue identifier
  pr: PRInfo | null;          // PR metadata if PR exists
  workspacePath: string | null;  // Path to workspace on disk
  runtimeHandle: RuntimeHandle | null;  // Runtime-specific handle
  agentInfo: AgentSessionInfo | null;  // Agent summary, cost, etc.
  createdAt: Date;            // When session was created
  lastActivityAt: Date;       // Last detected activity
  restoredAt?: Date;          // When session was restored (if ever)
  metadata: Record<string, string>;  // Raw metadata key-value pairs
}

Activity States

active
ActivityState
Agent is processing (thinking, writing code).
ready
ActivityState
Agent finished its turn, alive and waiting for input.
idle
ActivityState
Agent has been inactive for a while (stale).
waiting_input
ActivityState
Agent is asking a question / permission prompt.
blocked
ActivityState
Agent hit an error or is stuck.
exited
ActivityState
Agent process is no longer running.

Metadata Files

Sessions are stored as flat key=value files: File path:
~/.agent-orchestrator/{hash}-{projectId}/sessions/{sessionId}
Example file (my-app-1):
project=my-app
worktree=/Users/foo/.agent-orchestrator/a3b4c5d6e7f8-my-app/worktrees/my-app-1
branch=feat/INT-1234
status=working
tmuxName=a3b4c5d6e7f8-my-app-1
pr=https://github.com/org/repo/pull/42
issue=INT-1234
summary=Fix login authentication bug
agent=claude-code
createdAt=2026-03-04T10:30:00.000Z
runtimeHandle={"id":"a3b4c5d6e7f8-my-app-1","runtimeName":"tmux"}
Metadata files use user-facing session names (“my-app-1”), not tmux session names. The tmuxName field maps to the globally unique tmux session.

Error Handling

Resource Cleanup on Failure

The Session Manager automatically cleans up resources when spawn fails:
try {
  const handle = await runtime.create(...);
} catch (err) {
  // Clean up workspace
  if (workspace && workspacePath !== project.path) {
    await workspace.destroy(workspacePath);
  }
  // Clean up reserved session ID
  deleteMetadata(sessionsDir, sessionId, false);
  throw err;
}

Issue Validation

Issue validation fails fast to prevent creating resources for invalid issues:
if (config.issueId && tracker) {
  try {
    await tracker.getIssue(config.issueId, project);
  } catch (err) {
    if (isIssueNotFoundError(err)) {
      // Ad-hoc issue string - proceed without tracker
    } else {
      // Auth/network error - fail fast
      throw new Error(`Failed to fetch issue: ${err}`);
    }
  }
}

Graceful Degradation

Non-critical failures don’t stop the session:
// Post-launch prompt delivery failure is non-fatal
if (agent.promptDelivery === 'post-launch' && prompt) {
  try {
    await runtime.sendMessage(handle, prompt);
  } catch {
    // Agent is running but didn't get the prompt
    // User can retry with `ao send`
  }
}

Complete Example

import { createSessionManager } from '@composio/ao-core';
import { loadConfig } from '@composio/ao-core';
import { createPluginRegistry } from '@composio/ao-core';

// Setup
const config = loadConfig();
const registry = createPluginRegistry();
await registry.loadBuiltins(config);

const sessionManager = createSessionManager({
  config,
  registry,
});

// Spawn a new session
const session = await sessionManager.spawn({
  projectId: 'my-app',
  issueId: 'INT-1234',
  prompt: 'Add TypeScript types to the API',
});
console.log('Created session:', session.id);

// List all sessions
const sessions = await sessionManager.list();
for (const s of sessions) {
  console.log(`${s.id}: ${s.status} (${s.activity})`);
}

// Send a message
await sessionManager.send(session.id, 
  'Please add unit tests for the new types'
);

// Wait for work to complete...

// Clean up finished sessions
const cleanup = await sessionManager.cleanup();
console.log('Cleaned up:', cleanup.killed);

// Restore if needed
if (cleanup.killed.includes(session.id)) {
  const restored = await sessionManager.restore(session.id);
  console.log('Restored:', restored.id);
}

See Also

Build docs developers (and LLMs) love