diff --git a/src/agent_term/agent_machine_adapter.py b/src/agent_term/agent_machine_adapter.py new file mode 100644 index 0000000..2ba9b59 --- /dev/null +++ b/src/agent_term/agent_machine_adapter.py @@ -0,0 +1,229 @@ +"""Agent Machine workspace adapter for AgentTerm. + +Exposes operator-facing ChatOps commands for SourceOS Agent Machine workspaces. +Preserves Matrix-first event log semantics — all actions produce governed events. +Agent Registry resolution happens before any dispatch. Podman logic is deferred +to AgentPlane. Implements issue agent-term#18. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from agent_term.events import AgentTermEvent + + +def _now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +_GOVERNED_PARTICIPANT_KINDS = frozenset( + ["codex", "claude-code", "hermes", "openclaw", "local-shell", "github-bot"] +) + + +@dataclass(frozen=True) +class AgentMachineProfile: + """A resolved Agent Machine workspace profile.""" + + profile_ref: str + repo_ref: str + agent_kind: str + registry_entry_ref: str + policy_profile_ref: str | None = None + + +@dataclass(frozen=True) +class WorkspaceAttachment: + """An active Agent Machine workspace attachment.""" + + workspace_ref: str + profile_ref: str + agent_ref: str + attached_at: str + agentplane_ref: str | None = None + policy_decision_ref: str | None = None + + +@dataclass +class AgentMachineEventTrace: + """Lifecycle event trace for an Agent Machine workspace session.""" + + workspace_ref: str + events: list[dict[str, Any]] = field(default_factory=list) + + def append(self, kind: str, **kwargs: Any) -> None: + self.events.append({"kind": kind, "observedAt": _now(), **kwargs}) + + +def _make_agent_machine_event( + kind: str, + channel: str, + sender: str, + body: str, + metadata: dict[str, Any], + requires_approval: bool = False, +) -> AgentTermEvent: + return AgentTermEvent( + channel=channel, + sender=sender, + body=body, + kind=kind, + source="agent-machine", + requires_approval=requires_approval, + metadata=metadata, + ) + + +def cmd_doctor(channel: str, sender: str) -> AgentTermEvent: + """Record a doctor event for the Agent Machine workspace surface.""" + return _make_agent_machine_event( + kind="agent_machine_doctor", + channel=channel, + sender=sender, + body="Agent Machine doctor: check workspace health and registry resolution.", + metadata={ + "agentMachineCommand": "doctor", + "policyDecisionRequired": False, + "note": "Stub — AgentPlane integration pending per agent-term#18.", + }, + ) + + +def cmd_init(channel: str, sender: str, profile: str, repo: str) -> AgentTermEvent: + """Record an init event for a new Agent Machine workspace.""" + return _make_agent_machine_event( + kind="agent_machine_init", + channel=channel, + sender=sender, + body=f"Agent Machine init: profile={profile} repo={repo}", + metadata={ + "agentMachineCommand": "init", + "profile": profile, + "repo": repo, + "policyDecisionRequired": True, + "note": "Stub — workspace engine (Podman) deferred to AgentPlane per agent-term#18.", + }, + requires_approval=True, + ) + + +def cmd_attach(channel: str, sender: str, workspace_ref: str) -> AgentTermEvent: + """Record a workspace attach event. Logs direct operator attach.""" + return _make_agent_machine_event( + kind="agent_machine_attach", + channel=channel, + sender=sender, + body=f"Agent Machine attach: workspace={workspace_ref}", + metadata={ + "agentMachineCommand": "attach", + "workspaceRef": workspace_ref, + "attachKind": "direct-operator", + "policyDecisionRequired": True, + "note": "Direct operator attach logged. Governed execution hands off to AgentPlane.", + }, + requires_approval=True, + ) + + +def cmd_run(channel: str, sender: str, tool: str, workspace_ref: str) -> AgentTermEvent: + """Record a governed tool run request for an Agent Machine workspace.""" + return _make_agent_machine_event( + kind="agent_machine_run", + channel=channel, + sender=sender, + body=f"Agent Machine run: tool={tool} workspace={workspace_ref}", + metadata={ + "agentMachineCommand": "run", + "tool": tool, + "workspaceRef": workspace_ref, + "policyDecisionRequired": True, + "agentplaneHandoff": True, + "note": "Execution gated on policy decision; handoff to AgentPlane for governed run.", + }, + requires_approval=True, + ) + + +def cmd_fingerprint(channel: str, sender: str, workspace_ref: str) -> AgentTermEvent: + """Record a workspace fingerprint/identity verification event.""" + return _make_agent_machine_event( + kind="agent_machine_fingerprint", + channel=channel, + sender=sender, + body=f"Agent Machine fingerprint: workspace={workspace_ref}", + metadata={ + "agentMachineCommand": "fingerprint", + "workspaceRef": workspace_ref, + "policyDecisionRequired": False, + "note": "Identity verification — non-mutating, no approval required.", + }, + ) + + +def cmd_evidence(channel: str, sender: str, workspace_ref: str) -> AgentTermEvent: + """Record an evidence retrieval request for a workspace session.""" + return _make_agent_machine_event( + kind="agent_machine_evidence", + channel=channel, + sender=sender, + body=f"Agent Machine evidence: workspace={workspace_ref}", + metadata={ + "agentMachineCommand": "evidence", + "workspaceRef": workspace_ref, + "policyDecisionRequired": True, + "note": "Evidence retrieval gated on policy; AgentPlane is the evidence authority.", + }, + requires_approval=True, + ) + + +def resolve_participant(sender: str, agent_kind: str | None = None) -> dict[str, Any]: + """Resolve a participant through Agent Registry before dispatch. + + Non-human participants (Codex, Claude Code, OpenCLAW, etc.) must be + registered before being treated as governed actors. + """ + kind = agent_kind or "unknown" + is_governed = kind in _GOVERNED_PARTICIPANT_KINDS + return { + "senderRef": sender, + "agentKind": kind, + "governed": is_governed, + "registryResolutionNote": ( + "Resolved via Agent Registry stub. Live registry check pending per agent-term#18." + ), + } + + +EXAMPLE_LIFECYCLE_TRACE = [ + { + "kind": "agent_machine_init", + "profile": "claude-code-devtools", + "repo": "urn:srcos:repo:sourceos-devtools", + "policyDecisionRef": "urn:srcos:policy-decision:am-init-admit-20260718", + "observedAt": "2026-07-18T10:00:00Z", + }, + { + "kind": "agent_machine_attach", + "workspaceRef": "urn:srcos:workspace:claude-code-devtools-20260718", + "attachKind": "agentplane-governed", + "agentplaneRef": "urn:srcos:agentplane:session:20260718T100001Z", + "observedAt": "2026-07-18T10:00:01Z", + }, + { + "kind": "agent_machine_run", + "tool": "sourceos-agent", + "workspaceRef": "urn:srcos:workspace:claude-code-devtools-20260718", + "executionStart": "2026-07-18T10:00:02Z", + "observedAt": "2026-07-18T10:00:02Z", + }, + { + "kind": "agent_machine_evidence", + "workspaceRef": "urn:srcos:workspace:claude-code-devtools-20260718", + "evidenceRef": "urn:srcos:evidence:am-run-receipt-20260718", + "observedAt": "2026-07-18T10:05:00Z", + }, +] diff --git a/src/agent_term/cli.py b/src/agent_term/cli.py index ecc267e..1a08f96 100644 --- a/src/agent_term/cli.py +++ b/src/agent_term/cli.py @@ -10,6 +10,15 @@ from typing import Any from agent_term.events import AgentTermEvent +from agent_term.agent_machine_adapter import ( + cmd_attach as am_cmd_attach, + cmd_doctor as am_cmd_doctor, + cmd_evidence as am_cmd_evidence, + cmd_fingerprint as am_cmd_fingerprint, + cmd_init as am_cmd_init, + cmd_run as am_cmd_run, +) +from agent_term.tui import _TEXTUAL_AVAILABLE, run_tui from agent_term.graph_binding import ( format_agent_list, format_graph_snapshot, @@ -139,6 +148,12 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("shell", help="Open the minimal AgentTerm interactive shell.") + tui = subparsers.add_parser( + "tui", + help="Open the Textual TUI operator console (requires agent-term[textual]).", + ) + tui.add_argument("--db", dest="tui_db", default=None, help="Override DB path for TUI.") + # --- state-integrity (#36) --- si = subparsers.add_parser( "state-integrity", @@ -440,6 +455,12 @@ def cmd_shell(store: EventStore) -> int: print("/sherlock ") print("/meshrush ") print("/request-shell [profile]") + print("/agent-machine doctor") + print("/agent-machine init ") + print("/agent-machine attach ") + print("/agent-machine run ") + print("/agent-machine fingerprint ") + print("/agent-machine evidence ") print("/quit") print("Anything else is posted as a message event.") continue @@ -562,6 +583,25 @@ def cmd_shell(store: EventStore) -> int: append_and_print(store, event) continue + if line.startswith("/agent-machine "): + parts = shlex.split(line) + subcmd = parts[1] if len(parts) > 1 else "doctor" + if subcmd == "doctor": + append_and_print(store, am_cmd_doctor(channel, sender)) + elif subcmd == "init" and len(parts) >= 4: + append_and_print(store, am_cmd_init(channel, sender, parts[2], parts[3])) + elif subcmd == "attach" and len(parts) >= 3: + append_and_print(store, am_cmd_attach(channel, sender, parts[2])) + elif subcmd == "run" and len(parts) >= 4: + append_and_print(store, am_cmd_run(channel, sender, parts[2], parts[3])) + elif subcmd == "fingerprint" and len(parts) >= 3: + append_and_print(store, am_cmd_fingerprint(channel, sender, parts[2])) + elif subcmd == "evidence" and len(parts) >= 3: + append_and_print(store, am_cmd_evidence(channel, sender, parts[2])) + else: + print("Usage: /agent-machine doctor|init |attach |run |fingerprint |evidence ") + continue + event = AgentTermEvent(channel=channel, sender=sender, kind="message", source="local", body=line) append_and_print(store, event) @@ -658,6 +698,17 @@ def main(argv: list[str] | None = None) -> int: return cmd_office(store, args) if args.command == "shell": return cmd_shell(store) + if args.command == "tui": + tui_db = Path(args.tui_db) if getattr(args, "tui_db", None) else db_path + if not _TEXTUAL_AVAILABLE: + print( + "ERROR: Textual is not installed. Install with:\n" + " pip install agent-term[textual]", + file=sys.stderr, + ) + return 1 + run_tui(db_path=tui_db) + return 0 if args.command == "state-integrity": return cmd_state_integrity(args) if args.command == "graph": diff --git a/src/agent_term/tui.py b/src/agent_term/tui.py new file mode 100644 index 0000000..c81744f --- /dev/null +++ b/src/agent_term/tui.py @@ -0,0 +1,201 @@ +"""Textual TUI entry point for AgentTerm. + +Requires the optional `textual` dependency: + pip install agent-term[textual] + +Shows Matrix rooms, selected thread events, local event log, agent status, +Policy Fabric approval queue, and a governed command input line. +Side-effecting commands require explicit approval before dispatch. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_term.store import DEFAULT_DB_PATH, EventStore +from agent_term.tui_model import PANE_ORDER, TuiSnapshotBuilder, title_for_pane + +try: + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import Horizontal, Vertical + from textual.reactive import reactive + from textual.widgets import Footer, Header, Label, ListItem, ListView, Log, Static + + _TEXTUAL_AVAILABLE = True +except ImportError: + _TEXTUAL_AVAILABLE = False + if TYPE_CHECKING: + from textual.app import App, ComposeResult + + +_APPROVAL_REQUIRED_PREFIXES = ( + "/memory ", + "/workroom ", + "/meshrush ", + "/sherlock ", + "/agent ", +) + + +def _requires_approval(line: str) -> bool: + return any(line.startswith(p) for p in _APPROVAL_REQUIRED_PREFIXES) + + +if _TEXTUAL_AVAILABLE: + from textual.widgets import Input + + class AgentTermApp(App): # type: ignore[misc] + """Matrix-first terminal ChatOps console.""" + + TITLE = "AgentTerm" + SUB_TITLE = "SourceOS operator console" + CSS = """ + Screen { + layout: horizontal; + } + #sidebar { + width: 24; + border: solid $primary; + overflow-y: auto; + } + #main { + layout: vertical; + } + #event-log { + border: solid $accent; + height: 1fr; + } + #approval-bar { + height: 3; + border: solid $warning; + display: none; + } + #approval-bar.visible { + display: block; + } + #cmd-input { + height: 3; + border: solid $surface; + } + .pane-header { + background: $primary; + color: $text; + padding: 0 1; + } + .approval-warning { + color: $warning; + padding: 0 1; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("ctrl+c", "quit", "Quit"), + ] + + pending_command: reactive[str] = reactive("") + + def __init__(self, db_path: Path = DEFAULT_DB_PATH) -> None: + super().__init__() + self._db_path = db_path + self._store = EventStore(db_path) + self._builder = TuiSnapshotBuilder() + self._snapshot = self._builder.build(self._store.iter_recent(limit=200)) + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(): + with Vertical(id="sidebar"): + yield Static("Panes", classes="pane-header") + items = [ListItem(Label(title_for_pane(p))) for p in PANE_ORDER] + yield ListView(*items, id="pane-list") + with Vertical(id="main"): + yield Log(id="event-log", highlight=True, markup=True) + yield Static("", id="approval-bar", classes="approval-warning") + yield Input( + placeholder="/ command or message…", + id="cmd-input", + ) + yield Footer() + + def on_mount(self) -> None: + self._refresh_log() + + def _refresh_log(self) -> None: + log = self.query_one("#event-log", Log) + log.clear() + text = self._snapshot.render_text() + for line in text.splitlines(): + log.write_line(line) + + def on_input_submitted(self, event: Input.Submitted) -> None: + line = event.value.strip() + if not line: + return + approval_bar = self.query_one("#approval-bar", Static) + if _requires_approval(line): + approval_bar.add_class("visible") + approval_bar.update( + f"[bold yellow]Approval required before dispatch:[/bold yellow] {line!r} " + "— Confirm with /approve or /deny" + ) + self.pending_command = line + event.input.clear() + return + if line in ("/approve", "/y") and self.pending_command: + self._dispatch_approved(self.pending_command) + self.pending_command = "" + approval_bar.remove_class("visible") + approval_bar.update("") + elif line in ("/deny", "/n"): + self.pending_command = "" + approval_bar.remove_class("visible") + approval_bar.update("") + else: + self._dispatch_line(line) + event.input.clear() + + def _dispatch_line(self, line: str) -> None: + log = self.query_one("#event-log", Log) + log.write_line(f"[dim]> {line}[/dim]") + + def _dispatch_approved(self, line: str) -> None: + log = self.query_one("#event-log", Log) + log.write_line(f"[green]✓ approved:[/green] {line}") + + def on_list_view_selected(self, event: ListView.Selected) -> None: + idx = event.list_view.index + if idx is None or idx >= len(PANE_ORDER): + return + pane_name = PANE_ORDER[idx] + log = self.query_one("#event-log", Log) + pane = self._snapshot.pane(pane_name) + log.clear() + log.write_line(f"[bold]{title_for_pane(pane_name)}[/bold]") + for item in pane.items: + status_marker = { + "approved": "[green]✓[/green]", + "denied": "[red]✗[/red]", + "pending": "[yellow]⏳[/yellow]", + "revoked": "[red]⊘[/red]", + "expired": "[dim]⌛[/dim]", + }.get(item.status, "") + log.write_line(f" {status_marker} {item.text}") + + def on_unmount(self) -> None: + self._store.close() + + +def run_tui(db_path: Path = DEFAULT_DB_PATH) -> None: + """Launch the AgentTerm Textual TUI.""" + if not _TEXTUAL_AVAILABLE: + raise ImportError( + "Textual is required for the TUI. Install it with:\n" + " pip install agent-term[textual]\n" + "or:\n" + " pip install textual" + ) + app = AgentTermApp(db_path=db_path) + app.run()