Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ class Settings(BaseSettings):
agent_commit_message_timeout_seconds: int = 120
agent_commit_message_diff_max_bytes: int = 100_000
agent_commit_message_args: str = ""
# OpenCode's data directory (holding ``opencode.db``). Empty resolves the
# XDG default (``~/.local/share/opencode``).
opencode_data_dir: str = ""

vscode_enabled: bool = True
vscode_binary: str = "code-server"
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.core.errors import validation_exception_handler
from app.core.i18n import resolve_language, set_language
from app.core.logging import configure_logging
from app.modules.agent_sessions.router import router as agent_sessions_router
from app.modules.agents.router import router as agents_router
from app.modules.ai_chat.approvals import cancel_all as cancel_pending_approvals
from app.modules.ai_chat.router import router as ai_chat_router
Expand Down Expand Up @@ -113,6 +114,7 @@ async def _apply_accept_language(request: Request, call_next):
app.include_router(vscode_router, prefix=settings.api_prefix)
app.include_router(browser_router, prefix=settings.api_prefix)
app.include_router(agents_router, prefix=settings.api_prefix)
app.include_router(agent_sessions_router, prefix=settings.api_prefix)
app.include_router(ai_providers_router, prefix=settings.api_prefix)
app.include_router(ai_chat_router, prefix=settings.api_prefix)
app.include_router(workspace_router, prefix=settings.api_prefix)
Expand Down
Empty file.
26 changes: 26 additions & 0 deletions app/modules/agent_sessions/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from fastapi import APIRouter

from app.core.deps import DbDep, SettingsDep
from app.core.errors import error_responses
from app.modules.agent_sessions import service
from app.modules.agent_sessions.schemas import AgentSessionListResponse
from app.modules.auth.deps import CurrentUser

router = APIRouter(prefix="/projects", tags=["agent-sessions"])


@router.get(
"/{project_id}/agent-sessions",
response_model=AgentSessionListResponse,
responses=error_responses(401, 403, 404),
)
def list_agent_sessions(
project_id: int,
current_user: CurrentUser,
db: DbDep,
settings: SettingsDep,
worktree: str | None = None,
) -> AgentSessionListResponse:
return AgentSessionListResponse(
sessions=service.list_project_sessions(db, current_user, project_id, worktree, settings)
)
19 changes: 19 additions & 0 deletions app/modules/agent_sessions/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from datetime import datetime
from typing import Literal

from pydantic import BaseModel


class AgentSessionRead(BaseModel):
"""A resumable session, either an in-app chat or a host CLI agent session."""

source: Literal["chat", "cli"]
id: str
title: str
agent_id: str | None = None
agent_label: str | None = None
updated_at: datetime


class AgentSessionListResponse(BaseModel):
sessions: list[AgentSessionRead]
76 changes: 76 additions & 0 deletions app/modules/agent_sessions/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

from datetime import UTC, datetime
from pathlib import Path

from fastapi import status
from sqlalchemy.orm import Session

from app.core.config import Settings
from app.core.errors import ErrorCode, api_error
from app.modules.agent_sessions.schemas import AgentSessionRead
from app.modules.agents import service as agents_service
from app.modules.agents.sessions import list_sessions as list_cli_sessions
from app.modules.ai_chat import service as chat_service
from app.modules.auth.models import User
from app.modules.git import service as git_service
from app.modules.projects import service as project_service

MAX_SESSIONS = 200


def resolve_directory(
db: Session, user: User, project_id: int, worktree: str | None, settings: Settings
) -> Path:
"""The project or worktree directory whose sessions should be listed."""
project = project_service.get_project(db, user, project_id)
directory = Path(project.path).resolve()
if worktree:
resolved = git_service.find_worktree(directory, worktree, settings)
if resolved is None or not resolved.is_dir():
raise api_error(ErrorCode.GIT_WORKTREE_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND)
directory = resolved.resolve()
return directory


def list_project_sessions(
db: Session,
user: User,
project_id: int,
worktree: str | None,
settings: Settings,
) -> list[AgentSessionRead]:
"""Merge in-app chat conversations and host CLI agent sessions, newest first."""
directory = resolve_directory(db, user, project_id, worktree, settings)

sessions: list[AgentSessionRead] = [
AgentSessionRead(
source="chat",
id=conversation.id,
title=conversation.title,
updated_at=_as_utc(conversation.updated_at),
)
for conversation in chat_service.list_conversations(db, user, project_id)
]

