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
6 changes: 3 additions & 3 deletions src/toad/about.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import platform
from string import Template

from agent_comms.launch_root import LaunchRoot
from pathlib import Path
from toad.preferences import SavedSettings
from toad.terminal_attention import TerminalAttention
from toad import paths
Expand Down Expand Up @@ -78,7 +78,7 @@
)


def render() -> str:
def render(root: Path) -> str:
"""Render about markdown.

Returns:
Expand All @@ -95,7 +95,7 @@ def render() -> str:
"COLORTERM": os.environ.get("COLORTERM", ""),
"COMMS_AGENT_ARGS": shlex.join(InheritedArguments().configured().argv),
"COMMS_AGENT_BIN": ConfiguredBinary().configured(),
"COMMS_ROOT": str(LaunchRoot.current().path),
"COMMS_ROOT": str(root),
"CONFIG": config,
"DATA_PATH": paths.get_data(),
"LOG_PATH": paths.get_log(),
Expand Down
2 changes: 1 addition & 1 deletion src/toad/acp/agent_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def start(self, agent, processes: tuple[AgentProcess, ...]=()):
agent.presentation.log_path.parent.mkdir, parents=True, exist_ok=True)
env = os.environ.copy()
cwd = str(agent.project_root_path.resolve())
selection = RouteSelection.capture(agent.controller.transcripts.access.launch_root.path)
selection = agent.controller.transcripts.access.selection
if not agent.session.accepts_updates:
return self
for connection in dict.fromkeys((self, *processes)):
Expand Down
3 changes: 1 addition & 2 deletions src/toad/acp/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from agent_comms.errors import RouteChangedError
from agent_comms.input_attempt import NotSentInput
from agent_comms.owner_protocol import IncompatibleOwner
from agent_comms.route_selection import RouteSelection
from agent_comms.session_load import (
EnsuringSessionLoadPlan,
ExistingSessionLoadPlan,
Expand Down Expand Up @@ -362,7 +361,7 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None:
try:
try:
await asyncio.to_thread(
RouteSelection.capture, self.agent.controller.transcripts.access.launch_root.path)
lambda: self.agent.controller.transcripts.access.selection)
except RouteChangedError as error:
self.startup_failed(f"Reconnect blocked before restart: {error}")
return
Expand Down
6 changes: 1 addition & 5 deletions src/toad/acp/comms_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from agent_comms.acp_extension import (
Expand All @@ -29,7 +28,6 @@
TurnChangedUpdate,
)
from agent_comms.context_reading import UnmeasuredContext
from agent_comms.errors import RouteChangedError
from agent_comms.native_output import AssistantOutput, ToolOutput
from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation
from agent_comms.transcripts import NativeCommittedPrefix
Expand Down Expand Up @@ -186,9 +184,7 @@ def _post_unidentified(self, update: InputStartedUpdate) -> None:
@handles(CoordinationChangedUpdate)
def coordination_changed(self, update: CoordinationChangedUpdate) -> None:
agent = self.agent
root = agent.controller.transcripts.access.launch_root.path
if Path(update.wire_root).resolve() != root.resolve():
raise RouteChangedError("ACP owner reported a different coordination root")
agent.controller.transcripts.access.selected(update.wire_root)
agent.coordination = update
agent.project_root_path = update.worktree.path
agent.context_measurement = ContextMeasurement.saved(update.context_usage)
Expand Down
3 changes: 1 addition & 2 deletions src/toad/acp/transcript_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from functools import partial

from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate
from agent_comms.route_selection import RouteSelection
from agent_comms.thread_presentation import ThreadPresentation
from agent_comms.ui_model.observation import (
AwaitedRead,
Expand Down Expand Up @@ -44,7 +43,7 @@ async def read(self, request: AwaitedRead):

async def service(self, root):
return await self.access.preparation.run_thread(
lambda: self.access.require(RouteSelection.capture(root)).service
lambda: self.access.require(self.access.selected(root)).service
)

async def thread_presentation(
Expand Down
5 changes: 4 additions & 1 deletion src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import TYPE_CHECKING, Callable, Never, TypeVar, cast

from agent_comms.acp_extension import TranscriptChangedUpdate
from agent_comms.launch_root import LaunchRoot
from agent_comms.repo import Checkout
from toad.core import input_events
from rich.console import RenderableType
Expand Down Expand Up @@ -150,6 +151,7 @@ def __init__(
self,
agent_binding: AgentBinding,
project_dir: Checkout,
launch_root: LaunchRoot,
mode: str | None = None,
agent_session_id: str | None = None,
) -> None:
Expand All @@ -158,6 +160,7 @@ def __init__(
Args:
agent_binding: The agent selected at startup.
project_dir: Project directory.
launch_root: Coordination root selected by the launch owner.
mode: Initial mode.
"""
from toad.render_processes import RenderProcessPool
Expand Down Expand Up @@ -189,7 +192,7 @@ def __init__(
db=DB(),
)
self._session_tracker = SessionTracker(self.tab_order)
self.coordination_access = CoordinationAccess(self)
self.coordination_access = CoordinationAccess(self, launch_root)

