"""Session management commands for SDD CLI.
Provides commands for session tracking, context limits, and consultation monitoring.
"""
import os
import secrets
from pathlib import Path
from typing import List, Optional
import click
from foundry_mcp.cli.agent import agent_gated, get_agent_type
from foundry_mcp.cli.transcript import find_transcript_by_marker, parse_transcript
TRANSCRIPT_OPT_IN_ENV = "FOUNDRY_MCP_ALLOW_TRANSCRIPTS"
from foundry_mcp.cli.context import (
get_context_tracker,
get_session_status,
record_consultation,
)
from foundry_mcp.cli.logging import cli_command, get_cli_logger
from foundry_mcp.cli.output import emit_error, emit_success
from foundry_mcp.cli.registry import get_context
from foundry_mcp.cli.resilience import (
FAST_TIMEOUT,
MEDIUM_TIMEOUT,
handle_keyboard_interrupt,
with_sync_timeout,
)
logger = get_cli_logger()
# Valid work modes
WORK_MODES = frozenset({"single", "autonomous"})
DEFAULT_WORK_MODE = "single"
@click.group("session")
def session() -> None:
"""Session and context management commands."""
pass
@session.command("start")
@click.option("--id", "session_id", help="Custom session ID.")
@click.option(
"--max-consultations", type=int, help="Maximum LLM consultations allowed."
)
@click.option("--max-tokens", type=int, help="Maximum context tokens allowed.")
@click.pass_context
@cli_command("start")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Session start timed out")
def start_session_cmd(
ctx: click.Context,
session_id: Optional[str],
max_consultations: Optional[int],
max_tokens: Optional[int],
) -> None:
"""Start a new CLI session with optional limits.
Sessions track consultation usage and context budget for LLM workflows.
"""
from foundry_mcp.cli.context import SessionLimits
tracker = get_context_tracker()
# Build limits if any overrides provided
limits = None
if max_consultations is not None or max_tokens is not None:
limits = SessionLimits(
max_consultations=max_consultations or 50,
max_context_tokens=max_tokens or 100000,
)
session = tracker.start_session(session_id=session_id, limits=limits)
emit_success(
{
"session_id": session.session_id,
"started_at": session.started_at,
"limits": {
"max_consultations": session.limits.max_consultations,
"max_context_tokens": session.limits.max_context_tokens,
},
}
)
@session.command("status")
@click.pass_context
@cli_command("status")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Session status lookup timed out")
def session_status_cmd(ctx: click.Context) -> None:
"""Get current session status and usage."""
status = get_session_status()
emit_success(status)
@session.command("record")
@click.option("--tokens", type=int, default=0, help="Estimated tokens used.")
@click.pass_context
@cli_command("record")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Record consultation timed out")
def record_consultation_cmd(ctx: click.Context, tokens: int) -> None:
"""Record an LLM consultation.
Tracks consultation count and token usage against session limits.
"""
result = record_consultation(tokens)
emit_success(result)
@session.command("reset")
@click.pass_context
@cli_command("reset")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Session reset timed out")
def reset_session_cmd(ctx: click.Context) -> None:
"""Reset the current session."""
tracker = get_context_tracker()
tracker.reset()
emit_success({"message": "Session reset"})
@session.command("limits")
@click.pass_context
@cli_command("limits")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Limits lookup timed out")
def show_limits_cmd(ctx: click.Context) -> None:
"""Show current session limits and remaining budget."""
tracker = get_context_tracker()
session = tracker.get_session()
if session is None:
emit_success(
{
"active": False,
"message": "No active session. Use 'sdd session start' to begin.",
"default_limits": {
"max_consultations": tracker._default_limits.max_consultations,
"max_context_tokens": tracker._default_limits.max_context_tokens,
"warn_at_percentage": tracker._default_limits.warn_at_percentage,
},
}
)
else:
emit_success(
{
"active": True,
"session_id": session.session_id,
"limits": {
"max_consultations": session.limits.max_consultations,
"max_context_tokens": session.limits.max_context_tokens,
"warn_at_percentage": session.limits.warn_at_percentage,
},
"usage": {
"consultations_used": session.stats.consultation_count,
"consultations_remaining": session.consultations_remaining,
"tokens_used": session.stats.estimated_tokens_used,
"tokens_remaining": session.tokens_remaining,
},
"status": {
"consultation_percentage": round(
session.consultation_usage_percentage, 1
),
"token_percentage": round(session.token_usage_percentage, 1),
"should_warn": session.should_warn,
"at_limit": session.at_limit,
},
}
)
@session.command("capabilities")
@click.pass_context
@cli_command("capabilities")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Capabilities lookup timed out")
def session_capabilities_cmd(ctx: click.Context) -> None:
"""Show CLI capabilities.
Returns a manifest of available features, commands, and their status
for AI coding assistants to understand available functionality.
"""
from foundry_mcp.cli.main import cli
cli_ctx = get_context(ctx)
# Get registered command groups
command_groups = {}
for name, cmd in cli.commands.items():
if hasattr(cmd, "commands"): # It's a group
command_groups[name] = {
"type": "group",
"subcommands": list(cmd.commands.keys()),
}
else:
command_groups[name] = {"type": "command"}
# Known CLI capabilities
capabilities = {
"json_output": True, # All output is JSON
"spec_driven": True, # SDD methodology supported
"session_tracking": True, # Session/context tracking
"rate_limiting": True, # Rate limiting built-in
}
emit_success(
{
"version": "0.1.0",
"name": "foundry-cli",
"capabilities": capabilities,
"command_groups": list(command_groups.keys()),
"command_count": len(cli.commands),
"specs_dir": str(cli_ctx.specs_dir) if cli_ctx.specs_dir else None,
}
)
def get_work_mode() -> str:
"""Get the configured work mode from environment.
Work mode controls how sdd-next executes tasks:
- "single": Execute one task at a time, pause for approval
- "autonomous": Execute all tasks in a phase without pausing
Set via MCP server config:
"env": {"FOUNDRY_MCP_WORK_MODE": "autonomous"}
Returns:
Work mode string ("single" or "autonomous").
"""
env_mode = os.environ.get("FOUNDRY_MCP_WORK_MODE", "")
mode = env_mode.lower().strip()
return mode if mode in WORK_MODES else DEFAULT_WORK_MODE
@session.command("work-mode")
@click.pass_context
@cli_command("work-mode")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Work mode lookup timed out")
def work_mode_cmd(ctx: click.Context) -> None:
"""Get the current work mode for task execution.
Work mode is configured via FOUNDRY_MCP_WORK_MODE environment variable
in the MCP server configuration.
Modes:
- single: Execute one task at a time, pause for approval
- autonomous: Execute all tasks in a phase without pausing
"""
mode = get_work_mode()
agent = get_agent_type()
emit_success(
{
"work_mode": mode,
"agent_type": agent,
"modes_available": list(WORK_MODES),
"configured_via": "FOUNDRY_MCP_WORK_MODE",
}
)
@session.command("token-usage")
@agent_gated("claude-code")
@click.option("--session-marker", help="Session marker from generate-marker command.")
@click.pass_context
@cli_command("token-usage")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Token usage lookup timed out")
def token_usage_cmd(ctx: click.Context, session_marker: Optional[str]) -> None:
"""Monitor token and context usage (Claude Code only).
Parses Claude Code transcript files to extract token usage metrics.
Requires agent_type=claude-code in MCP configuration.
Use --session-marker to filter to a specific session.
"""
# Note: Full implementation requires transcript parsing logic
# For now, return a placeholder indicating the feature is available
emit_success(
{
"available": True,
"agent_type": "claude-code",
"session_marker": session_marker,
"message": "Token usage tracking available. Full metrics require transcript access.",
"hint": "Use generate-marker to create a session marker for tracking.",
}
)
@session.command("generate-marker")
@agent_gated("claude-code")
@click.pass_context
@cli_command("generate-marker")
@handle_keyboard_interrupt()
@with_sync_timeout(FAST_TIMEOUT, "Marker generation timed out")
def generate_marker_cmd(ctx: click.Context) -> None:
"""Generate a session marker for transcript identification (Claude Code only).
Creates a unique marker that can be used to identify and filter
transcript entries for token usage tracking.
Requires agent_type=claude-code in MCP configuration.
"""
marker = f"SESSION_MARKER_{secrets.token_hex(4).upper()}"
emit_success(
{
"marker": marker,
"usage": "Include this marker in your prompts to track context usage.",
"hint": "Pass to 'session token-usage --session-marker' to filter metrics.",
}
)
@session.command("context")
@agent_gated("claude-code")
@click.option(
"--session-marker",
required=True,
help="Session marker from generate-marker command for context tracking.",
)
@click.option(
"--check-limits",
is_flag=True,
help="Include limit checking and recommendations.",
)
@click.option(
"--transcript-dir",
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Explicit directory containing transcript JSONL files.",
)
@click.option(
"--allow-home-transcripts",
is_flag=True,
help="Allow scanning ~/.claude/projects for transcripts (requires opt-in).",
)
@click.pass_context
@cli_command("context")
@handle_keyboard_interrupt()
@with_sync_timeout(MEDIUM_TIMEOUT, "Context check timed out")
def context_cmd(
ctx: click.Context,
session_marker: str,
check_limits: bool,
transcript_dir: Optional[Path],
allow_home_transcripts: bool,
) -> None:
"""Check current context usage percentage (Claude Code only).
Completes the two-step context tracking contract:
1. Run 'sdd session generate-marker' to get a marker
2. Run 'sdd session context --session-marker <marker>' to check usage
The session marker is logged to the transcript and used to calculate
context percentage by analyzing conversation length.
Example:
sdd session generate-marker
# Returns: SESSION_MARKER_ABCD1234
sdd session context --session-marker SESSION_MARKER_ABCD1234
# Returns: {"context_percentage_used": 45}
"""
# Validate marker format
if not session_marker.startswith("SESSION_MARKER_"):
emit_error(
"Invalid session marker format",
code="INVALID_MARKER",
error_type="validation",
remediation="Use a marker from 'sdd session generate-marker'",
details={"provided_marker": session_marker},
)
return
transcript_dirs: Optional[List[Path]] = None
if transcript_dir is not None:
resolved_dir = transcript_dir.expanduser().resolve()
if not resolved_dir.exists() or not resolved_dir.is_dir():
emit_error(
"Transcript directory not found",
code="VALIDATION_ERROR",
error_type="validation",
remediation="Pass a directory containing transcript JSONL files",
details={"transcript_dir": str(transcript_dir)},
)
return
transcript_dirs = [resolved_dir]
allow_home_search = allow_home_transcripts or bool(
os.environ.get(TRANSCRIPT_OPT_IN_ENV, "").strip()
)
if transcript_dirs is None and not allow_home_search:
emit_error(
"Transcript access disabled",
code="TRANSCRIPTS_DISABLED",
error_type="forbidden",
remediation=(
"Pass --transcript-dir, use --allow-home-transcripts, or set FOUNDRY_MCP_ALLOW_TRANSCRIPTS=1"
),
details={"session_marker": session_marker},
)
return
# Find transcript containing the session marker
transcript_path = find_transcript_by_marker(
Path.cwd(),
session_marker,
search_dirs=transcript_dirs,
allow_home_search=allow_home_search,
)
if transcript_path is None:
emit_error(
"Could not find transcript containing marker",
code="TRANSCRIPT_NOT_FOUND",
error_type="not_found",
remediation=(
"Ensure you run 'sdd session generate-marker' first, then wait for "
"the marker to be logged before running 'sdd session context'."
),
details={
"session_marker": session_marker,
"cwd": str(Path.cwd()),
},
)
return
# Parse the transcript to get token metrics
metrics = parse_transcript(transcript_path)
if metrics is None:
emit_error(
"Failed to parse transcript file",
code="PARSE_ERROR",
error_type="internal",
remediation="Check that the transcript file is valid JSONL.",
details={"transcript_path": str(transcript_path)},
)
return
# Calculate context percentage (default max context: 155,000 tokens)
max_context = 155000
context_percentage = round(metrics.context_percentage(max_context))
recommendations = []
if check_limits:
if context_percentage >= 85:
recommendations.append(
"Context at or above 85%. Consider '/clear' and '/sdd-begin'."
)
elif context_percentage >= 70:
recommendations.append("Context above 70%. Monitor usage closely.")
result = {"context_percentage_used": int(context_percentage)}
if check_limits:
result["session_marker"] = session_marker
result["recommendations"] = recommendations
result["threshold_warning"] = 85
result["threshold_stop"] = 90
result["context_length"] = metrics.context_length
result["max_context"] = max_context
emit_success(result)