for session in list_cli_sessions(settings, directory):
sessions.append(
AgentSessionRead(
source="cli",
id=session.id,
title=session.title,
agent_id=session.agent_id,
agent_label=agents_service.agent_label(session.agent_id),
updated_at=_as_utc(session.updated_at),
)
)

sessions.sort(key=lambda item: item.updated_at, reverse=True)
return sessions[:MAX_SESSIONS]


def _as_utc(value: datetime) -> datetime:
"""Normalize a datetime to UTC-aware so mixed sources sort and serialize consistently."""
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
24 changes: 24 additions & 0 deletions app/modules/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass

PROMPT_PLACEHOLDER = "{prompt}"
SESSION_PLACEHOLDER = "{session}"


@dataclass(frozen=True, slots=True)
Expand All @@ -22,6 +23,9 @@ class AgentSpec:
# ``("-p", "{prompt}")`` or ``("exec", "{prompt}")``. Empty means the agent
# has no known non-interactive prompt mode.
prompt_args: tuple[str, ...] = ()
# How to resume a previous session, e.g. ``("--session", "{session}")`` or
# ``("resume", "{session}")``. Empty means the agent cannot resume.
resume_args: tuple[str, ...] = ()


class Agent(ABC):
Expand Down Expand Up @@ -49,6 +53,26 @@ def build_launch(self, command: str | None = None, args: str | None = None) -> s
resolved_args = (args if args is not None else self.spec.args).strip()
return f"{resolved_command} {resolved_args}".strip()

def build_resume_launch(
self, command: str | None, args: str | None, session_id: str | None
) -> str | None:
"""Return the shell line that resumes ``session_id``, or ``None``.

``None`` means the agent declares no resume form (see
:attr:`AgentSpec.resume_args`) or the session id is empty, in which case
callers fall back to a plain launch.
"""
if not self.spec.resume_args or not session_id:
return None
resolved_command = (command if command is not None else self.spec.command).strip()
if not resolved_command:
return None
resolved_args = (args if args is not None else self.spec.args).strip()
tail = [
session_id if token == SESSION_PLACEHOLDER else token for token in self.spec.resume_args
]
return " ".join(part for part in [resolved_command, resolved_args, *tail] if part)

