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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- tmux listing parse failures are retried once and reported as a distinct condition instead of surfacing as a bare `ValueError` that reads like "session not found" one layer up. libtmux 0.53.1+ zips `parse_output`'s fields with `strict=True`, so any short row (a pane or session vanishing mid-listing, or trailing fields tmux omits) raised `ValueError: zip() argument 2 is shorter than argument 1` — which propagated through `server.sessions`/`window.panes`, blocked launches outright, and left the pipe-liveness watchdog unable to tell a genuinely-gone session from a transient parse failure. Adds `TmuxLookupError` and routes the listing reads in `clients/tmux.py` through a single retry-and-classify wrapper; a failed `create_session` no longer leaves an orphaned tmux session that blocks relaunching the same name. Also caps `libtmux<0.53.1`, the last release that zips non-strict (caom-anv)
- Codex handoff extraction now skips native TUI activity cells without relying on an English verb allowlist, including when the model's reply starts with prose (#545)
- `list_sessions` ownership metadata now persists the effective canonical launch directory, stays stable after pane `cd`, and purges stale terminal rows before same-name session relaunches so reused sessions report the new directory/profile (#497)

## [2.4.1] - 2026-08-04

Expand Down
11 changes: 11 additions & 0 deletions src/cli_agent_orchestrator/clients/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class TerminalModel(Base):
tmux_window = Column(String, nullable=False) # "window-name"
provider = Column(String, nullable=False) # "kiro_cli", "claude_code"
agent_profile = Column(String) # "developer", "reviewer" (optional)
working_directory = Column(String, nullable=True) # launch-time cwd (optional)
allowed_tools = Column(String, nullable=True) # JSON-encoded list of CAO tool names
shell_command = Column(String, nullable=True) # shell process name captured before kiro launch
caller_id = Column(String, nullable=True) # terminal that created this one (callback target)
Expand Down Expand Up @@ -1015,6 +1016,10 @@ def _migrate_terminals_schema() -> None:
conn.execute('ALTER TABLE terminals ADD COLUMN "metadata" TEXT')
conn.commit()
logger.info("Migration: added metadata column to terminals table")
if "working_directory" not in columns:
conn.execute("ALTER TABLE terminals ADD COLUMN working_directory TEXT")
conn.commit()
logger.info("Migration: added working_directory column to terminals table")
conn.close()
except Exception as e:
logger.warning(f"Migration check for terminals schema failed: {e}")
Expand All @@ -1032,6 +1037,7 @@ def create_terminal(
engine: Optional[str] = None,
group: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
working_directory: Optional[str] = None,
) -> Dict[str, Any]:
"""Create terminal metadata record."""
import json as _json
Expand All @@ -1043,6 +1049,7 @@ def create_terminal(
tmux_window=tmux_window,
provider=provider,
agent_profile=agent_profile,
working_directory=working_directory,
allowed_tools=_json.dumps(allowed_tools) if allowed_tools else None,
shell_command=shell_command,
caller_id=caller_id,
Expand All @@ -1058,6 +1065,7 @@ def create_terminal(
"tmux_window": terminal.tmux_window,
"provider": terminal.provider,
"agent_profile": terminal.agent_profile,
"working_directory": terminal.working_directory,
"allowed_tools": allowed_tools,
"shell_command": terminal.shell_command,
"caller_id": terminal.caller_id,
Expand Down Expand Up @@ -1094,6 +1102,7 @@ def get_terminal_metadata(terminal_id: str) -> Optional[Dict[str, Any]]:
"tmux_window": terminal.tmux_window,
"provider": terminal.provider,
"agent_profile": terminal.agent_profile,
"working_directory": terminal.working_directory,
"allowed_tools": allowed_tools,
"shell_command": terminal.shell_command,
"caller_id": terminal.caller_id,
Expand Down Expand Up @@ -1262,6 +1271,7 @@ def list_terminals_by_session(tmux_session: str) -> List[Dict[str, Any]]:
"tmux_window": t.tmux_window,
"provider": t.provider,
"agent_profile": t.agent_profile,
"working_directory": t.working_directory,
"engine": t.engine or ("v2" if t.provider == "kiro_cli" else None),
"last_active": t.last_active,
}
Expand Down Expand Up @@ -1302,6 +1312,7 @@ def list_all_terminals() -> List[Dict[str, Any]]:
"tmux_window": t.tmux_window,
"provider": t.provider,
"agent_profile": t.agent_profile,
"working_directory": t.working_directory,
"engine": t.engine or ("v2" if t.provider == "kiro_cli" else None),
"last_active": t.last_active,
}
Expand Down
23 changes: 20 additions & 3 deletions src/cli_agent_orchestrator/ops_mcp_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from cli_agent_orchestrator.services.install_service import InstallResult

Expand Down Expand Up @@ -44,9 +44,25 @@ class SessionListResult(BaseModel):
default=None,
description="Error message when success is False",
)
sessions: List[Dict[str, Any]] = Field(
sessions: List["SessionListEntry"] = Field(
default_factory=list,
description="Active CAO sessions with terminal counts and statuses",
description="Active CAO sessions with ownership metadata and statuses",
)


class SessionListEntry(BaseModel):
"""A single active CAO session returned by list_sessions."""

model_config = ConfigDict(extra="allow")

id: Optional[str] = Field(default=None, description="Session identifier")
name: Optional[str] = Field(default=None, description="Session display name")
status: Optional[str] = Field(default=None, description="Backend session status")
working_directory: Optional[str] = Field(
default=None, description="Best-effort launch or pane working directory"
)
agent_profile: Optional[str] = Field(
default=None, description="Agent profile for the session's first known terminal"
)


Expand All @@ -62,6 +78,7 @@ class SendMessageResult(BaseModel):
"InstallResult",
"LaunchResult",
"ProfileListResult",
"SessionListEntry",
"SendMessageResult",
"SessionListResult",
]
64 changes: 62 additions & 2 deletions src/cli_agent_orchestrator/services/session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import logging
from typing import Any, Dict, List, Optional