from toad.file_kind import FileKind
FileKind.prepare_terminal()
Expand Down
18 changes: 12 additions & 6 deletions src/toad/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import click

from agent_comms.field_codec import FieldCodec
from agent_comms.launch_root import LaunchRoot
from agent_comms.repo import Checkout
from toad.session_scope import AgentAttached, AgentBinding, NoAgent

Expand Down Expand Up @@ -75,10 +76,9 @@ def parse_args(self, ctx, args):
if "--version" in args or "-v" in args:
return super().parse_args(ctx, args)
from agent_comms.active_route import ActiveRoute
from agent_comms.launch_root import LaunchRoot

# One selection, as in every Comms process: --comms-root if given, else the published route.
LaunchRoot.startup(args, ActiveRoute.published_launch)
# Select once at the process boundary; apps receive this captured value.
ctx.obj = LaunchRoot.startup(args, ActiveRoute.published_launch)
# Check if first arg is a known subcommand
if not args or args[0] not in self.commands:
# If not a subcommand, prepend the default command name
Expand Down Expand Up @@ -123,7 +123,8 @@ def main(ctx, version):
@main.command("run")
@click.argument("project_dir", metavar="PATH", required=True)
@click.option("-a", "--agent", metavar="AGENT", default="")
def run(project_dir: str, agent: str = "1"):
@click.pass_obj
def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"):
"""Run an installed agent."""

project = FieldCodec.decode(Checkout, project_dir).resolved(strict=True)
Expand All @@ -137,6 +138,7 @@ def run(project_dir: str, agent: str = "1"):
mode=binding.startup_mode,
agent_binding=binding,
project_dir=project,
launch_root=launch_root,
)
run_terminal(app)