def build_prompt_command(
self, command: str | None, args: str | None, prompt: str
) -> list[str] | None:
Expand Down
8 changes: 7 additions & 1 deletion app/modules/agents/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ def __init__(self, spec: AgentSpec) -> None:
DEFAULT_AGENT_SPECS: tuple[AgentSpec, ...] = (
AgentSpec(id="claude", label="Claude Code", command="claude", prompt_args=("-p", "{prompt}")),
AgentSpec(id="agy", label="Antigravity", command="agy"),
AgentSpec(id="opencode", label="OpenCode", command="opencode", prompt_args=("run", "{prompt}")),
AgentSpec(
id="opencode",
label="OpenCode",
command="opencode",
prompt_args=("run", "{prompt}"),
resume_args=("--session", "{session}"),
),
AgentSpec(id="codex", label="Codex", command="codex", prompt_args=("exec", "{prompt}")),
AgentSpec(id="gemini", label="Gemini CLI", command="gemini", prompt_args=("-p", "{prompt}")),
AgentSpec(id="aider", label="Aider", command="aider"),
Expand Down
33 changes: 33 additions & 0 deletions app/modules/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ def agent_exists(agent_id: str) -> bool:
return registry.get(agent_id) is not None


def agent_label(agent_id: str) -> str | None:
"""The catalog label for a registered agent, or ``None`` if unknown.

Unlike :func:`list_agents` this does not touch the database or probe the
host, so it is cheap enough for per-request display only.
"""
agent = registry.get(agent_id)
return agent.spec.label if agent is not None else None


def effective_config(db: Session, user: User, agent: Agent) -> tuple[str, str]:
"""Effective (command, args) for an agent, applying the user's override."""
override = db.scalar(
Expand All @@ -144,6 +154,29 @@ def resolve_launch(db: Session, user: User, agent_id: str) -> str | None:
return agent.build_launch(*effective_config(db, user, agent))


# Session ids are supplied by the client and typed verbatim into the shell, so
# only the conservative character set CLI agents use is accepted.
_SESSION_ID_MAX = 128
_SESSION_ID_RE = re.compile(rf"^[A-Za-z0-9._:-]{{1,{_SESSION_ID_MAX}}}$")


def valid_session_id(session_id: str) -> bool:
"""Whether ``session_id`` is safe to place on a shell launch line."""
return _SESSION_ID_RE.fullmatch(session_id) is not None


def resolve_resume_launch(db: Session, user: User, agent_id: str, session_id: str) -> str | None:
"""Build the line that resumes ``session_id`` for an agent, or ``None``.

Returns ``None`` for an unknown agent, an agent without a resume form, or an
unsafe session id, so callers simply skip launching.
"""
agent = registry.get(agent_id)
if agent is None or not valid_session_id(session_id):
return None
return agent.build_resume_launch(*effective_config(db, user, agent), session_id)


def get_default_agent_id(db: Session, user: User) -> str | None:
"""The agent the user selected as default, or ``None`` when unset."""
return user_settings.get_value(db, user.id, DEFAULT_AGENT_KEY)
Expand Down
129 changes: 129 additions & 0 deletions app/modules/agents/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

import os
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol

from app.core.config import Settings

DEFAULT_SESSION_LIMIT = 100
MAX_SESSION_LIMIT = 500
MAX_TITLE_LENGTH = 200
UNTITLED_SESSION = "Untitled session"


@dataclass(frozen=True, slots=True)
class AgentSession:
"""A single resumable session recorded by a CLI agent on the host."""

agent_id: str
id: str
title: str
directory: str
updated_at: datetime


class AgentSessionSource(Protocol):
"""Reads the sessions one agent has recorded for a project directory."""

agent_id: str

def list_sessions(self, directory: Path, limit: int) -> list[AgentSession]: ...


class OpencodeSessionSource:
"""Reads OpenCode sessions from its local SQLite database, read-only.

OpenCode records each session's launch directory, so sessions are matched
to a project/worktree by directory rather than by transcript files.
"""

agent_id = "opencode"

def __init__(self, data_dir: Path) -> None:
self._data_dir = data_dir

@property
def database_path(self) -> Path:
return self._data_dir / "opencode.db"

def list_sessions(self, directory: Path, limit: int) -> list[AgentSession]:
db_path = self.database_path
if limit <= 0 or not db_path.is_file():
return []
try:
connection = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True)
except (sqlite3.Error, ValueError):
return []
try:
connection.row_factory = sqlite3.Row
rows = connection.execute(
"SELECT id, title, directory, time_updated FROM session "
"WHERE rtrim(directory, '/') = ? AND time_archived IS NULL "
"ORDER BY time_updated DESC LIMIT ?",
(_normalize_directory(directory), limit),
).fetchall()
except sqlite3.Error:
return []
finally:
connection.close()
return [_row_to_session(self.agent_id, row) for row in rows]


def opencode_data_dir(settings: Settings) -> Path:
"""Resolve OpenCode's data directory as an absolute path."""
configured = settings.opencode_data_dir.strip()
if configured:
return Path(configured).expanduser().resolve()
xdg = os.environ.get("XDG_DATA_HOME")
base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share"
return (base / "opencode").resolve()


def build_session_sources(settings: Settings) -> list[AgentSessionSource]:
"""The session sources the server can read, keyed by agent id.

Only agents with a known, stable on-disk format are listed; adding another
agent means appending its source here.
"""
return [OpencodeSessionSource(opencode_data_dir(settings))]


def list_sessions(
settings: Settings, directory: Path, limit: int = DEFAULT_SESSION_LIMIT
) -> list[AgentSession]:
"""Every recorded session whose directory matches ``directory``, newest first."""
bounded = max(1, min(limit, MAX_SESSION_LIMIT))
sessions: list[AgentSession] = []
for source in build_session_sources(settings):
sessions.extend(source.list_sessions(directory, bounded))
sessions.sort(key=lambda session: session.updated_at, reverse=True)
return sessions[:bounded]


def _normalize_directory(directory: Path) -> str:
text = str(directory)
if len(text) > 1 and text.endswith("/"):
return text[:-1]
return text


def _row_to_session(agent_id: str, row: sqlite3.Row) -> AgentSession:
title = str(row["title"] or "").strip() or UNTITLED_SESSION
return AgentSession(
agent_id=agent_id,
id=str(row["id"]),
title=title[:MAX_TITLE_LENGTH],
directory=str(row["directory"] or ""),
updated_at=_from_millis(row["time_updated"]),
)


def _from_millis(value: object) -> datetime:
try:
return datetime.fromtimestamp(int(value) / 1000, tz=UTC)
except (TypeError, ValueError, OSError, OverflowError):
return datetime.now(UTC)
Loading
Loading