from cli_agent_orchestrator.backends.base import TerminalBackend
from cli_agent_orchestrator.backends.registry import get_backend
from cli_agent_orchestrator.clients.database import list_terminals_by_session
from cli_agent_orchestrator.constants import SESSION_PREFIX
Expand Down Expand Up @@ -112,11 +113,70 @@ async def create_session(
return terminal


def _enrich_session_ownership(
backend: TerminalBackend, session_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Add best-effort ownership metadata from the session's first known terminal."""
enriched = dict(session_data)
enriched.setdefault("working_directory", None)
enriched.setdefault("agent_profile", None)

# `... or ""` (not `.get("id", "")`): an explicit id=None must collapse to
# "" too, matching the sibling guard in list_sessions. `.get("id", "")`
# would yield the truthy string "None" and try to enrich a bogus session.
session_name = enriched.get("id") or ""
if not session_name:
return enriched

try:
terminals = list_terminals_by_session(session_name)
except Exception as e:
Comment on lines +131 to +133
logger.warning(f"Failed to load terminal metadata for {session_name}: {e}")
terminals = []

ownership_terminal: Dict[str, Any] = {}
for terminal in terminals:
if terminal.get("agent_profile") or terminal.get("working_directory"):
ownership_terminal = terminal
break

if not ownership_terminal:
for terminal in terminals:
if terminal.get("tmux_window"):
ownership_terminal = terminal
break

if ownership_terminal:
enriched["agent_profile"] = ownership_terminal.get("agent_profile")
persisted_working_directory = ownership_terminal.get("working_directory")
if persisted_working_directory:
enriched["working_directory"] = persisted_working_directory
elif ownership_terminal.get("tmux_window"):
try:
enriched["working_directory"] = backend.get_pane_working_directory(
session_name, ownership_terminal["tmux_window"]
)
except Exception as e:
logger.warning(f"Failed to resolve working directory for {session_name}: {e}")

return enriched


def list_sessions() -> List[Dict]:
"""List all sessions from tmux."""
try:
tmux_sessions = get_backend().list_sessions()
return [s for s in tmux_sessions if s["id"].startswith(SESSION_PREFIX)]
backend = get_backend()
tmux_sessions = backend.list_sessions()
return [
_enrich_session_ownership(backend, s)
for s in tmux_sessions
# Use .get() rather than s["id"]: a backend that returns a session
# dict without an "id" key must not blank the entire list (KeyError
# in this comprehension is swallowed by the outer except and returns
# []). Shipped backends always populate "id"; this hardens against a
# future backend that does not.
if (s.get("id") or "").startswith(SESSION_PREFIX)
]
except Exception as e:
logger.error(f"Failed to list sessions: {e}")
return []
Expand Down
25 changes: 23 additions & 2 deletions src/cli_agent_orchestrator/services/terminal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from cli_agent_orchestrator.clients.database import create_terminal as db_create_terminal
from cli_agent_orchestrator.clients.database import delete_terminal as db_delete_terminal
from cli_agent_orchestrator.clients.database import (
delete_terminals_by_session,
get_terminal_metadata,
list_siblings_by_group_prefix,
update_last_active,
Expand Down Expand Up @@ -82,6 +83,7 @@
from cli_agent_orchestrator.services.status_monitor import status_monitor
from cli_agent_orchestrator.services.step_output_store import _validate_key_part
from cli_agent_orchestrator.utils.agent_profiles import load_agent_profile
from cli_agent_orchestrator.utils.path_validation import resolve_and_validate_path
from cli_agent_orchestrator.utils.skills import build_skill_catalog
from cli_agent_orchestrator.utils.terminal import (
generate_session_name,
Expand Down Expand Up @@ -169,6 +171,16 @@ class OutputMode(str, Enum):
}


def _resolve_working_directory(working_directory: Optional[str]) -> str:
"""Resolve launch cwd exactly as the tmux backend does before creation."""
return resolve_and_validate_path(
working_directory if working_directory is not None else os.getcwd(),
allow_create=False,
allow_file=False,
description="Working directory",
)


async def create_terminal(
provider: str,
agent_profile: str,
Expand Down Expand Up @@ -347,6 +359,13 @@ async def create_terminal(
worktree_service.create_worktree, worktree_repo_root, terminal_id
)

# Resolve AFTER the worktree block, not before: when `use_worktree` is set
# the block above REPLACES `working_directory` with the new worktree path,
# so resolving earlier would both launch tmux in the pre-worktree directory
# (defeating the isolation #100 provides) and persist that stale path as the
# terminal's working_directory. This is the effective launch cwd either way.
resolved_working_directory = _resolve_working_directory(working_directory)

# Step 2: Create tmux session or window
if new_session:
# Ensure session name has the CAO prefix for identification
Expand All @@ -366,10 +385,11 @@ async def create_terminal(
session_name,
window_name,
terminal_id,
working_directory,
resolved_working_directory,
extra_env=env_vars,
)
session_created = True # only set after successful creation
delete_terminals_by_session(session_name)

# Persist forwarded env only after the tmux session actually
# exists; the failure path below clears it if a later step
Expand All @@ -388,7 +408,7 @@ async def create_terminal(
session_name,
window_name,
terminal_id,
working_directory,
resolved_working_directory,
extra_env={**get_session_env(session_name), **(env_vars or {})},
)
window_created = True # only set after successful creation
Expand Down Expand Up @@ -428,6 +448,7 @@ async def create_terminal(
engine=resolved_engine.value if resolved_engine is not None else None,
group=group,
metadata=metadata,
working_directory=resolved_working_directory,
)

# Step 4/5: Set up the FIFO event-driven output pipeline for pipe-pane
Expand Down
Loading
Loading