Expand All @@ -158,7 +160,9 @@ def run(project_dir: str, agent: str = "1"):
default=None,
)
@click.option("-d", "--project-dir", "project_option", metavar="PATH", default=None)
@click.pass_obj
def acp(
launch_root: LaunchRoot,
command: str,
title: str | None,
project_dir: str | None,
Expand Down Expand Up @@ -195,6 +199,7 @@ def acp(
app = ToadApp(
agent_binding=AgentAttached(agent_data),
project_dir=FieldCodec.decode(Checkout, project_dir).resolved(strict=True),
launch_root=launch_root,
agent_session_id=session_id,
)
run_terminal(app)
Expand Down Expand Up @@ -269,12 +274,13 @@ def serve(port: int, host: str, toad_args: tuple[str, ...]) -> None:


@main.command("about")
def about() -> None:
@click.pass_obj
def about(launch_root: LaunchRoot) -> None:
"""Show about information."""

from toad import about

print(about.render())
print(about.render(launch_root.path))


if __name__ == "__main__":
Expand Down
26 changes: 19 additions & 7 deletions src/toad/comms_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from agent_comms.comms import Comms
from agent_comms.display_references import PendingReferences, UnavailableReferences
from agent_comms.errors import RouteChangedError
from agent_comms.launch_root import LaunchRoot
from agent_comms.launch_runtime import LaunchRuntime
from metaclass_registry.mro_dispatch import MroDispatch, handles
from agent_comms.presentation import MessageNotification, NoRevision, ReadState
Expand Down Expand Up @@ -329,11 +330,9 @@ def observe_commander(self, wanted: ObserveCommanderHud | StopCommanderHud):
class CoordinationAccess(MroDispatch):
"""A validated Core route owns cached access, observations and UI write checks."""

def __init__(self, app: ToadApp) -> None:
from agent_comms.launch_root import LaunchRoot

def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None:
self.app = app
self.launch_root = LaunchRoot.current()
self.launch_root = launch_root
from toad.acp.transcript_reader import CoordinationTranscriptReader

self.transcripts = CoordinationTranscriptReader(self)
Expand Down Expand Up @@ -361,18 +360,31 @@ def __init__(self, app: ToadApp) -> None:
self.commander_pages: WeakSet[CommanderPage] = WeakSet()
self.commander_interest: ObserveCommanderHud | StopCommanderHud = StopCommanderHud()

@property
def selection(self) -> RouteSelection:
"""The current route resolved from this app's held launch root."""
return RouteSelection.of(self.launch_root)

def selected(self, source: str | Path) -> RouteSelection:
"""Select this app's route and refuse a foreign source."""
return self.selection.require_source(source)

def current(self, source: str | Path) -> bool:
"""Whether a source still belongs to this app's current route."""
return self.selection.root_is_current(source)

def open_current(self) -> Observing | FailedObservation:
"""Capture the current route and open its service, or expose the opening error."""
with self.opening:
selected: RouteSelection | UnselectedRoute = UnselectedRoute()
try:
selected = RouteSelection.capture(self.launch_root.path)
selected = self.selection
return self.require(selected)
except (OSError, ValueError, RuntimeError) as error:
return self.observation.failed(error, selected)

def confirm(self, selected: RouteSelection) -> None:
if RouteSelection.capture() != selected:
if self.selection != selected:
raise RouteChangedError("Comms route changed before the operation")

def require(self, selected: RouteSelection) -> Observing:
Expand Down Expand Up @@ -649,5 +661,5 @@ async def observe_route(self) -> None:
def write(self, selected: RouteSelection, operation: Callable[..., T], *args: object, **kwargs: object) -> T:
# This method runs in the same worker as the actual sink. No route lock: the
# store refuses a write an install holds, inside its own write transaction.
RouteSelection.capture(selected.root)
self.confirm(selected)
return operation(*args, **kwargs)
5 changes: 3 additions & 2 deletions src/toad/delivery_failure_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ async def refresh_composer(self, read_only: bool) -> None:
async def submit_comms_message(self, root, conversation, body, current, present):
"""CLI transcripts and wire pages use the same Core-send checks."""
from agent_comms.delivery_failure import delivery_failure
from agent_comms.route_selection import root_is_current

if not body.strip():
return
Expand All @@ -135,7 +134,9 @@ async def submit_comms_message(self, root, conversation, body, current, present)
self.feedback.flash(self.status, style="error")
return
self._enter_send_state(SendPending())
route_current = await self.app.preparation.run_thread(root_is_current, root)
route_current = await self.app.preparation.run_thread(
lambda: self.app.coordination_access.current(root)
)
if not route_current or not current():
raise HumanSendRefusedError(RelationViolationError("Message route changed; reopen this view"))
sent = await self.commit_user_message(comms, conversation.send_target(), body)
Expand Down
3 changes: 1 addition & 2 deletions src/toad/navigation_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ def key(self) -> tuple[str, str, str]:
return self.owner_mode, self.request.root, self.request.target

async def run(self) -> str:
from agent_comms.route_selection import root_is_current

navigator = self.navigator
try:
Expand All @@ -224,7 +223,7 @@ async def run(self) -> str:
):
return navigator.app.selected_mode
if not await navigator.app.preparation.run_thread(
root_is_current, self.request.root
lambda: navigator.app.coordination_access.current(self.request.root)
):
raise RuntimeError("The thread's bus route changed while opening")
return await prepared.open(self)
Expand Down
3 changes: 1 addition & 2 deletions src/toad/screens/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from agent_comms.coordination_errors import DMScopeChanged, Refused
from agent_comms.declared_family import DeclaredFamily
from metaclass_registry.mro_dispatch import handles
from agent_comms.route_selection import RouteSelection
from agent_comms.repo import Checkout
from textual import containers, getters, on
from textual.app import ComposeResult
Expand Down Expand Up @@ -238,7 +237,7 @@ def read_visible(self, screen: MainScreen) -> PaintedRead:
worktree = app.project_dir

def acknowledge(cursor) -> None:
selected = RouteSelection.capture(source_root)
selected = access.selected(source_root)
comms = access.require(selected).service

def mark() -> None:
Expand Down
14 changes: 7 additions & 7 deletions src/toad/session_navigation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Opening and closing sessions use the existing workspace's declared factories."""

from __future__ import annotations
Expand All @@ -18,7 +18,6 @@
)
from agent_comms.presentation import NoRevision, ReadState
from agent_comms.revision_views import RegistrationsView
from agent_comms.route_selection import root_is_current
from agent_comms.thread_identity import ThreadIncarnation

from toad.core import events as core_events
Expand Down Expand Up @@ -402,7 +401,9 @@
return app.selected_mode
if not origin.current(app.thread_navigation, owner_mode):
return app.selected_mode
if not await app.preparation.run_thread(root_is_current, requested_root):
if not await app.preparation.run_thread(
lambda: app.coordination_access.current(requested_root)
):
return app.selected_mode
if not origin.current(app.thread_navigation, owner_mode):
return app.selected_mode
Expand Down Expand Up @@ -544,7 +545,6 @@
await self.retire_missing()

async def retire_missing(self) -> None:
from agent_comms.route_selection import RouteSelection

# Borrow each open entry, including hidden views. Core decides
# which of their sources no longer exist; Toad only closes the views.
Expand All @@ -569,11 +569,11 @@
answers = {}
for root, (threads, channels) in asked.items():
try:
selected = RouteSelection.capture(root)
service = self.app.coordination_access.require(selected).service
access = self.app.coordination_access
selected = access.selected(root)
service = access.require(selected).service
retired = service.views.retired_views(threads, channels)
if RouteSelection.capture(root) != selected:
continue # Replaced while reading; the next observation reads it.
access.confirm(selected)
except RouteChangedError:
continue # The next observation reads the replacement route.
answers[root] = service, retired
Expand Down
5 changes: 2 additions & 3 deletions src/toad/session_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from agent_comms.repo import Checkout
from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision
from agent_comms.errors import RelationViolationError
from agent_comms.route_selection import RouteSelection
from agent_comms.thread_identity import ThreadIncarnation
from agent_comms.ui_model.observation import (
ReadTranscript,
Expand Down Expand Up @@ -130,7 +129,7 @@ def original_threads(
return ((self.root, identity.thread_incarnation()),)

def matches_session(self, access: CoordinationAccess, session_id: str) -> bool:
service = access.require(RouteSelection.capture(self.root)).service
service = access.require(access.selected(self.root)).service
return (
service.registry.canonical_name(session_id)
== service.registry.require(self.name).name
Expand Down Expand Up @@ -407,7 +406,7 @@ def require(self) -> ProjectSessionOrigin:

def resolve_thread(self) -> str:
identity = self.thread_identity
selected = RouteSelection.capture(identity.wire_root())
selected = self.app.coordination_access.selected(identity.wire_root())
service = self.app.coordination_access.require(selected).service
return identity.resolve_thread(service.registry, self.project_root)

Expand Down
2 changes: 1 addition & 1 deletion src/toad/slash_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async def apply(self, conversation: Conversation) -> bool:
from toad import about
from toad.widgets.markdown_note import MarkdownNote

markdown = about.render()
markdown = about.render(conversation.app.coordination_access.selection.root)
await conversation.post(MarkdownNote(markdown, classes="about"))
conversation.app.copy_to_clipboard(markdown)
conversation.notify(
Expand Down
Loading
Loading