8366 lines
358 KiB
Python
8366 lines
358 KiB
Python
"""OPC CLI — the primary user interface for the One-Person Company system."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections import deque
|
||
from importlib import resources as importlib_resources
|
||
import json
|
||
import os
|
||
import shlex
|
||
import signal
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from enum import Enum
|
||
from pathlib import Path, PureWindowsPath
|
||
from typing import Any, Optional
|
||
import re
|
||
|
||
import typer
|
||
from rich.console import Console
|
||
from rich.markup import escape
|
||
from rich.markdown import Markdown
|
||
from rich.panel import Panel
|
||
from rich.table import Table
|
||
from rich.theme import Theme
|
||
|
||
from opc import __version__
|
||
from opc.core.config import OPCConfig, get_opc_home
|
||
from opc.core.windows_ssl import (
|
||
format_windows_sslkeylog_warning,
|
||
pop_windows_sslkeylogfile,
|
||
)
|
||
from opc.database.store import OPCStore
|
||
from opc.layer2_organization.talent_market import TalentMarket
|
||
from opc.core.models import OPCEvent
|
||
|
||
# Proxy is configured via llm_config.yaml or shell environment — not hardcoded.
|
||
# os.environ['https_proxy'] = 'http://127.0.0.1:7890'
|
||
# os.environ['http_proxy'] = 'http://127.0.0.1:7890'
|
||
|
||
custom_theme = Theme({
|
||
"info": "cyan",
|
||
"warning": "yellow",
|
||
"error": "red bold",
|
||
"success": "green bold",
|
||
"agent": "blue",
|
||
"tool": "magenta",
|
||
})
|
||
console = Console(theme=custom_theme)
|
||
app = typer.Typer(
|
||
name="opc",
|
||
help="OPC — One-Person Company: Autonomous AI Agent system with natural language interface.",
|
||
no_args_is_help=True,
|
||
)
|
||
|
||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||
|
||
|
||
class _CliRepoPath:
|
||
"""Native filesystem path with stable Windows-drive display semantics."""
|
||
|
||
def __init__(self, value: str) -> None:
|
||
self._native = Path(value)
|
||
self._display = str(PureWindowsPath(value))
|
||
|
||
def __fspath__(self) -> str:
|
||
return os.fspath(self._native)
|
||
|
||
def __str__(self) -> str:
|
||
return self._display
|
||
|
||
def __getattr__(self, name: str) -> Any:
|
||
return getattr(self._native, name)
|
||
|
||
|
||
def _talent_repo_path(value: str) -> Path | _CliRepoPath:
|
||
if _WINDOWS_DRIVE_PATH_RE.match(str(value or "")):
|
||
return _CliRepoPath(value)
|
||
return Path(value)
|
||
|
||
|
||
def _current_command_label(argv: list[str] | None = None) -> str:
|
||
args = list(argv or sys.argv)
|
||
if len(args) > 1 and not args[1].startswith("-"):
|
||
return f"opc {args[1]}"
|
||
return "opc"
|
||
|
||
|
||
def _get_config() -> OPCConfig:
|
||
config_dir = get_opc_home() / "config"
|
||
if config_dir.exists():
|
||
return OPCConfig.load(config_dir)
|
||
return OPCConfig()
|
||
|
||
|
||
def _channel_runtime_pid_path() -> Path:
|
||
from opc.core.config import get_opc_home, get_project_workplace
|
||
|
||
return get_opc_home() / "run" / "channels.pid"
|
||
|
||
|
||
def _read_channel_runtime_state() -> dict | None:
|
||
path = _channel_runtime_pid_path()
|
||
if not path.exists():
|
||
return None
|
||
try:
|
||
return json.loads(path.read_text())
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _write_channel_runtime_state(enabled_channels: list[str]) -> None:
|
||
path = _channel_runtime_pid_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps({"pid": os.getpid(), "channels": enabled_channels}, ensure_ascii=False))
|
||
|
||
|
||
def _clear_channel_runtime_state() -> None:
|
||
path = _channel_runtime_pid_path()
|
||
if path.exists():
|
||
path.unlink()
|
||
|
||
|
||
def _pid_is_running(pid: int) -> bool:
|
||
try:
|
||
os.kill(pid, 0)
|
||
except OSError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _create_default_skills(opc_home: Path) -> None:
|
||
"""Copy bundled skills to the OPC home skills directory."""
|
||
import shutil
|
||
|
||
repo_skills = Path(__file__).parent.parent.parent / "skills" / "core"
|
||
target_skills = opc_home / "skills" / "core"
|
||
target_skills.mkdir(parents=True, exist_ok=True)
|
||
|
||
copied = False
|
||
if repo_skills.exists():
|
||
for skill_file in repo_skills.glob("*.md"):
|
||
dest = target_skills / skill_file.name
|
||
if not dest.exists():
|
||
shutil.copy2(skill_file, dest)
|
||
copied = True
|
||
if copied:
|
||
return
|
||
|
||
try:
|
||
resource_skills = importlib_resources.files("opc").joinpath("skills_assets", "core")
|
||
except Exception:
|
||
return
|
||
if not resource_skills.is_dir():
|
||
return
|
||
for child in resource_skills.iterdir():
|
||
if child.is_file() and child.name.endswith(".md"):
|
||
dest = target_skills / child.name
|
||
if not dest.exists():
|
||
dest.write_bytes(child.read_bytes())
|
||
|
||
|
||
def _progress_callback():
|
||
"""Create an async progress callback for streaming agent output."""
|
||
async def callback(text: str, **_: Any) -> None:
|
||
if text.startswith("[Tool:"):
|
||
console.print(f" [tool]{escape(text)}[/tool]")
|
||
elif text.startswith("[Suggestion]"):
|
||
console.print(Panel(text, title="Suggestion", border_style="yellow"))
|
||
elif text.startswith("[Delegating") or text.startswith("[Autonomy]"):
|
||
console.print(f" [info]{escape(text)}[/info]")
|
||
elif text.startswith("[External approval]"):
|
||
console.print(f" [info]{escape(text)}[/info]")
|
||
elif text.startswith("[External status]"):
|
||
if _should_show_external_status(text):
|
||
console.print(f" [info]{escape(text)}[/info]")
|
||
elif text.startswith("[External agent denied]"):
|
||
console.print(f" [warning]{escape(text)}[/warning]")
|
||
elif text.startswith("[External agent failed]") or text.startswith("[External agents exhausted]"):
|
||
console.print(f" [warning]{escape(text)}[/warning]")
|
||
elif text.startswith("[External:"):
|
||
if _cli_verbose_external_progress():
|
||
console.print(f" [warning]{escape(text)}[/warning]")
|
||
else:
|
||
pass
|
||
return callback
|
||
|
||
|
||
def _cli_verbose_external_progress() -> bool:
|
||
value = os.environ.get("OPC_CLI_VERBOSE_EXTERNAL", "")
|
||
return value.strip().lower() in {"1", "true", "yes", "on", "debug"}
|
||
|
||
|
||
def _should_show_external_status(text: str) -> bool:
|
||
if _cli_verbose_external_progress():
|
||
return True
|
||
detail = text.split("]", 1)[1].strip().lower() if "]" in text else text.lower()
|
||
if "working; last activity" in detail:
|
||
return False
|
||
return any(token in detail for token in ("started", "approval", "timeout", "failed", "denied", "exhausted", "awaiting", "requires"))
|
||
|
||
|
||
@dataclass
|
||
class _QueuedAssistantLine:
|
||
text: str
|
||
enqueued_at: float
|
||
|
||
|
||
class _CliRuntimeDisplay:
|
||
def __init__(self, rich_console: Console) -> None:
|
||
self.console = rich_console
|
||
self._assistant_buffer = ""
|
||
self._line_queue: deque[_QueuedAssistantLine] = deque()
|
||
self._drain_task: asyncio.Task[None] | None = None
|
||
self._mode = "smooth"
|
||
self._below_exit_since: float | None = None
|
||
self._last_exit_at: float | None = None
|
||
self._status_snapshot: dict[str, object] = {}
|
||
self._checkpoint_hint = ""
|
||
self._last_status_render = ""
|
||
self._last_status_render_at = 0.0
|
||
self._sidecar_quiet_depth = 0
|
||
self._sidecar_quiet_event_count = 0
|
||
self.has_streamed_content = False
|
||
|
||
def begin_turn(self) -> None:
|
||
self.has_streamed_content = False
|
||
self._assistant_buffer = ""
|
||
self._line_queue.clear()
|
||
self._mode = "smooth"
|
||
self._below_exit_since = None
|
||
self._last_exit_at = None
|
||
self._checkpoint_hint = ""
|
||
|
||
def status_snapshot(self) -> dict[str, object]:
|
||
snapshot = dict(self._status_snapshot)
|
||
snapshot["stream_queue_depth"] = len(self._line_queue)
|
||
snapshot["stream_drain_mode"] = self._mode
|
||
if self._checkpoint_hint:
|
||
snapshot["checkpoint_hint"] = self._checkpoint_hint
|
||
return snapshot
|
||
|
||
def set_checkpoint_hint(self, hint: str) -> None:
|
||
self._checkpoint_hint = hint.strip()
|
||
|
||
async def render_status(self, *, force: bool = False) -> None:
|
||
await self._render_status(force=force)
|
||
|
||
async def handle_event(self, event: OPCEvent) -> None:
|
||
if getattr(event, "event_type", "") != "runtime_event":
|
||
return
|
||
payload = dict(getattr(event, "payload", {}) or {})
|
||
runtime_type = str(payload.get("type", "") or "").strip()
|
||
if self._sidecar_quiet_depth > 0:
|
||
self._sidecar_quiet_event_count += 1
|
||
if runtime_type == "status_snapshot":
|
||
self._status_snapshot = payload
|
||
elif runtime_type in {"turn_completed", "turn_failed"}:
|
||
self._assistant_buffer = ""
|
||
self._line_queue.clear()
|
||
return
|
||
if runtime_type == "assistant_delta":
|
||
self._enqueue_assistant_delta(str(payload.get("text", "") or ""))
|
||
self._ensure_drain_task()
|
||
return
|
||
if runtime_type in {"turn_completed", "turn_failed"}:
|
||
self._finalize_assistant_buffer()
|
||
await self.flush()
|
||
await self._render_status(force=True)
|
||
return
|
||
if runtime_type == "status_snapshot":
|
||
self._status_snapshot = payload
|
||
await self._render_status()
|
||
return
|
||
if runtime_type == "tool_started":
|
||
tool_name = str(payload.get("tool_name", "") or "tool").strip()
|
||
self.console.print(f" [tool]Running {escape(tool_name)}[/tool]")
|
||
return
|
||
if runtime_type == "tool_completed":
|
||
tool_name = str(payload.get("tool_name", "") or "tool").strip()
|
||
elapsed_ms = int(payload.get("elapsed_ms", 0) or 0)
|
||
summary = str(payload.get("result_summary", "") or "").strip()
|
||
suffix = f" ({elapsed_ms}ms)" if elapsed_ms > 0 else ""
|
||
if summary:
|
||
self.console.print(f" [tool]{escape(tool_name)} finished{suffix}: {escape(summary)}[/tool]")
|
||
else:
|
||
self.console.print(f" [tool]{escape(tool_name)} finished{suffix}[/tool]")
|
||
return
|
||
if runtime_type == "permission_requested":
|
||
tool_name = str(payload.get("tool_name", "") or "tool").strip()
|
||
rationale = str(payload.get("rationale", "") or "").strip()
|
||
self.console.print(
|
||
f" [warning]Approval needed for {escape(tool_name)}: {escape(rationale or 'awaiting review')}[/warning]"
|
||
)
|
||
return
|
||
if runtime_type == "sandbox_retry_requested":
|
||
tool_name = str(payload.get("tool_name", "") or "tool").strip()
|
||
self.console.print(
|
||
f" [info]Retrying {escape(tool_name)} with sandbox upgrade "
|
||
f"{escape(str(payload.get('from_mode', 'unknown')))} -> "
|
||
f"{escape(str(payload.get('to_mode', 'unknown')))}[/info]"
|
||
)
|
||
return
|
||
if runtime_type == "sandbox_retry_completed":
|
||
tool_name = str(payload.get("tool_name", "") or "tool").strip()
|
||
state = "succeeded" if bool(payload.get("success", False)) else "failed"
|
||
self.console.print(f" [info]Sandbox retry {state} for {escape(tool_name)}[/info]")
|
||
return
|
||
if runtime_type == "context_warning":
|
||
pct = int(payload.get("context_remaining_pct", 0) or 0)
|
||
self.console.print(f" [warning]Context window low: {pct}% remaining[/warning]")
|
||
|
||
def _enqueue_assistant_delta(self, text: str) -> None:
|
||
if not text:
|
||
return
|
||
self._assistant_buffer += text
|
||
while "\n" in self._assistant_buffer:
|
||
line, self._assistant_buffer = self._assistant_buffer.split("\n", 1)
|
||
self._line_queue.append(_QueuedAssistantLine(line, time.monotonic()))
|
||
|
||
def _finalize_assistant_buffer(self) -> None:
|
||
if self._assistant_buffer:
|
||
self._line_queue.append(_QueuedAssistantLine(self._assistant_buffer, time.monotonic()))
|
||
self._assistant_buffer = ""
|
||
|
||
def _ensure_drain_task(self) -> None:
|
||
if self._drain_task is None or self._drain_task.done():
|
||
self._drain_task = asyncio.create_task(self._drain_loop())
|
||
|
||
def _drain_count(self, now: float) -> int:
|
||
queued = len(self._line_queue)
|
||
if queued == 0:
|
||
self._mode = "smooth"
|
||
self._below_exit_since = None
|
||
return 0
|
||
oldest_age_ms = int(max(0.0, now - self._line_queue[0].enqueued_at) * 1000)
|
||
severe = queued >= 64 or oldest_age_ms >= 300
|
||
if self._mode == "smooth":
|
||
if (queued >= 8 or oldest_age_ms >= 120) and (not self._last_exit_at or severe or (now - self._last_exit_at) >= 0.25):
|
||
self._mode = "catch_up"
|
||
self._below_exit_since = None
|
||
else:
|
||
below_exit = queued <= 2 and oldest_age_ms <= 40
|
||
if below_exit:
|
||
if self._below_exit_since is None:
|
||
self._below_exit_since = now
|
||
elif (now - self._below_exit_since) >= 0.25:
|
||
self._mode = "smooth"
|
||
self._below_exit_since = None
|
||
self._last_exit_at = now
|
||
else:
|
||
self._below_exit_since = None
|
||
return queued if self._mode == "catch_up" else 1
|
||
|
||
async def _drain_loop(self) -> None:
|
||
try:
|
||
while self._line_queue:
|
||
now = time.monotonic()
|
||
count = self._drain_count(now)
|
||
for _ in range(min(count, len(self._line_queue))):
|
||
line = self._line_queue.popleft()
|
||
self.console.print(line.text, markup=False)
|
||
self.has_streamed_content = True
|
||
await self._render_status()
|
||
await asyncio.sleep(0.05)
|
||
finally:
|
||
self._drain_task = None
|
||
|
||
async def _render_status(self, *, force: bool = False) -> None:
|
||
now = time.monotonic()
|
||
snapshot = dict(self._status_snapshot)
|
||
stream_queue_depth = len(self._line_queue)
|
||
runtime_queue_depth = snapshot.get("queue_depth", 0)
|
||
current_tool = str(snapshot.get("current_tool", "") or "").strip()
|
||
agent_id = str(snapshot.get("agent_id", "") or snapshot.get("role_id", "") or "").strip()
|
||
task_id = str(snapshot.get("task_id", "") or "").strip()
|
||
context_remaining_pct = snapshot.get("context_remaining_pct")
|
||
turn_cost = snapshot.get("turn_cost_usd")
|
||
session_cost = snapshot.get("session_cost_usd")
|
||
pending_permission_count = snapshot.get("pending_permission_count")
|
||
parts: list[str] = []
|
||
parts.append(f"queue={int(runtime_queue_depth or 0)}")
|
||
parts.append(f"stream_queue={stream_queue_depth}")
|
||
parts.append(f"drain={self._mode}")
|
||
if agent_id:
|
||
parts.append(f"agent={agent_id}")
|
||
if task_id:
|
||
parts.append(f"task={task_id}")
|
||
if current_tool:
|
||
elapsed_ms = int(snapshot.get("tool_elapsed_ms", 0) or 0)
|
||
tool_part = f"tool={current_tool}"
|
||
if elapsed_ms > 0:
|
||
tool_part += f" {elapsed_ms}ms"
|
||
parts.append(tool_part)
|
||
if context_remaining_pct not in (None, ""):
|
||
parts.append(f"context={int(context_remaining_pct)}%")
|
||
if turn_cost not in (None, ""):
|
||
parts.append(f"turn=${float(turn_cost):.4f}")
|
||
if session_cost not in (None, ""):
|
||
parts.append(f"session=${float(session_cost):.4f}")
|
||
if pending_permission_count not in (None, "") and int(pending_permission_count or 0) > 0:
|
||
parts.append(f"approvals={int(pending_permission_count or 0)}")
|
||
if self._checkpoint_hint:
|
||
parts.append(f"checkpoint={self._checkpoint_hint}")
|
||
message = " | ".join(parts)
|
||
if not message:
|
||
return
|
||
if not force and (message == self._last_status_render or (now - self._last_status_render_at) < 0.75):
|
||
return
|
||
self.console.print(f"[dim][status][/dim] {message}")
|
||
self._last_status_render = message
|
||
self._last_status_render_at = now
|
||
|
||
async def flush(self) -> None:
|
||
self._finalize_assistant_buffer()
|
||
if self._drain_task is not None:
|
||
await self._drain_task
|
||
while self._line_queue:
|
||
line = self._line_queue.popleft()
|
||
self.console.print(line.text)
|
||
self.has_streamed_content = True
|
||
|
||
def enter_sidecar_quiet(self) -> None:
|
||
self._sidecar_quiet_depth += 1
|
||
|
||
def exit_sidecar_quiet(self) -> int:
|
||
if self._sidecar_quiet_depth > 0:
|
||
self._sidecar_quiet_depth -= 1
|
||
count = self._sidecar_quiet_event_count
|
||
if self._sidecar_quiet_depth == 0:
|
||
self._sidecar_quiet_event_count = 0
|
||
return count
|
||
|
||
|
||
def _create_cli_engine(config, project: str | None):
|
||
from opc.engine import OPCEngine
|
||
|
||
runtime_display = _CliRuntimeDisplay(console)
|
||
engine = OPCEngine(
|
||
config=config,
|
||
project_id=project,
|
||
on_progress=_progress_callback(),
|
||
on_runtime_event=runtime_display.handle_event,
|
||
on_escalation=_escalation_callback(),
|
||
)
|
||
return engine, runtime_display
|
||
|
||
|
||
def _attach_cli_runtime_callbacks(state: _InteractiveChatState) -> None:
|
||
def on_company_runtime_children(session_id: str, task_ids: list[str]) -> None:
|
||
parent_session_id = str(session_id or "").strip()
|
||
clean_task_ids = [str(item or "").strip() for item in list(task_ids or []) if str(item or "").strip()]
|
||
if parent_session_id and clean_task_ids:
|
||
state.session_to_task[parent_session_id] = clean_task_ids[0]
|
||
for task_id in clean_task_ids:
|
||
state.active_runtime_children[task_id] = clean_task_ids[0]
|
||
|
||
try:
|
||
state.engine.on_company_runtime_children = on_company_runtime_children
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _normalize_escalation_key(value: str) -> str:
|
||
return re.sub(r"[\s\-]+", "_", value.strip()).strip("_").casefold()
|
||
|
||
|
||
def _format_escalation_option(option: dict, index: int) -> str:
|
||
option_id = str(option.get("id", "")).strip()
|
||
label = str(option.get("label", option_id)).strip() or option_id
|
||
# Use an explicit numeric prefix and escape markup so Rich never hides the id.
|
||
return f" {index}. {escape(label)} ({escape(option_id)})"
|
||
|
||
|
||
def _normalize_escalation_reply(reply: str, options: list[dict]) -> str | None:
|
||
raw_reply = reply.strip()
|
||
if not raw_reply:
|
||
return None
|
||
|
||
normalized_map: dict[str, str] = {}
|
||
for idx, option in enumerate(options, start=1):
|
||
option_id = str(option.get("id", "")).strip()
|
||
label = str(option.get("label", option_id)).strip()
|
||
if not option_id:
|
||
continue
|
||
normalized_map[option_id.casefold()] = option_id
|
||
normalized_map[_normalize_escalation_key(option_id)] = option_id
|
||
if label:
|
||
normalized_map[label.casefold()] = option_id
|
||
normalized_map[_normalize_escalation_key(label)] = option_id
|
||
normalized_map[str(idx)] = option_id
|
||
|
||
alias_map = {
|
||
"y": "approve_once",
|
||
"yes": "approve_once",
|
||
"approve": "approve_once",
|
||
"allow": "approve_once",
|
||
"n": "deny",
|
||
"no": "deny",
|
||
"deny": "deny",
|
||
"reject": "deny",
|
||
"project": "always_project",
|
||
"global": "always_global",
|
||
}
|
||
if alias := alias_map.get(_normalize_escalation_key(raw_reply)):
|
||
if alias in normalized_map.values():
|
||
return alias
|
||
|
||
return normalized_map.get(raw_reply.casefold()) or normalized_map.get(_normalize_escalation_key(raw_reply))
|
||
|
||
|
||
def _escalation_callback():
|
||
"""Create an async escalation callback for human-in-the-loop."""
|
||
async def callback(message: str, options: list[dict]) -> str | None:
|
||
console.print(Panel(message, title="Action Required", border_style="yellow"))
|
||
if options:
|
||
for idx, opt in enumerate(options, start=1):
|
||
console.print(_format_escalation_option(opt, idx))
|
||
console.print(" Enter the number, label, or internal id.")
|
||
while True:
|
||
try:
|
||
reply = console.input("[bold]Your choice: [/bold]")
|
||
except (EOFError, KeyboardInterrupt):
|
||
return None
|
||
normalized = _normalize_escalation_reply(reply, options)
|
||
if normalized is not None:
|
||
return normalized
|
||
console.print("[warning]Invalid choice. Please enter a listed number, label, or id.[/warning]")
|
||
else:
|
||
try:
|
||
reply = console.input("[bold]Your reply: [/bold]")
|
||
return reply.strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
return None
|
||
return callback
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commands
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.command()
|
||
def chat(
|
||
message: Optional[str] = typer.Argument(None, help="Single message to process"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID to work in"),
|
||
model: Optional[str] = typer.Option(None, "--model", "-m", help="Override default LLM model"),
|
||
mode: str = typer.Option("task", "--mode", help="Execution mode: task or company"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred agent: native, claude_code, codex, cursor, opencode"),
|
||
company_profile: str = typer.Option("corporate", "--company-profile", help="Company profile for company mode"),
|
||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed logs"),
|
||
no_markdown: bool = typer.Option(False, "--no-markdown", help="Plain text output"),
|
||
):
|
||
"""Chat with the OPC system — interactive or single message mode."""
|
||
config = _get_config()
|
||
if model:
|
||
config.llm.default_model = model
|
||
if verbose:
|
||
config.system.log_level = "DEBUG"
|
||
|
||
if message:
|
||
asyncio.run(_single_message(
|
||
config,
|
||
message,
|
||
project,
|
||
no_markdown,
|
||
mode=mode,
|
||
preferred_agent=agent,
|
||
company_profile=company_profile,
|
||
))
|
||
else:
|
||
asyncio.run(_interactive_mode(
|
||
config,
|
||
project,
|
||
no_markdown,
|
||
mode=mode,
|
||
preferred_agent=agent,
|
||
company_profile=company_profile,
|
||
explicit_mode=_cli_option_present("--mode"),
|
||
explicit_agent=_cli_option_present("--agent"),
|
||
explicit_company_profile=_cli_option_present("--company-profile"),
|
||
))
|
||
|
||
|
||
@app.command("exec")
|
||
def exec_command(
|
||
prompt: Optional[str] = typer.Argument(None, help="Prompt to run non-interactively. Reads stdin when omitted."),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID to work in"),
|
||
mode: str = typer.Option("task", "--mode", help="Execution mode: task, company, or org"),
|
||
company_profile: str = typer.Option("corporate", "--company-profile", help="Company profile for company mode"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred agent: native, claude_code, codex, cursor, opencode"),
|
||
org_id: Optional[str] = typer.Option(None, "--org", "--org-id", help="Saved org id for org/custom mode"),
|
||
session_id: Optional[str] = typer.Option(None, "--session-id", help="Existing task-backed session id to reuse"),
|
||
resume: bool = typer.Option(False, "--resume", help="Resume the latest task-backed session in the project"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print final JSON payload"),
|
||
stream_json: bool = typer.Option(False, "--stream-json", help="Print newline-delimited JSON runtime events"),
|
||
no_markdown: bool = typer.Option(False, "--no-markdown", help="Plain text output"),
|
||
):
|
||
"""Run one non-interactive OPC task for scripts and CI."""
|
||
if json_output and stream_json:
|
||
console.print("[error]Use either --json or --stream-json, not both.[/error]")
|
||
raise typer.Exit(code=2)
|
||
if session_id and resume:
|
||
console.print("[error]Use either --session-id or --resume, not both.[/error]")
|
||
raise typer.Exit(code=2)
|
||
message = prompt
|
||
if message is None and not sys.stdin.isatty():
|
||
message = sys.stdin.read()
|
||
config = _get_config()
|
||
try:
|
||
asyncio.run(_exec_message(
|
||
config=config,
|
||
prompt=str(message or "").strip(),
|
||
project=project,
|
||
mode=mode,
|
||
company_profile=company_profile,
|
||
preferred_agent=agent,
|
||
org_id=org_id,
|
||
session_id=session_id,
|
||
resume=resume,
|
||
json_output=json_output,
|
||
stream_json=stream_json,
|
||
no_markdown=no_markdown,
|
||
))
|
||
except KeyboardInterrupt as exc:
|
||
if stream_json:
|
||
_print_exec_event(
|
||
{"seq": 0},
|
||
"error",
|
||
project_id=project or "default",
|
||
payload={"code": "cancelled", "error": "Interrupted"},
|
||
)
|
||
else:
|
||
console.print("\n[warning]Interrupted.[/warning]")
|
||
raise typer.Exit(code=130) from exc
|
||
|
||
|
||
@app.command()
|
||
def secretary(
|
||
message: Optional[str] = typer.Argument(None, help="Single secretary message"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID for secretary context"),
|
||
model: Optional[str] = typer.Option(None, "--model", "-m", help="Override default LLM model"),
|
||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed logs"),
|
||
no_markdown: bool = typer.Option(False, "--no-markdown", help="Plain text output"),
|
||
):
|
||
"""Talk directly with the long-term secretary interface."""
|
||
config = _get_config()
|
||
if model:
|
||
config.llm.default_model = model
|
||
if verbose:
|
||
config.system.log_level = "DEBUG"
|
||
|
||
if message:
|
||
asyncio.run(_single_secretary_message(config, message, project, no_markdown))
|
||
else:
|
||
asyncio.run(_interactive_secretary_mode(config, project, no_markdown))
|
||
|
||
|
||
@app.command("propose-reorg")
|
||
def propose_reorg(
|
||
payload: str = typer.Argument(..., help="JSON payload with summary/rationale/changeset"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Create a runtime company reorg proposal."""
|
||
config = _get_config()
|
||
asyncio.run(_propose_reorg(config, payload, project))
|
||
|
||
|
||
@app.command("approve-reorg")
|
||
def approve_reorg(
|
||
proposal_id: str = typer.Argument(..., help="Reorg proposal ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Approve a pending company reorg proposal."""
|
||
config = _get_config()
|
||
asyncio.run(_approve_reorg(config, proposal_id, project, approved=True))
|
||
|
||
|
||
@app.command("deny-reorg")
|
||
def deny_reorg(
|
||
proposal_id: str = typer.Argument(..., help="Reorg proposal ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Deny a pending company reorg proposal."""
|
||
config = _get_config()
|
||
asyncio.run(_approve_reorg(config, proposal_id, project, approved=False))
|
||
|
||
|
||
@app.command("apply-reorg")
|
||
def apply_reorg(
|
||
proposal_id: str = typer.Argument(..., help="Approved reorg proposal ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Apply an approved company reorg proposal."""
|
||
config = _get_config()
|
||
asyncio.run(_apply_reorg(config, proposal_id, project))
|
||
|
||
|
||
@app.command("show-reorg")
|
||
def show_reorg(
|
||
proposal_id: str = typer.Argument(..., help="Reorg proposal ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Show a company reorg proposal."""
|
||
config = _get_config()
|
||
asyncio.run(_show_reorg(config, proposal_id, project))
|
||
|
||
|
||
def _template_has_required_config_files(source: Any) -> bool:
|
||
try:
|
||
return (
|
||
source.is_dir()
|
||
and source.joinpath("llm_config.yaml").is_file()
|
||
and source.joinpath("system_config.yaml").is_file()
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _project_config_template_dir() -> Any | None:
|
||
"""Return the best config template source.
|
||
|
||
Source checkouts use ``project_root/config``. Installed packages fall back
|
||
to ``opc/config_templates`` shipped as package data via ``importlib.resources``.
|
||
"""
|
||
from opc.core.config import get_opc_home
|
||
|
||
opc_home = get_opc_home()
|
||
project_root = opc_home.parent
|
||
repo_config = project_root / "config"
|
||
if _template_has_required_config_files(repo_config):
|
||
return repo_config
|
||
|
||
try:
|
||
packaged_config = importlib_resources.files("opc").joinpath("config_templates")
|
||
except Exception:
|
||
packaged_config = None
|
||
if packaged_config is not None and _template_has_required_config_files(packaged_config):
|
||
return packaged_config
|
||
return None
|
||
|
||
|
||
def _copy_template_tree(source: Any, target: Path) -> None:
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
for child in source.iterdir():
|
||
dest = target / child.name
|
||
if child.is_dir():
|
||
_copy_template_tree(child, dest)
|
||
elif child.is_file():
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
dest.write_bytes(child.read_bytes())
|
||
|
||
|
||
def _load_config_template(source: Any) -> OPCConfig:
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
tmp_path = Path(tmpdir)
|
||
_copy_template_tree(source, tmp_path)
|
||
return OPCConfig.load(tmp_path)
|
||
|
||
|
||
def _opc_config_initialized(opc_home: Path) -> bool:
|
||
config_dir = opc_home / "config"
|
||
if not config_dir.exists():
|
||
return False
|
||
expected = (
|
||
"llm_config.yaml",
|
||
"system_config.yaml",
|
||
"agent_config.yaml",
|
||
"org_config.yaml",
|
||
)
|
||
if any((config_dir / name).exists() for name in expected):
|
||
return True
|
||
return any(config_dir.iterdir())
|
||
|
||
|
||
def _trust_configured_external_agents(
|
||
config: OPCConfig,
|
||
opc_home: Path,
|
||
*,
|
||
project_id: str | None,
|
||
) -> None:
|
||
from opc.layer5_memory.approval_allowlist import ApprovalAllowlistManager
|
||
|
||
manager = ApprovalAllowlistManager(opc_home)
|
||
manager.ensure_file()
|
||
scope_project = project_id.strip() if isinstance(project_id, str) and project_id.strip() else None
|
||
for agent_name, agent_config in config.agents.agents.items():
|
||
if not getattr(agent_config, "enabled", True):
|
||
continue
|
||
manager.add_patterns(
|
||
"external_agent",
|
||
agent_name,
|
||
["*"],
|
||
project_id=scope_project,
|
||
)
|
||
|
||
|
||
def _run_init_external_agent_preflight(
|
||
config: OPCConfig,
|
||
*,
|
||
project_id: str,
|
||
opc_home: Path,
|
||
workspace_path: Path,
|
||
) -> None:
|
||
from opc.layer3_agent.preflight import run_external_agent_preflight
|
||
|
||
results = run_external_agent_preflight(
|
||
config,
|
||
project_id=project_id,
|
||
workspace_path=workspace_path,
|
||
opc_home=opc_home,
|
||
probe_commands=False,
|
||
prepare_surfaces=True,
|
||
)
|
||
available = sum(1 for item in results if item.available)
|
||
configured = sum(1 for item in results if item.enabled)
|
||
failures = [item for item in results if item.enabled and item.issues]
|
||
console.print(
|
||
f"[info]External agent preflight:[/info] {available}/{configured} configured agents found; "
|
||
f"collab surfaces and workspace permissions checked."
|
||
)
|
||
for item in failures:
|
||
console.print(f" [warning]- {item.agent}: {'; '.join(item.issues[:2])}[/warning]")
|
||
|
||
|
||
def _render_external_agent_detection(config: OPCConfig) -> None:
|
||
import shutil
|
||
|
||
from opc.layer3_agent.adapters.registry import ADAPTER_CLASSES
|
||
|
||
for agent_name, agent_config in config.agents.agents.items():
|
||
adapter_cls = ADAPTER_CLASSES.get(agent_name)
|
||
if adapter_cls is not None:
|
||
adapter = adapter_cls(config=agent_config)
|
||
command = adapter.configured_command()
|
||
found = adapter.resolve_binary()
|
||
else:
|
||
command = agent_config.command
|
||
found = shutil.which(command)
|
||
if found:
|
||
console.print(f" - {agent_name}: [success]found[/success] ({found})")
|
||
else:
|
||
console.print(f" - {agent_name}: [warning]not found[/warning] (command={command})")
|
||
|
||
|
||
def _render_external_agent_preflight_table(results: list[Any]) -> None:
|
||
table = Table(show_header=True, header_style="bold")
|
||
table.add_column("Agent")
|
||
table.add_column("Binary")
|
||
table.add_column("Mode")
|
||
table.add_column("Stdin")
|
||
table.add_column("Workspace")
|
||
table.add_column("Collab")
|
||
table.add_column("Notes")
|
||
for item in results:
|
||
if not item.enabled:
|
||
binary = "[warning]disabled[/warning]"
|
||
elif item.available:
|
||
binary = f"[success]found[/success]\n{item.binary}"
|
||
else:
|
||
binary = f"[error]missing[/error]\n{item.command}"
|
||
mode = ""
|
||
if item.launch_command:
|
||
mode = _truncate_status_cell(item.launch_command, limit=180)
|
||
elif item.version:
|
||
mode = item.version
|
||
workspace_ok = all(check.ok for check in item.write_checks)
|
||
workspace = "[success]writable[/success]" if workspace_ok else "[error]blocked[/error]"
|
||
collab = "[success]ready[/success]" if item.collab_cli and not item.issues else "[warning]check[/warning]"
|
||
rpc_transport = str(getattr(item, "collaboration_rpc_transport", "") or "").strip()
|
||
if rpc_transport:
|
||
collab = f"{collab}\nRPC: {rpc_transport}"
|
||
notes = []
|
||
if item.issues:
|
||
notes.extend(item.issues[:2])
|
||
if item.warnings:
|
||
notes.extend(item.warnings[:2])
|
||
table.add_row(
|
||
item.agent,
|
||
binary,
|
||
mode,
|
||
str(getattr(item, "stdin_policy", "") or "-"),
|
||
workspace,
|
||
collab,
|
||
_truncate_status_cell("; ".join(notes) if notes else "ok", limit=160),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _truncate_status_cell(value: str, *, limit: int) -> str:
|
||
text = str(value or "").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: max(limit - 3, 0)].rstrip() + "..."
|
||
|
||
|
||
@app.command()
|
||
def init(
|
||
project: Optional[str] = typer.Argument(None, help="Project ID to initialize"),
|
||
yes: bool = typer.Option(False, "--yes", "-y", help="Continue when OPC is already initialized; existing config is preserved."),
|
||
external_agent_preflight: bool = typer.Option(
|
||
True,
|
||
"--external-agent-preflight/--no-external-agent-preflight",
|
||
help="Provision and validate external-agent homes, collab CLI, and workspace permissions.",
|
||
),
|
||
trust_external_agents: bool = typer.Option(
|
||
True,
|
||
"--trust-external-agents/--no-trust-external-agents",
|
||
help="Persist OpenOPC approval rules for configured external-agent launches.",
|
||
),
|
||
):
|
||
"""Initialize OPC configuration and workspace."""
|
||
from opc.core.config import get_opc_home, get_project_workplace
|
||
|
||
opc_home = get_opc_home()
|
||
template_dir = _project_config_template_dir()
|
||
already_initialized = _opc_config_initialized(opc_home)
|
||
config: OPCConfig
|
||
|
||
if already_initialized:
|
||
if not yes and not typer.confirm(
|
||
f"OPC is already initialized at {opc_home}. Continue without overwriting existing config?",
|
||
default=False,
|
||
):
|
||
console.print("[warning]Init cancelled. Existing config was left unchanged.[/warning]")
|
||
raise typer.Exit(1)
|
||
console.print(f"[info]Existing config preserved: {opc_home / 'config'}[/info]")
|
||
config = OPCConfig.load(opc_home / "config")
|
||
elif template_dir is not None:
|
||
# Use repo config template (same setup as maintainers, keys left for user to set)
|
||
config = _load_config_template(template_dir)
|
||
config.llm.api_key = ""
|
||
config.save(opc_home / "config")
|
||
else:
|
||
config = OPCConfig()
|
||
if not config.org.roles:
|
||
from opc.core.config import RoleConfig, EscalationRule
|
||
browser_tools = [
|
||
"browser_navigate",
|
||
"browser_navigate_back",
|
||
"browser_click",
|
||
"browser_snapshot",
|
||
"browser_type",
|
||
"browser_wait_for",
|
||
"browser_scroll",
|
||
"browser_select_option",
|
||
"browser_take_screenshot",
|
||
"browser_close",
|
||
]
|
||
planning_tools = [
|
||
"file_read",
|
||
"file_search",
|
||
"list_dir",
|
||
"todo_write",
|
||
"todo_read",
|
||
]
|
||
engineering_tools = [
|
||
"shell_exec",
|
||
"file_read",
|
||
"file_write",
|
||
"file_edit",
|
||
"file_search",
|
||
"list_dir",
|
||
"web_search",
|
||
"web_fetch",
|
||
"todo_write",
|
||
"todo_read",
|
||
*browser_tools,
|
||
]
|
||
review_tools = [
|
||
"file_read",
|
||
"file_search",
|
||
"list_dir",
|
||
"shell_exec",
|
||
"browser_navigate",
|
||
"browser_navigate_back",
|
||
"browser_snapshot",
|
||
"browser_wait_for",
|
||
"browser_scroll",
|
||
"browser_select_option",
|
||
"browser_take_screenshot",
|
||
]
|
||
config.org.roles = [
|
||
RoleConfig(
|
||
id="ceo",
|
||
name="CEO",
|
||
icon="leader",
|
||
responsibility="Strategic intake, high-level routing, final aggregation and delivery to the owner",
|
||
reports_to="owner",
|
||
can_spawn=["cto", "cmo", "coo"],
|
||
tools=planning_tools,
|
||
),
|
||
RoleConfig(
|
||
id="cto",
|
||
name="CTO",
|
||
icon="code",
|
||
responsibility="Technical planning, architecture decisions, code review, and engineering oversight",
|
||
reports_to="ceo",
|
||
can_spawn=["senior_engineer", "devops_engineer"],
|
||
tools=[*planning_tools, "shell_exec", "web_search", "web_fetch"],
|
||
),
|
||
RoleConfig(
|
||
id="cmo",
|
||
name="CMO",
|
||
icon="marketing",
|
||
responsibility="Marketing strategy, content planning, UX review, and brand oversight",
|
||
reports_to="ceo",
|
||
can_spawn=["content_specialist", "designer"],
|
||
tools=[
|
||
*planning_tools,
|
||
"web_search",
|
||
"web_fetch",
|
||
"browser_navigate",
|
||
"browser_navigate_back",
|
||
"browser_snapshot",
|
||
"browser_wait_for",
|
||
"browser_scroll",
|
||
"browser_take_screenshot",
|
||
],
|
||
),
|
||
RoleConfig(
|
||
id="coo",
|
||
name="COO",
|
||
icon="strategy",
|
||
responsibility="Operations coordination, process management, cross-team alignment, and quality assurance",
|
||
reports_to="ceo",
|
||
can_spawn=["qa_analyst"],
|
||
tools=[
|
||
*planning_tools,
|
||
"shell_exec",
|
||
"browser_navigate",
|
||
"browser_navigate_back",
|
||
"browser_snapshot",
|
||
"browser_wait_for",
|
||
"browser_scroll",
|
||
"browser_take_screenshot",
|
||
],
|
||
),
|
||
RoleConfig(
|
||
id="senior_engineer",
|
||
name="Senior Engineer",
|
||
icon="terminal",
|
||
responsibility="Code implementation, system development, and technical execution",
|
||
reports_to="cto",
|
||
preferred_external_agent="codex",
|
||
tools=engineering_tools,
|
||
),
|
||
RoleConfig(
|
||
id="devops_engineer",
|
||
name="DevOps Engineer",
|
||
icon="settings",
|
||
responsibility="Infrastructure, deployment, CI/CD, monitoring, and operational hardening",
|
||
reports_to="cto",
|
||
preferred_external_agent="cursor",
|
||
tools=engineering_tools,
|
||
),
|
||
RoleConfig(
|
||
id="content_specialist",
|
||
name="Content Specialist",
|
||
icon="writing",
|
||
responsibility="Documentation, copywriting, presentations, and user-facing writing",
|
||
reports_to="cmo",
|
||
tools=[
|
||
"file_read",
|
||
"file_write",
|
||
"file_edit",
|
||
"file_search",
|
||
"list_dir",
|
||
"web_search",
|
||
"web_fetch",
|
||
"todo_write",
|
||
"todo_read",
|
||
*browser_tools,
|
||
],
|
||
),
|
||
RoleConfig(
|
||
id="designer",
|
||
name="Designer",
|
||
icon="design",
|
||
responsibility="Visual design, UX artifacts, wireframes, and design system work",
|
||
reports_to="cmo",
|
||
tools=[
|
||
"file_read",
|
||
"file_write",
|
||
"file_edit",
|
||
"file_search",
|
||
"list_dir",
|
||
"web_search",
|
||
"web_fetch",
|
||
"todo_write",
|
||
"todo_read",
|
||
*browser_tools,
|
||
],
|
||
),
|
||
RoleConfig(
|
||
id="qa_analyst",
|
||
name="QA Analyst",
|
||
icon="bug",
|
||
responsibility="Testing, security review, compliance checks, and acceptance validation",
|
||
reports_to="coo",
|
||
tools=review_tools,
|
||
),
|
||
]
|
||
config.org.escalation_rules = [
|
||
EscalationRule(condition="3 consecutive failures with no progress", action="Escalate to owner with failure reason"),
|
||
EscalationRule(condition="External account or credentials required", action="Escalate to owner"),
|
||
EscalationRule(condition="Security vulnerability severity >= HIGH", action="Immediately halt and escalate"),
|
||
EscalationRule(condition="Budget exceeds 80% of limit", action="Alert owner and await instructions"),
|
||
]
|
||
config.save(opc_home / "config")
|
||
|
||
# Create default directories. ``agent_homes/`` and ``bin/`` get
|
||
# provisioned lazily by the skill installer the first time an
|
||
# external agent launches.
|
||
for subdir in ["memory", "skills/core", "skills/cache", "skills/learned", "logs", "prompts/talent"]:
|
||
(opc_home / subdir).mkdir(parents=True, exist_ok=True)
|
||
|
||
# Create default skills
|
||
_create_default_skills(opc_home)
|
||
|
||
# Create default global memory.
|
||
from opc.layer5_memory.approval_allowlist import ApprovalAllowlistManager
|
||
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
||
memory_store = MarkdownMemoryStore(opc_home)
|
||
if not memory_store.load_raw_text():
|
||
memory_store.save_visible_text("# Global Memory", None)
|
||
ApprovalAllowlistManager(opc_home).ensure_file()
|
||
|
||
if project:
|
||
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", project or ""):
|
||
console.print("[error]Invalid project ID (use alphanumeric, hyphens, underscores).[/error]")
|
||
raise typer.Exit(1)
|
||
proj_dir = opc_home / "projects" / project
|
||
project_memory = memory_store.memory_path(project)
|
||
workplace = get_project_workplace(project)
|
||
if proj_dir.exists() or project_memory.exists() or workplace.exists():
|
||
console.print(f"[error]Project '{project}' already exists.[/error]")
|
||
raise typer.Exit(1)
|
||
proj_dir.mkdir(parents=True, exist_ok=False)
|
||
workplace.mkdir(parents=True, exist_ok=False)
|
||
memory_store.ensure_memory_file(project, f"# Project Memory ({project})")
|
||
console.print(f"[success]Project '{project}' initialized at {proj_dir}[/success]")
|
||
console.print(f" Workplace: {workplace}")
|
||
|
||
if trust_external_agents:
|
||
_trust_configured_external_agents(config, opc_home, project_id=project)
|
||
|
||
if external_agent_preflight:
|
||
_run_init_external_agent_preflight(
|
||
config,
|
||
project_id=project or "default",
|
||
opc_home=opc_home,
|
||
workspace_path=get_project_workplace(project or "default"),
|
||
)
|
||
|
||
console.print(f"[success]OPC initialized at {opc_home}[/success]")
|
||
console.print(f" Config: {opc_home / 'config'}")
|
||
console.print(f" Memory: {opc_home / 'memory'}")
|
||
console.print(f" Skills: {opc_home / 'skills'}")
|
||
console.print(f"\nEdit [bold]{opc_home / 'config' / 'llm_config.yaml'}[/bold] to set your API key.")
|
||
|
||
|
||
@app.command()
|
||
def status(
|
||
project: str = typer.Option("default", "--project", "-p", help="Project ID used for workspace preflight."),
|
||
external_agent_preflight: bool = typer.Option(
|
||
True,
|
||
"--external-agent-preflight/--no-external-agent-preflight",
|
||
help="Run external-agent command, flag, collab, and workspace checks.",
|
||
),
|
||
probe_agent_commands: bool = typer.Option(
|
||
True,
|
||
"--probe-agent-commands/--no-probe-agent-commands",
|
||
help="Run short --version/--help probes to detect unsupported CLI flags.",
|
||
),
|
||
):
|
||
"""Show OPC system status."""
|
||
from opc.core.config import get_opc_home
|
||
|
||
config = _get_config()
|
||
opc_home = get_opc_home()
|
||
|
||
console.print(Panel(f"[bold]OPC v{__version__}[/bold]", border_style="blue"))
|
||
console.print(f" Home: {opc_home}")
|
||
console.print(f" Model: {config.llm.default_model}")
|
||
console.print(f" Log level: {config.system.log_level}")
|
||
console.print(f" Max iterations: {config.system.max_agent_iterations}")
|
||
console.print(f" Autonomy mode: {config.autonomy.mode}")
|
||
console.print(f" Max auto-approve risk: {config.autonomy.max_auto_approve_risk}")
|
||
|
||
# Roles
|
||
console.print("\n[bold]Organization:[/bold]")
|
||
console.print(f" Company: {config.org.company_name}")
|
||
for role in config.org.roles:
|
||
console.print(f" - {role.name} ({role.id}): {role.responsibility[:60]}...")
|
||
|
||
# External agents
|
||
console.print("\n[bold]External Agents:[/bold]")
|
||
for name, agent_config in config.agents.agents.items():
|
||
status_str = "[success]enabled[/success]" if agent_config.enabled else "[warning]disabled[/warning]"
|
||
console.print(f" - {name}: {status_str}")
|
||
|
||
if not config.agents.agents:
|
||
console.print(" (none configured)")
|
||
|
||
console.print("\n[bold]External Agent Preflight:[/bold]")
|
||
if external_agent_preflight:
|
||
from opc.layer3_agent.preflight import run_external_agent_preflight
|
||
|
||
results = run_external_agent_preflight(
|
||
config,
|
||
project_id=project,
|
||
probe_commands=probe_agent_commands,
|
||
prepare_surfaces=True,
|
||
)
|
||
_render_external_agent_preflight_table(results)
|
||
else:
|
||
_render_external_agent_detection(config)
|
||
|
||
# Projects
|
||
projects_dir = opc_home / "projects"
|
||
if projects_dir.exists():
|
||
projects = [p.name for p in projects_dir.iterdir() if p.is_dir()]
|
||
console.print(f"\n[bold]Projects:[/bold] {', '.join(projects) if projects else '(none)'}")
|
||
|
||
# Cost
|
||
asyncio.run(_show_cost_summary(config))
|
||
asyncio.run(_show_autonomy_summary(config))
|
||
|
||
|
||
@app.command()
|
||
def autonomy_status(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Show autonomy policy, learned preferences, and approval stats."""
|
||
config = _get_config()
|
||
console.print(Panel("[bold]Autonomy Status[/bold]", border_style="blue"))
|
||
console.print(f" Mode: {config.autonomy.mode}")
|
||
console.print(f" Enabled: {config.autonomy.enabled}")
|
||
console.print(f" Max auto-approve risk: {config.autonomy.max_auto_approve_risk}")
|
||
console.print(f" Confidence threshold: {config.autonomy.approval_confidence_threshold}")
|
||
asyncio.run(_show_autonomy_summary(config, project=project))
|
||
|
||
|
||
@app.command()
|
||
def autonomy_reset(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
):
|
||
"""Reset learned autonomy preferences globally or for a project."""
|
||
from opc.core.config import get_opc_home
|
||
from opc.layer5_memory.approval_allowlist import ApprovalAllowlistManager
|
||
from opc.layer5_memory.preference import PreferenceManager
|
||
|
||
opc_home = get_opc_home()
|
||
prefs = PreferenceManager(opc_home)
|
||
prefs.reset_autonomy_preferences(project_id=project)
|
||
ApprovalAllowlistManager(opc_home).reset(project_id=project)
|
||
scope = f"project '{project}'" if project else "global"
|
||
console.print(f"[success]Reset learned autonomy preferences and allowlist rules for {scope}.[/success]")
|
||
|
||
|
||
@app.command()
|
||
def autonomy_configure(
|
||
mode: Optional[str] = typer.Option(None, "--mode", help="Autonomy mode, e.g. bounded"),
|
||
max_auto_approve_risk: Optional[str] = typer.Option(None, "--max-risk", help="Max auto-approve risk"),
|
||
approval_confidence_threshold: Optional[float] = typer.Option(None, "--confidence-threshold", help="Approval confidence threshold"),
|
||
):
|
||
"""Update autonomy configuration settings."""
|
||
from opc.core.config import get_opc_home
|
||
|
||
config = _get_config()
|
||
if mode is not None:
|
||
config.autonomy.mode = mode
|
||
if max_auto_approve_risk is not None:
|
||
config.autonomy.max_auto_approve_risk = max_auto_approve_risk
|
||
if approval_confidence_threshold is not None:
|
||
config.autonomy.approval_confidence_threshold = approval_confidence_threshold
|
||
config.save(get_opc_home() / "config")
|
||
console.print("[success]Updated autonomy configuration.[/success]")
|
||
|
||
|
||
@app.command()
|
||
def projects():
|
||
"""List all projects."""
|
||
from opc.core.config import get_opc_home, get_project_workplace
|
||
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
||
opc_home = get_opc_home()
|
||
memory_store = MarkdownMemoryStore(opc_home)
|
||
projects_dir = opc_home / "projects"
|
||
|
||
if not projects_dir.exists() or not any(projects_dir.iterdir()):
|
||
console.print("[warning]No projects found.[/warning]")
|
||
console.print("Create one with: opc init <project-name>")
|
||
return
|
||
|
||
for p in sorted(projects_dir.iterdir()):
|
||
if p.is_dir():
|
||
has_tasks = (p / "tasks.db").exists()
|
||
has_memory = memory_store.memory_path(p.name).exists()
|
||
has_workplace = get_project_workplace(p.name).exists()
|
||
markers = []
|
||
if has_tasks:
|
||
markers.append("tasks")
|
||
if has_memory:
|
||
markers.append("memory")
|
||
if has_workplace:
|
||
markers.append("workplace")
|
||
info = f" ({', '.join(markers)})" if markers else ""
|
||
console.print(f" - [bold]{p.name}[/bold]{info}")
|
||
|
||
|
||
@app.command()
|
||
def skills():
|
||
"""List available skills."""
|
||
from opc.core.config import get_opc_home
|
||
from opc.layer5_memory.skill_library import SkillLibrary
|
||
|
||
opc_home = get_opc_home()
|
||
lib = SkillLibrary(opc_home)
|
||
lib.load_all()
|
||
|
||
skill_list = lib.list_skills()
|
||
if not skill_list:
|
||
console.print("[warning]No skills found.[/warning]")
|
||
console.print(f"Add skills to {opc_home / 'skills'}")
|
||
return
|
||
|
||
console.print(f"[bold]Skills ({len(skill_list)}):[/bold]")
|
||
for s in skill_list:
|
||
always = " [always]" if s.always else ""
|
||
level = f" [{s.level}]" if s.level else ""
|
||
console.print(f" - [bold]{s.name}[/bold]{always}{level}")
|
||
if s.description:
|
||
console.print(f" {s.description}")
|
||
|
||
|
||
@app.command()
|
||
def config_show():
|
||
"""Show current configuration."""
|
||
import yaml
|
||
config = _get_config()
|
||
console.print(yaml.dump(config.model_dump(), default_flow_style=False))
|
||
|
||
|
||
def _json_safe(value: Any) -> Any:
|
||
if hasattr(value, "model_dump"):
|
||
return value.model_dump()
|
||
if hasattr(value, "__dict__"):
|
||
return dict(value.__dict__)
|
||
if hasattr(value, "value"):
|
||
return value.value
|
||
if hasattr(value, "isoformat"):
|
||
return value.isoformat()
|
||
return str(value)
|
||
|
||
|
||
def _emit_payload(payload: dict[str, Any], *, json_output: bool = False) -> None:
|
||
if json_output:
|
||
console.print(json.dumps(payload, ensure_ascii=False, indent=2, default=_json_safe))
|
||
return
|
||
if not payload:
|
||
console.print("[success]OK[/success]")
|
||
return
|
||
table = Table(show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
for key, value in payload.items():
|
||
if isinstance(value, (list, dict)):
|
||
rendered = json.dumps(value, ensure_ascii=False, default=_json_safe)
|
||
else:
|
||
rendered = str(_json_safe(value))
|
||
table.add_row(str(key), _clip_text(rendered, 400))
|
||
console.print(table)
|
||
|
||
|
||
async def _run_service_command(
|
||
project: str | None,
|
||
operation: Any,
|
||
*,
|
||
json_output: bool = False,
|
||
render: Any | None = None,
|
||
) -> None:
|
||
from opc.plugins.office_ui.services import ServiceError
|
||
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
|
||
|
||
async with OfficeServiceFactory(config=_get_config(), project_id=project) as services:
|
||
try:
|
||
result = await operation(services)
|
||
except ServiceError as exc:
|
||
if json_output:
|
||
console.print(json.dumps({"ok": False, **exc.to_payload()}, ensure_ascii=False, indent=2))
|
||
else:
|
||
console.print(f"[error]{escape(exc.message)}[/error]")
|
||
raise typer.Exit(code=1) from exc
|
||
payload = {"ok": True, **dict(result.payload)}
|
||
if render is not None and not json_output:
|
||
render(payload)
|
||
else:
|
||
_emit_payload(payload, json_output=json_output)
|
||
|
||
|
||
def _load_structured_payload(*, payload: str | None = None, file_path: str | None = None) -> Any:
|
||
raw = ""
|
||
if file_path:
|
||
raw = Path(file_path).read_text(encoding="utf-8")
|
||
elif payload:
|
||
raw = payload
|
||
if not raw.strip():
|
||
return {}
|
||
try:
|
||
return json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
import yaml
|
||
|
||
return yaml.safe_load(raw) or {}
|
||
|
||
|
||
def _split_csv(value: str | None) -> list[str]:
|
||
return [item.strip() for item in str(value or "").split(",") if item.strip()]
|
||
|
||
|
||
def _exec_event_payload(value: Any) -> Any:
|
||
if hasattr(value, "model_dump"):
|
||
return value.model_dump()
|
||
if isinstance(value, dict):
|
||
return value
|
||
if hasattr(value, "__dict__"):
|
||
return dict(value.__dict__)
|
||
return _json_safe(value)
|
||
|
||
|
||
def _print_exec_event(
|
||
event_state: dict[str, Any],
|
||
event_type: str,
|
||
*,
|
||
project_id: str,
|
||
task_id: str = "",
|
||
session_id: str = "",
|
||
payload: dict[str, Any] | None = None,
|
||
) -> None:
|
||
event_state["seq"] = int(event_state.get("seq", 0) or 0) + 1
|
||
row = {
|
||
"type": event_type,
|
||
"seq": event_state["seq"],
|
||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
"project_id": project_id,
|
||
"task_id": task_id,
|
||
"session_id": session_id,
|
||
"payload": payload or {},
|
||
}
|
||
sys.stdout.write(json.dumps(row, ensure_ascii=False, default=_json_safe) + "\n")
|
||
sys.stdout.flush()
|
||
|
||
|
||
def _exec_title(prompt: str) -> str:
|
||
for line in str(prompt or "").splitlines():
|
||
title = line.strip()
|
||
if title:
|
||
return title[:120]
|
||
return "Exec Session"
|
||
|
||
|
||
async def _task_id_for_session(services: Any, *, project_id: str, session_id: str) -> str:
|
||
engine = await services.context.engine_for_project(project_id)
|
||
store = getattr(engine, "store", None)
|
||
if not store or not session_id:
|
||
return ""
|
||
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
|
||
for task in tasks:
|
||
if str(getattr(task, "session_id", "") or "") == session_id:
|
||
return str(getattr(task, "id", "") or "")
|
||
return ""
|
||
|
||
|
||
async def _resolve_exec_session(
|
||
services: Any,
|
||
*,
|
||
project_id: str,
|
||
prompt: str,
|
||
mode: str,
|
||
company_profile: str,
|
||
preferred_agent: str | None,
|
||
org_id: str | None,
|
||
session_id: str | None,
|
||
resume: bool,
|
||
) -> dict[str, Any]:
|
||
from opc.plugins.office_ui.services import ServiceError
|
||
|
||
normalized_mode = str(mode or "task").strip().lower()
|
||
if normalized_mode == "custom":
|
||
normalized_mode = "org"
|
||
if normalized_mode not in {"task", "company", "org"}:
|
||
raise ServiceError("invalid_mode", "mode must be task, company, or org", {"mode": mode})
|
||
|
||
if session_id:
|
||
task_id = await _task_id_for_session(services, project_id=project_id, session_id=session_id)
|
||
if not task_id:
|
||
raise ServiceError("session_not_task_backed", "Session is not linked to a task-backed CLI/UI session", {"session_id": session_id})
|
||
detail = await services.session.detail(project_id=project_id, task_id=task_id, session_id=session_id, limit=1)
|
||
return {**detail.payload, "task_id": task_id, "session_id": session_id, "restored": True}
|
||
|
||
if resume:
|
||
starting = await services.session.resolve_starting_session(project_id=project_id)
|
||
payload = dict(starting.payload)
|
||
restored_session_id = str(payload.get("session_id", "") or "")
|
||
restored_task_id = str(payload.get("task_id", "") or "")
|
||
if restored_session_id and not restored_task_id:
|
||
restored_task_id = await _task_id_for_session(services, project_id=project_id, session_id=restored_session_id)
|
||
if not restored_task_id:
|
||
raise ServiceError("session_not_task_backed", "Latest session is not task-backed; create a new exec session without --resume", {"session_id": restored_session_id})
|
||
payload["task_id"] = restored_task_id
|
||
payload["session_id"] = restored_session_id
|
||
payload["restored"] = True
|
||
return payload
|
||
|
||
created = await services.session.create(
|
||
project_id=project_id,
|
||
title=_exec_title(prompt),
|
||
exec_mode=normalized_mode,
|
||
company_profile="custom" if normalized_mode == "org" else company_profile,
|
||
preferred_agent=preferred_agent,
|
||
org_id=org_id,
|
||
interface="cli_exec",
|
||
)
|
||
return dict(created.payload)
|
||
|
||
|
||
async def _exec_message(
|
||
*,
|
||
config: OPCConfig,
|
||
prompt: str,
|
||
project: str | None,
|
||
mode: str,
|
||
company_profile: str,
|
||
preferred_agent: str | None,
|
||
org_id: str | None,
|
||
session_id: str | None,
|
||
resume: bool,
|
||
json_output: bool,
|
||
stream_json: bool,
|
||
no_markdown: bool,
|
||
) -> None:
|
||
from opc.plugins.office_ui.services import ServiceError
|
||
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
|
||
|
||
project_id = str(project or "default").strip() or "default"
|
||
event_state: dict[str, Any] = {"seq": 0, "task_id": "", "session_id": ""}
|
||
|
||
async def on_progress(*args: Any, **kwargs: Any) -> None:
|
||
if not stream_json:
|
||
return
|
||
_print_exec_event(
|
||
event_state,
|
||
"runtime_update",
|
||
project_id=project_id,
|
||
task_id=str(event_state.get("task_id", "") or ""),
|
||
session_id=str(event_state.get("session_id", "") or ""),
|
||
payload={"args": [_exec_event_payload(arg) for arg in args], "kwargs": kwargs},
|
||
)
|
||
|
||
async def on_runtime_event(event: Any) -> None:
|
||
if not stream_json:
|
||
return
|
||
_print_exec_event(
|
||
event_state,
|
||
"runtime_update",
|
||
project_id=project_id,
|
||
task_id=str(event_state.get("task_id", "") or ""),
|
||
session_id=str(event_state.get("session_id", "") or ""),
|
||
payload=_exec_event_payload(event),
|
||
)
|
||
|
||
try:
|
||
async with OfficeServiceFactory(
|
||
config=config,
|
||
project_id=project_id,
|
||
on_progress=on_progress,
|
||
on_runtime_event=on_runtime_event,
|
||
on_escalation=_escalation_callback(),
|
||
) as services:
|
||
target = await _resolve_exec_session(
|
||
services,
|
||
project_id=project_id,
|
||
prompt=prompt,
|
||
mode=mode,
|
||
company_profile=company_profile,
|
||
preferred_agent=preferred_agent,
|
||
org_id=org_id,
|
||
session_id=session_id,
|
||
resume=resume,
|
||
)
|
||
task_id = str(target.get("task_id", "") or "")
|
||
resolved_session_id = str(target.get("session_id", "") or "")
|
||
event_state["task_id"] = task_id
|
||
event_state["session_id"] = resolved_session_id
|
||
if stream_json:
|
||
_print_exec_event(
|
||
event_state,
|
||
"session_created" if not target.get("restored") else "session_resumed",
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
session_id=resolved_session_id,
|
||
payload=target,
|
||
)
|
||
|
||
response = ""
|
||
if prompt:
|
||
normalized_mode = str(mode or "task").strip().lower()
|
||
send_mode = "company" if normalized_mode in {"company", "org", "custom"} else "task"
|
||
send_profile = "custom" if normalized_mode in {"org", "custom"} else company_profile
|
||
sent = await services.session.send(
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
content=prompt,
|
||
mode=send_mode,
|
||
company_profile=send_profile,
|
||
preferred_agent=preferred_agent,
|
||
)
|
||
response = str(sent.payload.get("response", "") or "")
|
||
if stream_json:
|
||
_print_exec_event(
|
||
event_state,
|
||
"message",
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
session_id=resolved_session_id,
|
||
payload={"role": "assistant", "content": response},
|
||
)
|
||
|
||
final_payload = {
|
||
"ok": True,
|
||
"project_id": project_id,
|
||
"task_id": task_id,
|
||
"session_id": resolved_session_id,
|
||
"mode": mode,
|
||
"company_profile": company_profile,
|
||
"response": response,
|
||
}
|
||
if stream_json:
|
||
_print_exec_event(
|
||
event_state,
|
||
"final",
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
session_id=resolved_session_id,
|
||
payload=final_payload,
|
||
)
|
||
return
|
||
if json_output:
|
||
console.print(json.dumps(final_payload, ensure_ascii=False, indent=2, default=_json_safe))
|
||
return
|
||
if response:
|
||
_print_response(response, no_markdown=no_markdown)
|
||
else:
|
||
_emit_payload(final_payload)
|
||
except ServiceError as exc:
|
||
payload = {"ok": False, **exc.to_payload()}
|
||
if stream_json:
|
||
_print_exec_event(event_state, "error", project_id=project_id, payload=payload)
|
||
elif json_output:
|
||
console.print(json.dumps(payload, ensure_ascii=False, indent=2, default=_json_safe))
|
||
else:
|
||
console.print(f"[error]{escape(exc.message)}[/error]")
|
||
raise typer.Exit(code=1) from exc
|
||
except ValueError as exc:
|
||
payload = {"ok": False, "code": "invalid_argument", "error": str(exc)}
|
||
if stream_json:
|
||
_print_exec_event(event_state, "error", project_id=project_id, payload=payload)
|
||
elif json_output:
|
||
console.print(json.dumps(payload, ensure_ascii=False, indent=2, default=_json_safe))
|
||
else:
|
||
console.print(f"[error]{escape(str(exc))}[/error]")
|
||
raise typer.Exit(code=2) from exc
|
||
|
||
|
||
project_app = typer.Typer(help="Manage OPC projects")
|
||
app.add_typer(project_app, name="project")
|
||
|
||
|
||
@project_app.command("list")
|
||
def project_list(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Active project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""List projects."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.list(active_project_id=project), json_output=json_output))
|
||
|
||
|
||
@project_app.command("show")
|
||
def project_show(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Show project index payload."""
|
||
target = project or "default"
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.project_index(target, include_snapshot=False), json_output=json_output))
|
||
|
||
|
||
@project_app.command("create")
|
||
def project_create(
|
||
project_id: str = typer.Argument(..., help="Project ID to create"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Active project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Create a project."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.create(project_id, active_project_id=project), json_output=json_output))
|
||
|
||
|
||
@project_app.command("switch")
|
||
def project_switch(
|
||
project_id: str = typer.Argument(..., help="Project ID to prepare/switch"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Current project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Prepare and validate switching to a project."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.switch(project_id, include_snapshot=False), json_output=json_output))
|
||
|
||
|
||
@project_app.command("rename")
|
||
def project_rename(
|
||
old_project_id: str = typer.Argument(..., help="Existing project ID"),
|
||
new_project_id: str = typer.Argument(..., help="New project ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Active project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Rename a project id and move its persisted project data."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.rename(old_project_id, new_project_id), json_output=json_output))
|
||
|
||
|
||
@project_app.command("delete")
|
||
def project_delete(
|
||
project_id: str = typer.Argument(..., help="Project ID to delete"),
|
||
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm deletion"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Active project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Delete a project and its persisted UI/runtime data."""
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.delete(project_id), json_output=json_output))
|
||
|
||
|
||
session_app = typer.Typer(help="Manage OPC sessions")
|
||
app.add_typer(session_app, name="session")
|
||
|
||
|
||
@session_app.command("list")
|
||
def session_list(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.list(project_id=project or "default", limit=limit), json_output=json_output))
|
||
|
||
|
||
@session_app.command("create")
|
||
def session_create(
|
||
title: str = typer.Argument("New Chat", help="Session title"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
mode: str = typer.Option("task", "--mode", help="task, company, or org"),
|
||
company_profile: str = typer.Option("corporate", "--company-profile", help="corporate or custom"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred task-mode agent"),
|
||
org_id: Optional[str] = typer.Option(None, "--org", "--org-id", help="Saved org id"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.create(project_id=project or "default", title=title, exec_mode=mode, company_profile=company_profile, preferred_agent=agent, org_id=org_id, interface="cli"), json_output=json_output))
|
||
|
||
|
||
@session_app.command("show")
|
||
def session_show(
|
||
target: str = typer.Argument(..., help="Task ID or session ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
limit: int = typer.Option(200, "--limit", "-n", help="Transcript limit"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.detail(project_id=project or "default", task_id=target, session_id=target, limit=limit), json_output=json_output))
|
||
|
||
|
||
@session_app.command("config")
|
||
def session_config(
|
||
task_id: str = typer.Argument(..., help="Task ID"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
mode: Optional[str] = typer.Option(None, "--mode", help="task, company, or org"),
|
||
company_profile: Optional[str] = typer.Option(None, "--company-profile", help="corporate or custom"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred task-mode agent"),
|
||
org_id: Optional[str] = typer.Option(None, "--org", "--org-id", help="Saved org id"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.update_config(project_id=project or "default", task_id=task_id, exec_mode=mode, company_profile=company_profile, preferred_agent=agent, org_id=org_id), json_output=json_output))
|
||
|
||
|
||
@session_app.command("send")
|
||
def session_send(
|
||
task_id: str = typer.Argument(..., help="Task ID"),
|
||
message: str = typer.Argument(..., help="Message to send"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
||
mode: str = typer.Option("task", "--mode", help="task or company"),
|
||
company_profile: str = typer.Option("corporate", "--company-profile", help="Company profile"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred agent"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.send(project_id=project or "default", task_id=task_id, content=message, mode=mode, company_profile=company_profile, preferred_agent=agent), json_output=json_output))
|
||
|
||
|
||
@session_app.command("rename")
|
||
def session_rename(
|
||
target: str = typer.Argument(..., help="Task ID or session ID"),
|
||
title: str = typer.Argument(...),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.rename(project_id=project or "default", task_id=target, session_id=target, title=title), json_output=json_output))
|
||
|
||
|
||
@session_app.command("delete")
|
||
def session_delete(
|
||
task_id: str = typer.Argument(...),
|
||
yes: bool = typer.Option(False, "--yes", "-y"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.delete(project_id=project or "default", task_id=task_id), json_output=json_output))
|
||
|
||
|
||
@session_app.command("stop")
|
||
def session_stop(target: str = typer.Argument(..., help="Task ID or session ID"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.stop(project_id=project or "default", target=target), json_output=json_output))
|
||
|
||
|
||
@session_app.command("continue")
|
||
def session_continue(
|
||
target: str = typer.Argument(..., help="Task ID or session ID"),
|
||
message: Optional[str] = typer.Argument(None, help="Optional resume instruction"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.continue_run(project_id=project or "default", target=target, content=message or ""), json_output=json_output))
|
||
|
||
|
||
@session_app.command("resume")
|
||
def session_resume(target: str = typer.Argument(..., help="Task ID or session ID"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.resume(project_id=project or "default", target=target), json_output=json_output))
|
||
|
||
|
||
@session_app.command("complete")
|
||
def session_complete(task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.session.complete(project_id=project or "default", task_id=task_id), json_output=json_output))
|
||
|
||
|
||
mode_app = typer.Typer(help="Manage default execution mode")
|
||
app.add_typer(mode_app, name="mode")
|
||
|
||
|
||
@mode_app.command("show")
|
||
def mode_show(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.mode_show(), json_output=json_output))
|
||
|
||
|
||
@mode_app.command("set")
|
||
def mode_set(
|
||
mode: str = typer.Argument(..., help="task, company, or org"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
profile: str = typer.Option("corporate", "--profile", "--company-profile"),
|
||
agent: Optional[str] = typer.Option(None, "--agent"),
|
||
org_id: Optional[str] = typer.Option(None, "--org", "--org-id"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.mode_set(mode=mode, profile=profile, preferred_agent=agent, org_id=org_id), json_output=json_output))
|
||
|
||
|
||
kanban_app = typer.Typer(help="Manage kanban views and tasks")
|
||
kanban_task_app = typer.Typer(help="Manage kanban tasks")
|
||
kanban_app.add_typer(kanban_task_app, name="task")
|
||
app.add_typer(kanban_app, name="kanban")
|
||
|
||
|
||
@kanban_task_app.command("create")
|
||
def kanban_task_create(title: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), description: str = typer.Option("", "--description", "-d"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.create_task(project_id=project or "default", title=title, description=description), json_output=json_output))
|
||
|
||
|
||
@kanban_task_app.command("update")
|
||
def kanban_task_update(task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), title: Optional[str] = typer.Option(None, "--title"), description: Optional[str] = typer.Option(None, "--description"), json_output: bool = typer.Option(False, "--json")):
|
||
updates = {k: v for k, v in {"title": title, "description": description}.items() if v is not None}
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.update_task(project_id=project or "default", task_id=task_id, updates=updates), json_output=json_output))
|
||
|
||
|
||
@kanban_task_app.command("move")
|
||
def kanban_task_move(task_id: str = typer.Argument(...), column: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.move_task(project_id=project or "default", task_id=task_id, column_id=column), json_output=json_output))
|
||
|
||
|
||
@kanban_task_app.command("delete")
|
||
def kanban_task_delete(task_id: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.delete_task(project_id=project or "default", task_id=task_id), json_output=json_output))
|
||
|
||
|
||
@kanban_task_app.command("assign")
|
||
def kanban_task_assign(task_id: str = typer.Argument(...), agent_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.assign(project_id=project or "default", task_id=task_id, agent_id=agent_id), json_output=json_output))
|
||
|
||
|
||
@kanban_task_app.command("status")
|
||
def kanban_task_status(task_id: str = typer.Argument(...), status: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.kanban.status(project_id=project or "default", task_id=task_id, status=status), json_output=json_output))
|
||
|
||
|
||
@kanban_app.command("view")
|
||
def kanban_view(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.project.project_index(project or "default", include_snapshot=False), json_output=json_output))
|
||
|
||
|
||
agent_app = typer.Typer(help="Manage UI agents")
|
||
app.add_typer(agent_app, name="agent")
|
||
|
||
|
||
@agent_app.command("list")
|
||
def agent_list(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.list(), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("create")
|
||
def agent_create(name: str = typer.Argument(...), role_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), office_id: str = typer.Option("office-0", "--office"), description: str = typer.Option("", "--description"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.create(name=name, role_id=role_id, office_id=office_id, description=description), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("create-from-template")
|
||
def agent_create_from_template(template_id: str = typer.Argument(...), role_id: Optional[str] = typer.Option(None, "--role", "--role-id"), project: Optional[str] = typer.Option(None, "--project", "-p"), office_id: str = typer.Option("office-0", "--office"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.create_from_template(template_id=template_id, role_id=role_id or template_id, office_id=office_id), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("import-employee")
|
||
def agent_import_employee(employee_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), office_id: str = typer.Option("office-0", "--office"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.import_employee(employee_id=employee_id, office_id=office_id), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("detail")
|
||
def agent_detail(agent_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.detail(project_id=project or "default", agent_id=agent_id), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("delete")
|
||
def agent_delete(agent_id: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.delete(agent_id), json_output=json_output))
|
||
|
||
|
||
@agent_app.command("move")
|
||
def agent_move(agent_id: str = typer.Argument(...), office_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), seat_zone: Optional[str] = typer.Option(None, "--seat-zone"), desk_id: Optional[str] = typer.Option(None, "--desk"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.agent.move(agent_id=agent_id, office_id=office_id, seat_zone=seat_zone, desk_id=desk_id), json_output=json_output))
|
||
|
||
|
||
org_app = typer.Typer(help="Manage organization configuration")
|
||
app.add_typer(org_app, name="org")
|
||
|
||
|
||
@org_app.command("info")
|
||
def org_info(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.info(), json_output=json_output))
|
||
|
||
|
||
@org_app.command("export")
|
||
def org_export(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(True, "--json/--no-json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.export_config(), json_output=json_output))
|
||
|
||
|
||
@org_app.command("import")
|
||
def org_import(
|
||
path: str = typer.Argument(..., help="YAML or JSON org config payload"),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Validate and preview without applying"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
raw = Path(path).read_text(encoding="utf-8")
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.import_config(raw, dry_run=dry_run), json_output=json_output))
|
||
|
||
|
||
org_saved_app = typer.Typer(help="Manage saved org architectures")
|
||
org_app.add_typer(org_saved_app, name="saved")
|
||
|
||
|
||
@org_saved_app.command("list")
|
||
def org_saved_list(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.saved_list(), json_output=json_output))
|
||
|
||
|
||
@org_saved_app.command("save")
|
||
def org_saved_save(name: str = typer.Argument(...), overwrite: bool = typer.Option(False, "--overwrite"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.saved_save_as(name, overwrite=overwrite), json_output=json_output))
|
||
|
||
|
||
@org_saved_app.command("load")
|
||
def org_saved_load(name: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.saved_load(name), json_output=json_output))
|
||
|
||
|
||
@org_saved_app.command("delete")
|
||
def org_saved_delete(name: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.saved_delete(name), json_output=json_output))
|
||
|
||
|
||
org_role_app = typer.Typer(help="Manage organization roles")
|
||
org_app.add_typer(org_role_app, name="role")
|
||
|
||
|
||
@org_role_app.command("add")
|
||
def org_role_add(role_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), name: Optional[str] = typer.Option(None, "--name"), responsibility: str = typer.Option("", "--responsibility"), reports_to: str = typer.Option("owner", "--reports-to"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.add_role({"role_id": role_id, "name": name or role_id, "responsibility": responsibility, "reports_to": reports_to}), json_output=json_output))
|
||
|
||
|
||
@org_role_app.command("update")
|
||
def org_role_update(
|
||
role_id: str = typer.Argument(...),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
||
name: Optional[str] = typer.Option(None, "--name"),
|
||
responsibility: Optional[str] = typer.Option(None, "--responsibility"),
|
||
reports_to: Optional[str] = typer.Option(None, "--reports-to"),
|
||
can_spawn: Optional[str] = typer.Option(None, "--can-spawn", help="Comma-separated role ids"),
|
||
tools: Optional[str] = typer.Option(None, "--tools", help="Comma-separated tool names"),
|
||
agent: Optional[str] = typer.Option(None, "--agent", help="Preferred external agent"),
|
||
json_output: bool = typer.Option(False, "--json"),
|
||
):
|
||
updates = {
|
||
key: value
|
||
for key, value in {
|
||
"name": name,
|
||
"responsibility": responsibility,
|
||
"reports_to": reports_to,
|
||
"preferred_external_agent": agent,
|
||
}.items()
|
||
if value is not None
|
||
}
|
||
if can_spawn is not None:
|
||
updates["can_spawn"] = _split_csv(can_spawn)
|
||
if tools is not None:
|
||
updates["tools"] = _split_csv(tools)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.update_role(role_id, updates), json_output=json_output))
|
||
|
||
|
||
@org_role_app.command("bulk-add")
|
||
def org_role_bulk_add(path: str = typer.Argument(..., help="JSON/YAML list of role objects or object with roles"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
loaded = _load_structured_payload(file_path=path)
|
||
roles = loaded.get("roles", []) if isinstance(loaded, dict) else loaded if isinstance(loaded, list) else []
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.bulk_add_roles(list(roles or [])), json_output=json_output))
|
||
|
||
|
||
@org_role_app.command("delete")
|
||
def org_role_delete(role_id: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.delete_role(role_id), json_output=json_output))
|
||
|
||
|
||
org_policy_app = typer.Typer(help="Manage organization runtime policy")
|
||
org_app.add_typer(org_policy_app, name="policy")
|
||
|
||
|
||
@org_policy_app.command("update")
|
||
def org_policy_update(payload: Optional[str] = typer.Option(None, "--payload", help="JSON/YAML object"), file_path: Optional[str] = typer.Option(None, "--file", "-f"), profile: str = typer.Option("custom", "--profile"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
data = _load_structured_payload(payload=payload, file_path=file_path)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.update_runtime_policy(data, profile=profile), json_output=json_output))
|
||
|
||
|
||
org_strategy_app = typer.Typer(help="Manage organization strategy")
|
||
org_app.add_typer(org_strategy_app, name="strategy")
|
||
|
||
|
||
@org_strategy_app.command("update")
|
||
def org_strategy_update(final_decider_role_id: Optional[str] = typer.Option(None, "--final-decider", "--final-decider-role-id"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.update_org_strategy(final_decider_role_id=final_decider_role_id), json_output=json_output))
|
||
|
||
|
||
@org_app.command("reset")
|
||
def org_reset(yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.org.reset_architecture(), json_output=json_output))
|
||
|
||
|
||
runtime_app = typer.Typer(help="Inspect and control runtime state")
|
||
app.add_typer(runtime_app, name="runtime")
|
||
|
||
|
||
@runtime_app.command("status")
|
||
def runtime_status(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.status(project_id=project or "default"), json_output=json_output))
|
||
|
||
|
||
@runtime_app.command("checkpoints")
|
||
def runtime_checkpoints(project: Optional[str] = typer.Option(None, "--project", "-p"), limit: int = typer.Option(50, "--limit", "-n"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.checkpoints(project_id=project or "default", limit=limit), json_output=json_output))
|
||
|
||
|
||
@runtime_app.command("logs")
|
||
def runtime_logs(task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), limit: int = typer.Option(100, "--limit", "-n"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.logs(project_id=project or "default", task_id=task_id, limit=limit), json_output=json_output))
|
||
|
||
|
||
@runtime_app.command("run")
|
||
def runtime_run(task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.run_task(project_id=project or "default", task_id=task_id), json_output=json_output))
|
||
|
||
|
||
recovery_app = typer.Typer(help="Recover interrupted company runtimes")
|
||
app.add_typer(recovery_app, name="recovery")
|
||
|
||
|
||
@recovery_app.command("scan")
|
||
def recovery_scan(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_scan(project_id=project or "default"), json_output=json_output))
|
||
|
||
|
||
@recovery_app.command("resume")
|
||
def recovery_resume(parent_task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="resume", parent_task_id=parent_task_id), json_output=json_output))
|
||
|
||
|
||
@recovery_app.command("cancel")
|
||
def recovery_cancel(parent_task_id: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y"), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="cancel", parent_task_id=parent_task_id), json_output=json_output))
|
||
|
||
|
||
@recovery_app.command("retry")
|
||
def recovery_retry(parent_task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.runtime.recovery_action(project_id=project or "default", action="retry", parent_task_id=parent_task_id), json_output=json_output))
|
||
|
||
|
||
comms_app = typer.Typer(help="Inspect company-mode comms")
|
||
app.add_typer(comms_app, name="comms")
|
||
|
||
|
||
@comms_app.command("state")
|
||
def comms_state(task_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.comms.state(project_id=project or "default", task_id=task_id), json_output=json_output))
|
||
|
||
|
||
@comms_app.command("read")
|
||
def comms_read(task_id: str = typer.Argument(...), path: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.comms.read(project_id=project or "default", task_id=task_id, path=path), json_output=json_output))
|
||
|
||
|
||
work_item_app = typer.Typer(help="Inspect company-mode work items")
|
||
app.add_typer(work_item_app, name="work-item")
|
||
|
||
|
||
@work_item_app.command("list")
|
||
def work_item_list(project: Optional[str] = typer.Option(None, "--project", "-p"), role_id: Optional[str] = typer.Option(None, "--role", "--role-id"), status: Optional[str] = typer.Option(None, "--status"), limit: int = typer.Option(100, "--limit", "-n"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.work_item.list(project_id=project or "default", role_id=role_id, status=status, limit=limit), json_output=json_output))
|
||
|
||
|
||
@work_item_app.command("show")
|
||
def work_item_show(work_item_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), limit: int = typer.Option(100, "--limit", "-n"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.work_item.show(project_id=project or "default", work_item_id=work_item_id, limit=limit), json_output=json_output))
|
||
|
||
|
||
@work_item_app.command("logs")
|
||
def work_item_logs(work_item_id: str = typer.Argument(""), role_id: Optional[str] = typer.Option(None, "--role", "--role-id"), project: Optional[str] = typer.Option(None, "--project", "-p"), limit: int = typer.Option(100, "--limit", "-n"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.work_item.logs(project_id=project or "default", work_item_id=work_item_id, role_id=role_id or "", limit=limit), json_output=json_output))
|
||
|
||
|
||
@work_item_app.command("role-status")
|
||
def work_item_role_status(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.work_item.status_by_role(project_id=project or "default"), json_output=json_output))
|
||
|
||
|
||
channels_app = typer.Typer(help="Manage external messaging channels")
|
||
app.add_typer(channels_app, name="channels")
|
||
|
||
talent_app = typer.Typer(help="Manage recruitable talent templates and employees")
|
||
app.add_typer(talent_app, name="talent")
|
||
|
||
|
||
@talent_app.command("list")
|
||
def talent_list(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""List imported talent templates."""
|
||
config = _get_config()
|
||
market = TalentMarket(get_opc_home(), config)
|
||
templates = market.list_templates()
|
||
if json_output:
|
||
_emit_payload({"templates": templates}, json_output=True)
|
||
return
|
||
if not templates:
|
||
console.print("[warning]No imported talent templates found.[/warning]")
|
||
return
|
||
console.print(f"[bold]Talent Templates ({len(templates)}):[/bold]")
|
||
for template in templates:
|
||
domains = ", ".join(template.domains[:4]) if template.domains else "general"
|
||
console.print(
|
||
f" - [bold]{template.id}[/bold] :: {template.name} "
|
||
f"[{template.category}] domains={domains}"
|
||
)
|
||
|
||
|
||
@talent_app.command("employees")
|
||
def talent_employees(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""List hired employees."""
|
||
config = _get_config()
|
||
market = TalentMarket(get_opc_home(), config)
|
||
employees = market.list_employees()
|
||
if json_output:
|
||
_emit_payload({"employees": employees}, json_output=True)
|
||
return
|
||
if not employees:
|
||
console.print("[warning]No employees hired yet.[/warning]")
|
||
return
|
||
console.print(f"[bold]Employees ({len(employees)}):[/bold]")
|
||
for employee in employees:
|
||
domains = ", ".join(employee.domains[:4]) if employee.domains else "general"
|
||
console.print(
|
||
f" - [bold]{employee.employee_id}[/bold] :: {employee.name} "
|
||
f"role={employee.role_id} domains={domains}"
|
||
)
|
||
|
||
|
||
@talent_app.command("import")
|
||
def talent_import(
|
||
repo_path: str = typer.Argument(..., help="Local path to the agency-agents repository"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Import local agency-agent markdown files into recruitable talent templates."""
|
||
config = _get_config()
|
||
market = TalentMarket(get_opc_home(), config)
|
||
imported = market.import_from_repo(_talent_repo_path(repo_path))
|
||
config.save(get_opc_home() / "config")
|
||
if json_output:
|
||
_emit_payload({"count": len(imported), "imported": imported}, json_output=True)
|
||
return
|
||
console.print(f"[success]Imported {len(imported)} talent templates.[/success]")
|
||
|
||
|
||
@talent_app.command("hire")
|
||
def talent_hire(
|
||
template_id: str = typer.Argument(..., help="Imported talent template id"),
|
||
role_id: str = typer.Argument(..., help="Company role id to staff"),
|
||
employee_name: Optional[str] = typer.Option(None, "--name", help="Optional hired employee display name"),
|
||
employee_id: Optional[str] = typer.Option(None, "--employee-id", help="Optional employee id override"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Hire an imported template into a company role."""
|
||
config = _get_config()
|
||
market = TalentMarket(get_opc_home(), config)
|
||
employee = market.hire_template(
|
||
template_id,
|
||
role_id,
|
||
employee_name=employee_name,
|
||
employee_id=employee_id,
|
||
)
|
||
config.save(get_opc_home() / "config")
|
||
if json_output:
|
||
_emit_payload({"employee": employee}, json_output=True)
|
||
return
|
||
console.print(
|
||
f"[success]Hired {employee.name} into role `{employee.role_id}` "
|
||
f"as `{employee.employee_id}`.[/success]"
|
||
)
|
||
|
||
|
||
@talent_app.command("scan")
|
||
def talent_scan(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Scan local talent templates."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.scan(), json_output=json_output))
|
||
|
||
|
||
@talent_app.command("import-selected")
|
||
def talent_import_selected(template_ids: list[str] = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Import selected local talent templates."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.import_selected(template_ids), json_output=json_output))
|
||
|
||
|
||
@talent_app.command("employee-detail")
|
||
def talent_employee(employee_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Show employee detail."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.employee_detail(employee_id), json_output=json_output))
|
||
|
||
|
||
@talent_app.command("import-agent")
|
||
def talent_import_agent(employee_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Import an employee as a visual office agent."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.import_employee_as_agent(employee_id=employee_id), json_output=json_output))
|
||
|
||
|
||
talent_employee_app = typer.Typer(help="Manage hired employees")
|
||
talent_app.add_typer(talent_employee_app, name="employee")
|
||
|
||
|
||
@talent_employee_app.command("detail")
|
||
def talent_employee_detail(employee_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.employee_detail(employee_id), json_output=json_output))
|
||
|
||
|
||
@talent_employee_app.command("import-agent")
|
||
def talent_employee_import_agent(employee_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), office_id: str = typer.Option("office-0", "--office"), json_output: bool = typer.Option(False, "--json")):
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.talent.import_employee_as_agent(employee_id=employee_id, office_id=office_id), json_output=json_output))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Market subcommands
|
||
# ---------------------------------------------------------------------------
|
||
market_app = typer.Typer(help="OPC Market — export, import, and manage architecture packages")
|
||
app.add_typer(market_app, name="market")
|
||
|
||
|
||
def _render_architecture_presets(presets: list[Any]) -> None:
|
||
if not presets:
|
||
console.print("[warning]No architecture presets available.[/warning]")
|
||
return
|
||
table = Table(title=f"Architecture Presets ({len(presets)})")
|
||
table.add_column("ID", style="cyan", no_wrap=True)
|
||
table.add_column("Name")
|
||
table.add_column("Category")
|
||
table.add_column("Roles", justify="right")
|
||
table.add_column("Templates", justify="right")
|
||
table.add_column("Pattern")
|
||
for preset in presets:
|
||
table.add_row(
|
||
str(getattr(preset, "id", "") or ""),
|
||
str(getattr(preset, "name", "") or ""),
|
||
str(getattr(preset, "category", "") or ""),
|
||
str(len(getattr(preset, "roles", []) or [])),
|
||
str(len(getattr(preset, "work_item_templates", []) or [])),
|
||
str(getattr(preset, "collaboration_pattern", "") or ""),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
@market_app.command("export")
|
||
def market_export(
|
||
package_id: str = typer.Option(..., "--id", help="Package identifier (slug)"),
|
||
name: str = typer.Option(..., "--name", help="Display name"),
|
||
description: str = typer.Option("", "--desc", help="Package description"),
|
||
version: str = typer.Option("1.0.0", "--version", help="Semantic version"),
|
||
output_dir: str = typer.Option(".", "--output-dir", "-o", help="Output directory"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Export the current org as an .opcpkg package."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.export(package_id=package_id, name=name, description=description, version=version, output_dir=output_dir), json_output=json_output))
|
||
|
||
|
||
@market_app.command("browse")
|
||
def market_browse(project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Browse market architecture presets."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.browse(), json_output=json_output))
|
||
|
||
|
||
@market_app.command("preview")
|
||
def market_preview(preset_id: str = typer.Argument(...), project: Optional[str] = typer.Option(None, "--project", "-p"), json_output: bool = typer.Option(False, "--json")):
|
||
"""Preview a built-in architecture preset."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.preview(preset_id), json_output=json_output))
|
||
|
||
|
||
@market_app.command("presets")
|
||
def market_presets(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""List built-in architecture presets."""
|
||
from opc.market.architecture_registry import get_all_presets
|
||
|
||
if json_output:
|
||
_emit_payload({"presets": get_all_presets()}, json_output=True)
|
||
return
|
||
_render_architecture_presets(get_all_presets())
|
||
|
||
|
||
@market_app.command("apply-preset")
|
||
def market_apply_preset(
|
||
preset_id: str = typer.Argument(..., help="Built-in architecture preset id"),
|
||
strategy: str = typer.Option("overwrite", "--strategy", "-s", help="Role id strategy: namespace or overwrite"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Apply a built-in architecture preset as the active custom organization."""
|
||
if strategy not in {"namespace", "overwrite"}:
|
||
console.print("[error]Strategy must be namespace or overwrite.[/error]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.apply_preset(preset_id=preset_id, strategy=strategy), json_output=json_output))
|
||
|
||
|
||
@market_app.command("install")
|
||
def market_install(
|
||
path: str = typer.Argument(..., help="Path to .opcpkg directory"),
|
||
strategy: str = typer.Option("namespace", "--strategy", "-s", help="Conflict strategy: namespace or overwrite"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Install an .opcpkg package from a local path."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.install(path=path, strategy=strategy), json_output=json_output))
|
||
|
||
|
||
@market_app.command("list")
|
||
def market_list(
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""List installed OPC Market packages."""
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.list_installed(), json_output=json_output))
|
||
|
||
|
||
@market_app.command("uninstall")
|
||
def market_uninstall(
|
||
package_id: str = typer.Argument(..., help="Package id to uninstall"),
|
||
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm uninstall"),
|
||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project context"),
|
||
json_output: bool = typer.Option(False, "--json", help="Print JSON"),
|
||
):
|
||
"""Uninstall an installed OPC Market package."""
|
||
if not yes:
|
||
console.print("[warning]Destructive command requires --yes.[/warning]")
|
||
raise typer.Exit(code=1)
|
||
asyncio.run(_run_service_command(project, lambda svc: svc.market.uninstall(package_id), json_output=json_output))
|
||
|
||
|
||
@channels_app.command("status")
|
||
def channels_status():
|
||
"""Show configured channel status."""
|
||
config = _get_config()
|
||
from opc.channels.manager import ChannelManager
|
||
from opc.channels.provider_registry import ordered_provider_specs
|
||
from opc.layer0_interaction.message_bus import MessageBus
|
||
|
||
runtime_state = _read_channel_runtime_state()
|
||
runtime_running = False
|
||
runtime_pid = None
|
||
runtime_channels: list[str] = []
|
||
if runtime_state:
|
||
runtime_pid = int(runtime_state.get("pid", 0) or 0)
|
||
runtime_channels = list(runtime_state.get("channels", []) or [])
|
||
runtime_running = runtime_pid > 0 and _pid_is_running(runtime_pid)
|
||
if runtime_pid > 0 and not runtime_running:
|
||
_clear_channel_runtime_state()
|
||
|
||
manager = ChannelManager(config, MessageBus())
|
||
console.print(f"[bold]Runtime:[/bold] {'running' if runtime_running else 'stopped'}")
|
||
if runtime_pid:
|
||
console.print(f" PID: {runtime_pid}")
|
||
console.print("[bold]Channels:[/bold]")
|
||
for spec in ordered_provider_specs():
|
||
if hasattr(manager, "get_status"):
|
||
status = manager.get_status(spec.name)
|
||
else:
|
||
channel = manager.get_channel(spec.name)
|
||
capability = channel.describe_capability() if channel is not None else {}
|
||
status = {
|
||
"name": spec.name,
|
||
"enabled": bool(getattr(getattr(config.channels, spec.name), "enabled", False)),
|
||
"available": capability.get("available", channel is not None),
|
||
"configured": capability.get("configured", channel is not None),
|
||
"delivery_mode": capability.get("delivery_mode", spec.delivery_mode),
|
||
"bridge_required": spec.bridge_required,
|
||
"last_error": capability.get("last_error", ""),
|
||
}
|
||
details: list[str] = ["enabled" if status["enabled"] else "disabled"]
|
||
if status["enabled"]:
|
||
details.append("available" if status["available"] else "dependency-missing")
|
||
details.append("configured" if status["configured"] else "config-missing")
|
||
if status["bridge_required"]:
|
||
details.append("bridge-required")
|
||
if status["delivery_mode"]:
|
||
details.append(str(status["delivery_mode"]))
|
||
if runtime_running and spec.name in runtime_channels:
|
||
details.append("runtime-active")
|
||
if status["last_error"]:
|
||
details.append(f"error={status['last_error']}")
|
||
console.print(f" - {spec.name}: {', '.join(details)}")
|
||
|
||
|
||
@channels_app.command("start")
|
||
def channels_start(project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID")):
|
||
"""Start enabled channels in foreground."""
|
||
config = _get_config()
|
||
asyncio.run(_run_channel_runtime(config, project))
|
||
|
||
|
||
@channels_app.command("stop")
|
||
def channels_stop():
|
||
"""Stop foreground channel runtime."""
|
||
runtime_state = _read_channel_runtime_state()
|
||
if not runtime_state:
|
||
console.print("[warning]No running channel runtime found.[/warning]")
|
||
return
|
||
|
||
pid = int(runtime_state.get("pid", 0) or 0)
|
||
if pid <= 0 or not _pid_is_running(pid):
|
||
_clear_channel_runtime_state()
|
||
console.print("[warning]Channel runtime is not running.[/warning]")
|
||
return
|
||
|
||
os.kill(pid, signal.SIGTERM)
|
||
console.print(f"[success]Sent stop signal to channel runtime PID {pid}.[/success]")
|
||
|
||
|
||
@channels_app.command("login")
|
||
def channels_login(channel: Optional[str] = typer.Argument(None, help="Optional channel name")):
|
||
"""Show login/setup guidance for channels."""
|
||
from opc.channels.provider_registry import PROVIDER_SPECS, ordered_provider_specs
|
||
|
||
if channel:
|
||
spec = PROVIDER_SPECS.get(channel)
|
||
if spec is None:
|
||
console.print(f"[warning]Unknown channel `{channel}`.[/warning]")
|
||
return
|
||
requirements = ", ".join(spec.required_config_fields) if spec.required_config_fields else "no required config fields"
|
||
bridge = " Requires a companion bridge/runtime." if spec.bridge_required else ""
|
||
console.print(
|
||
f"[info]Configure `{channel}` in `.opc/config/channel_config.yaml`. "
|
||
f"{escape(spec.login_summary)} Install extras with `{escape(f'pip install -e .[{spec.extra_name}]')}`. "
|
||
f"Required config: {escape(requirements)}.{bridge} Then run `opc channels start`.[/info]"
|
||
)
|
||
return
|
||
console.print("[info]Set channel credentials in .opc/config/channel_config.yaml and start the runtime with `opc channels start` or `opc run`.[/info]")
|
||
for spec in ordered_provider_specs():
|
||
bridge = " bridge-required;" if spec.bridge_required else ""
|
||
console.print(
|
||
f" - {spec.name}: install `.[{spec.extra_name}]`; required fields: "
|
||
f"{', '.join(spec.required_config_fields) or 'none'}; mode: {spec.delivery_mode};{bridge} {spec.login_summary}"
|
||
)
|
||
|
||
|
||
@app.command()
|
||
def run(project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID")):
|
||
"""Run the long-lived engine + channel runtime in foreground."""
|
||
config = _get_config()
|
||
asyncio.run(_run_channel_runtime(config, project))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Async helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def _single_message(
|
||
config,
|
||
message: str,
|
||
project: str | None,
|
||
no_markdown: bool,
|
||
*,
|
||
mode: str = "task",
|
||
preferred_agent: str | None = None,
|
||
company_profile: str = "corporate",
|
||
) -> None:
|
||
engine, runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
console.print(f"\n[info]Processing:[/info] {message}\n")
|
||
runtime_display.begin_turn()
|
||
response = await engine.process_message(
|
||
message,
|
||
project_id=project,
|
||
session_id=str(uuid.uuid4()),
|
||
mode=mode,
|
||
company_profile=company_profile if mode == "company" else None,
|
||
preferred_agent=preferred_agent,
|
||
)
|
||
await runtime_display.flush()
|
||
if not runtime_display.has_streamed_content:
|
||
_print_response(response, no_markdown)
|
||
except KeyboardInterrupt:
|
||
console.print("\n[warning]Interrupted.[/warning]")
|
||
except Exception as e:
|
||
console.print(f"\n[error]Error: {escape(str(e))}[/error]")
|
||
if config.system.log_level == "DEBUG":
|
||
import traceback
|
||
console.print(escape(traceback.format_exc()))
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
async def _single_secretary_message(config, message: str, project: str | None, no_markdown: bool) -> None:
|
||
engine, runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
console.print(f"\n[info]Secretary processing:[/info] {message}\n")
|
||
runtime_display.begin_turn()
|
||
payload = await engine.process_secretary_message(
|
||
message,
|
||
project_id=project,
|
||
session_id=str(uuid.uuid4()),
|
||
)
|
||
await runtime_display.flush()
|
||
if not runtime_display.has_streamed_content:
|
||
_print_response(payload.get("response", ""), no_markdown)
|
||
except KeyboardInterrupt:
|
||
console.print("\n[warning]Interrupted.[/warning]")
|
||
except Exception as e:
|
||
console.print(f"\n[error]Error: {escape(str(e))}[/error]")
|
||
if config.system.log_level == "DEBUG":
|
||
import traceback
|
||
console.print(escape(traceback.format_exc()))
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
async def _propose_reorg(config, payload: str, project: str | None) -> None:
|
||
engine, _runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
data = json.loads(payload)
|
||
proposal = await engine.propose_company_reorg(
|
||
summary=str(data.get("summary", "Runtime company reorg")),
|
||
rationale=str(data.get("rationale", data.get("summary", ""))),
|
||
title=str(data.get("title", "")),
|
||
changeset=data.get("changeset", {}),
|
||
session_id=str(uuid.uuid4()),
|
||
task_id=data.get("task_id"),
|
||
initiated_by=str(data.get("initiated_by", "owner")),
|
||
source_role_id=str(data.get("source_role_id", "")),
|
||
metadata={"source": "cli"},
|
||
)
|
||
console.print(f"[success]Created reorg proposal:[/success] {proposal.proposal_id}")
|
||
console.print(f"Status: {proposal.status.value}")
|
||
console.print(f"Scope: {proposal.scope.value}")
|
||
console.print(f"Risk: {proposal.risk_level.value}")
|
||
console.print(f"Summary: {proposal.summary}")
|
||
if proposal.user_confirmation_required:
|
||
await engine._save_reorg_checkpoint(proposal) # noqa: SLF001
|
||
console.print("[warning]User confirmation is required before applying this reorg.[/warning]")
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
async def _approve_reorg(config, proposal_id: str, project: str | None, *, approved: bool) -> None:
|
||
engine, _runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
proposal = await engine.approve_company_reorg(
|
||
proposal_id,
|
||
approved=approved,
|
||
notes=f"{'Approved' if approved else 'Denied'} via CLI.",
|
||
)
|
||
console.print(f"[success]Updated reorg:[/success] {proposal.proposal_id}")
|
||
console.print(f"Status: {proposal.status.value}")
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
async def _apply_reorg(config, proposal_id: str, project: str | None) -> None:
|
||
engine, _runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
result = await engine.apply_company_reorg(proposal_id)
|
||
console.print(f"[success]Applied reorg:[/success] {proposal_id}")
|
||
console.print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
async def _show_reorg(config, proposal_id: str, project: str | None) -> None:
|
||
engine, _runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
proposal = await engine.show_company_reorg(proposal_id)
|
||
if not proposal:
|
||
console.print(f"[warning]Unknown reorg proposal:[/warning] {proposal_id}")
|
||
return
|
||
console.print(json.dumps({
|
||
"proposal_id": proposal.proposal_id,
|
||
"status": proposal.status.value,
|
||
"scope": proposal.scope.value,
|
||
"risk_level": proposal.risk_level.value,
|
||
"summary": proposal.summary,
|
||
"rationale": proposal.rationale,
|
||
"impact_summary": proposal.impact_summary,
|
||
"approval_notes": proposal.approval_notes,
|
||
"metadata": proposal.metadata,
|
||
}, ensure_ascii=False, indent=2))
|
||
finally:
|
||
await engine.shutdown()
|
||
|
||
|
||
_SLASH_DEFAULT_LIMIT = 20
|
||
_SLASH_MAX_LIMIT = 100
|
||
_VALID_CHAT_MODES = {"task", "company"}
|
||
_VALID_COMPANY_PROFILES = {"corporate", "custom"}
|
||
_VALID_PREFERRED_AGENTS = {"native", "codex", "claude_code", "cursor", "opencode"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SlashCommandSpec:
|
||
group: str
|
||
command: str
|
||
description: str
|
||
subcommands: tuple[str, ...] = ()
|
||
|
||
|
||
@dataclass
|
||
class _InteractiveChatState:
|
||
config: OPCConfig
|
||
engine: Any
|
||
runtime_display: Any
|
||
session_id: str
|
||
no_markdown: bool
|
||
mode: str = "task"
|
||
company_profile: str = "corporate"
|
||
org_id: str = ""
|
||
preferred_agent: str | None = "native"
|
||
domains: list[str] = field(default_factory=list)
|
||
company_staffing_drafts: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||
session_to_task: dict[str, str] = field(default_factory=dict)
|
||
active_runtime_children: dict[str, str] = field(default_factory=dict)
|
||
runtime_control_state: str = ""
|
||
runtime_control_task_id: str = ""
|
||
runtime_control_session_id: str = ""
|
||
runtime_control_checkpoint_id: str = ""
|
||
|
||
|
||
class BusyCommandPolicy(str, Enum):
|
||
IMMEDIATE_READONLY = "immediate_readonly"
|
||
BLOCKED_WHEN_BUSY = "blocked_when_busy"
|
||
NORMAL_WHEN_IDLE = "normal_when_idle"
|
||
|
||
|
||
@dataclass
|
||
class QueuedChatInput:
|
||
text: str
|
||
project_id: str
|
||
session_id: str
|
||
mode: str
|
||
company_profile: str
|
||
preferred_agent: str | None
|
||
domains: list[str]
|
||
org_id: str = ""
|
||
message_metadata: dict[str, Any] | None = None
|
||
allow_interactive_followups: bool = False
|
||
enqueue_time: float = field(default_factory=time.time)
|
||
|
||
|
||
class ChatTurnController:
|
||
"""Keep interactive chat responsive while a turn is running."""
|
||
|
||
def __init__(self, state: _InteractiveChatState) -> None:
|
||
self.state = state
|
||
self.queue: deque[QueuedChatInput] = deque()
|
||
self.active_task: asyncio.Task[None] | None = None
|
||
self.kanban_watch_task: asyncio.Task[None] | None = None
|
||
self._kanban_watch_fingerprint = ""
|
||
self._lock: asyncio.Lock | None = None
|
||
self._closing = False
|
||
|
||
@property
|
||
def is_busy(self) -> bool:
|
||
return self.active_task is not None and not self.active_task.done()
|
||
|
||
def queue_depth(self) -> int:
|
||
return len(self.queue)
|
||
|
||
def kanban_watch_active(self) -> bool:
|
||
return self.kanban_watch_task is not None and not self.kanban_watch_task.done()
|
||
|
||
def queued_items(self) -> list[QueuedChatInput]:
|
||
return list(self.queue)
|
||
|
||
async def submit_user_message(self, text: str) -> None:
|
||
item = QueuedChatInput(
|
||
text=text,
|
||
project_id=_current_project_id(self.state.engine),
|
||
session_id=self.state.session_id,
|
||
mode=self.state.mode,
|
||
company_profile=self.state.company_profile,
|
||
org_id=self.state.org_id,
|
||
preferred_agent=self.state.preferred_agent,
|
||
domains=list(self.state.domains),
|
||
)
|
||
await self.submit_item(item)
|
||
|
||
async def submit_control_message(
|
||
self,
|
||
text: str,
|
||
*,
|
||
message_metadata: dict[str, Any],
|
||
mode: str | None = None,
|
||
company_profile: str | None = None,
|
||
org_id: str | None = None,
|
||
preferred_agent: str | None = None,
|
||
domains: list[str] | None = None,
|
||
) -> None:
|
||
item = QueuedChatInput(
|
||
text=text,
|
||
project_id=_current_project_id(self.state.engine),
|
||
session_id=self.state.session_id,
|
||
mode=mode or self.state.mode,
|
||
company_profile=company_profile or self.state.company_profile,
|
||
org_id=self.state.org_id if org_id is None else org_id,
|
||
preferred_agent=self.state.preferred_agent if preferred_agent is None else preferred_agent,
|
||
domains=list(self.state.domains if domains is None else domains),
|
||
message_metadata=dict(message_metadata),
|
||
)
|
||
await self.submit_item(item)
|
||
|
||
async def submit_item(self, item: QueuedChatInput) -> None:
|
||
async with self._ensure_lock():
|
||
if self.is_busy:
|
||
self.queue.append(item)
|
||
console.print(
|
||
f"[dim]Queued #{len(self.queue)}; it will run after the current turn. "
|
||
"Use /queue list or /queue drop <n>.[/dim]"
|
||
)
|
||
return
|
||
self._start_item_locked(item)
|
||
|
||
async def wait_for_active(self) -> None:
|
||
task = self.active_task
|
||
if task is not None:
|
||
await task
|
||
|
||
async def shutdown(self) -> None:
|
||
self._closing = True
|
||
self.queue.clear()
|
||
await self.stop_kanban_watch(silent=True)
|
||
task = self.active_task
|
||
if task is None or task.done():
|
||
return
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
return
|
||
|
||
def drop(self, index: int) -> QueuedChatInput | None:
|
||
if index < 1 or index > len(self.queue):
|
||
return None
|
||
items = list(self.queue)
|
||
item = items.pop(index - 1)
|
||
self.queue = deque(items)
|
||
return item
|
||
|
||
def clear(self) -> int:
|
||
count = len(self.queue)
|
||
self.queue.clear()
|
||
return count
|
||
|
||
async def start_kanban_watch(
|
||
self,
|
||
*,
|
||
interval: float = 2.0,
|
||
limit: int = 100,
|
||
initial_fingerprint: str = "",
|
||
session_id: str | None = None,
|
||
project_scope: bool = False,
|
||
) -> None:
|
||
if self.kanban_watch_active():
|
||
console.print("[dim]Kanban live watch is already running. Use /kanban stop to stop it.[/dim]")
|
||
return
|
||
self._kanban_watch_fingerprint = initial_fingerprint
|
||
self.kanban_watch_task = asyncio.create_task(
|
||
self._kanban_watch_loop(
|
||
interval=interval,
|
||
limit=limit,
|
||
session_id=session_id,
|
||
project_scope=project_scope,
|
||
)
|
||
)
|
||
scope_label = "project" if project_scope else f"session {(session_id or self.state.session_id)[:8]}"
|
||
console.print(
|
||
f"[dim]Kanban live watch started for {escape(scope_label)}. "
|
||
"New or changed work items will appear here. Exit with /kanban stop.[/dim]"
|
||
)
|
||
|
||
async def stop_kanban_watch(self, *, silent: bool = False) -> None:
|
||
task = self.kanban_watch_task
|
||
self.kanban_watch_task = None
|
||
if task is None or task.done():
|
||
if not silent:
|
||
console.print("[dim]Kanban live watch is not running.[/dim]")
|
||
return
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
if not silent:
|
||
console.print("[dim]Kanban live watch stopped.[/dim]")
|
||
|
||
def _start_item_locked(self, item: QueuedChatInput) -> None:
|
||
self.active_task = asyncio.create_task(self._run_item(item))
|
||
|
||
def _ensure_lock(self) -> asyncio.Lock:
|
||
if self._lock is None:
|
||
self._lock = asyncio.Lock()
|
||
return self._lock
|
||
|
||
async def _run_item(self, item: QueuedChatInput) -> None:
|
||
self.state.session_id = item.session_id
|
||
self.state.mode = item.mode
|
||
self.state.company_profile = item.company_profile
|
||
self.state.org_id = item.org_id
|
||
self.state.preferred_agent = item.preferred_agent
|
||
self.state.domains = list(item.domains)
|
||
try:
|
||
await _process_interactive_chat_message(
|
||
self.state,
|
||
item.text,
|
||
message_metadata=item.message_metadata,
|
||
interactive_followups=item.allow_interactive_followups,
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
finally:
|
||
async with self._ensure_lock():
|
||
self.active_task = None
|
||
if not self._closing and self.queue:
|
||
next_item = self.queue.popleft()
|
||
console.print(f"[dim]Running queued input; {len(self.queue)} remaining.[/dim]")
|
||
self._start_item_locked(next_item)
|
||
|
||
async def _kanban_watch_loop(
|
||
self,
|
||
*,
|
||
interval: float,
|
||
limit: int,
|
||
session_id: str | None,
|
||
project_scope: bool,
|
||
) -> None:
|
||
try:
|
||
while True:
|
||
items = await _fetch_kanban_items(
|
||
self.state,
|
||
limit=limit,
|
||
session_id=session_id,
|
||
project_scope=project_scope,
|
||
)
|
||
fingerprint = _kanban_items_fingerprint(items)
|
||
if fingerprint != self._kanban_watch_fingerprint:
|
||
self._kanban_watch_fingerprint = fingerprint
|
||
title = "Kanban Live Update (project)" if project_scope else f"Kanban Live Update (session {(session_id or self.state.session_id)[:8]})"
|
||
_render_kanban_items(items, title=title)
|
||
await asyncio.sleep(interval)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
console.print(f"[warning]Kanban live watch stopped: {escape(str(exc))}[/warning]")
|
||
|
||
|
||
_SLASH_COMMANDS: tuple[_SlashCommandSpec, ...] = (
|
||
_SlashCommandSpec("Chat", "/help", "Show this command list."),
|
||
_SlashCommandSpec("Chat", "/quit", "Exit interactive chat."),
|
||
_SlashCommandSpec("Chat", "/queue list|drop|clear", "Inspect or edit queued prompts while a turn is running.", ("list", "drop", "clear")),
|
||
_SlashCommandSpec("Context", "/status", "Show project, session, mode, agent, domains, model, and cost."),
|
||
_SlashCommandSpec("Context", "/mode [task|company] [corporate|custom]", "Set execution mode for future messages.", ("task", "company", "corporate", "custom")),
|
||
_SlashCommandSpec("Context", "/agent [native|codex|claude_code|cursor|opencode|none]", "Set preferred external agent.", ("native", "codex", "claude_code", "cursor", "opencode", "none")),
|
||
_SlashCommandSpec("Context", "/domains [domain ...|clear]", "Set domain hints for future messages.", ("clear",)),
|
||
_SlashCommandSpec("Project", "/project", "Show current project, known projects, and switch/create/delete usage.", ("list", "switch", "create", "rename", "delete")),
|
||
_SlashCommandSpec("Project", "/project list", "List known projects."),
|
||
_SlashCommandSpec("Project", "/project create <id>", "Create a project through the shared Office service."),
|
||
_SlashCommandSpec("Project", "/project switch <id>", "Switch project and restore its latest session."),
|
||
_SlashCommandSpec("Project", "/project rename <old_id> <new_id>", "Rename a project id and move persisted project data."),
|
||
_SlashCommandSpec("Project", "/project delete <id> --yes", "Delete a project through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/stop [task_id|session_id]", "Stop the current or selected runtime and preserve history for follow-up."),
|
||
_SlashCommandSpec("Session", "/continue [task_id|session_id] [message]", "Continue a stopped runtime without re-planning."),
|
||
_SlashCommandSpec("Session", "/session", "Show current session, recent sessions, and switch/create/delete usage.", ("list", "new", "create", "resume", "show", "config", "send", "rename", "delete", "stop", "continue", "complete")),
|
||
_SlashCommandSpec("Session", "/session list [limit]", "List recent sessions."),
|
||
_SlashCommandSpec("Session", "/session new", "Start a fresh conversation session."),
|
||
_SlashCommandSpec("Session", "/session create [title] [--mode task|company|org] [--agent ...]", "Create a task-backed session through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/session resume <session_id>", "Resume a session in this project."),
|
||
_SlashCommandSpec("Session", "/session show <session_id|task_id> [--limit N] [--full]", "Show session metadata and transcript."),
|
||
_SlashCommandSpec("Session", "/session config <task_id> [--mode ...] [--agent ...] [--org ...]", "Update session execution config through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/session send <task_id> <message>", "Send a message to a task-backed session through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/session rename <task_id|session_id> <title>", "Rename a task-backed or plain session."),
|
||
_SlashCommandSpec("Session", "/session delete <task_id> --yes", "Hard-delete a task-backed session."),
|
||
_SlashCommandSpec("Session", "/session stop [task_id|session_id]", "Stop the current or selected runtime through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/session continue [task_id|session_id] [message]", "Continue a stopped runtime through the shared Office service."),
|
||
_SlashCommandSpec("Session", "/session complete <task_id>", "Complete a task-backed session through the shared Office service."),
|
||
_SlashCommandSpec("Tasks", "/tasks [status] [--limit N] [--full]", "List current project tasks."),
|
||
_SlashCommandSpec("Tasks", "/task show <task_id> [--limit N] [--full]", "Show task detail and transcript.", ("show", "move", "done", "rename", "delete")),
|
||
_SlashCommandSpec("Tasks", "/task move <task_id> todo|in-progress|done|blocked|failed|cancelled", "Move a task through the shared transition API."),
|
||
_SlashCommandSpec("Tasks", "/task done <task_id>", "Mark a task done through the shared transition API."),
|
||
_SlashCommandSpec("Tasks", "/task rename <task_id> <title>", "Rename a task and its session title."),
|
||
_SlashCommandSpec("Tasks", "/task delete <task_id> --yes", "Hard-delete a task and its persisted lifecycle data."),
|
||
_SlashCommandSpec("Runtime", "/runtime [--limit N] [--full]", "Show live runtime, active tasks, external sessions, and checkpoints."),
|
||
_SlashCommandSpec("Runtime", "/recover [--limit N] [--full]", "Show interrupted runtime and resumable checkpoints.", ("scan", "resume", "cancel", "retry")),
|
||
_SlashCommandSpec("Runtime", "/recover resume|cancel|retry <parent_task_id>", "Act on an interrupted company runtime."),
|
||
_SlashCommandSpec("Runtime", "/logs <task_id|session_id> [--limit N] [--full]", "Show execution logs, runtime events, tools, and transcript."),
|
||
_SlashCommandSpec("Runtime", "/comms <task_id> [--limit N] [--full]", "Show company-mode messages, handoffs, and review notes."),
|
||
_SlashCommandSpec("Runtime", "/attachments [--limit N] [--full]", "List current session attachment references."),
|
||
_SlashCommandSpec("Runtime", "/staffing [context]", "Open the pending company staffing/agent editor or role context preview.", ("context",)),
|
||
_SlashCommandSpec("Board", "/kanban [once|stop|all]", "Show current-session work-item status inline and optionally watch live updates.", ("once", "stop", "all", "project", "--once", "--all")),
|
||
_SlashCommandSpec("Board", "/board kanban|pipeline|work-item|role|logs", "Open opc board in read-only inspector mode.", ("kanban", "pipeline", "work-item", "role", "logs")),
|
||
_SlashCommandSpec("Work Items", "/work-items list|show|logs|role-status", "Inspect company work items and role progress.", ("list", "show", "logs", "role-status")),
|
||
_SlashCommandSpec("Org", "/org", "Show or edit organization config.", ("info", "role", "policy", "strategy", "reset", "saved", "export", "import")),
|
||
_SlashCommandSpec("Org", "/org role add|update|delete|bulk-add", "Manage company roles through the shared Office service."),
|
||
_SlashCommandSpec("Org", "/org policy update --payload ...", "Update runtime policy through the shared Office service."),
|
||
_SlashCommandSpec("Org", "/org strategy update --final-decider <role>", "Update organization strategy."),
|
||
_SlashCommandSpec("Org", "/org saved list|save|load|delete", "Manage saved organization architectures."),
|
||
_SlashCommandSpec("Agent", "/agent list|detail|create|delete|move|import-employee", "Manage visual office agents.", ("list", "detail", "create", "delete", "move", "create-from-template", "import-employee")),
|
||
_SlashCommandSpec("Talent", "/talent list|employees|scan", "Browse talent templates and employees.", ("list", "employees", "scan", "import", "import-repo", "hire", "employee", "import-agent")),
|
||
_SlashCommandSpec("Talent", "/talent import <template_id...>", "Import local talent templates."),
|
||
_SlashCommandSpec("Talent", "/talent import-repo <path>", "Import talent templates from a local repo."),
|
||
_SlashCommandSpec("Talent", "/talent hire <template_id> <role_id> [--name ...]", "Hire a template into a role."),
|
||
_SlashCommandSpec("Talent", "/talent employee <employee_id>", "Show employee detail."),
|
||
_SlashCommandSpec("Talent", "/talent import-agent <employee_id>", "Import an employee as a visual office agent."),
|
||
_SlashCommandSpec("Market", "/market list", "List installed OPC Market packages.", ("browse", "preview", "list", "presets", "apply-preset", "install", "uninstall", "export")),
|
||
_SlashCommandSpec("Market", "/market browse|preview <preset_id>", "Browse and preview architecture presets."),
|
||
_SlashCommandSpec("Market", "/market presets", "List built-in architecture presets."),
|
||
_SlashCommandSpec("Market", "/market apply-preset <preset_id> [--strategy namespace|overwrite]", "Apply a built-in architecture preset as the active custom organization."),
|
||
_SlashCommandSpec("Market", "/market install <path> [--strategy namespace|overwrite]", "Install a local .opcpkg package."),
|
||
_SlashCommandSpec("Market", "/market uninstall <package_id> --yes", "Uninstall an OPC Market package."),
|
||
_SlashCommandSpec("Market", "/market export --id ... --name ...", "Export the current org as a package."),
|
||
_SlashCommandSpec("Reorg", "/reorg list|show|approve|deny|apply", "Manage reorg proposals.", ("list", "show", "approve", "deny", "apply")),
|
||
_SlashCommandSpec("Diagnostics", "/cost", "Show token and cost counters."),
|
||
_SlashCommandSpec("Diagnostics", "/checkpoints [--limit N] [--full]", "List pending execution checkpoints."),
|
||
)
|
||
CommandSpec = _SlashCommandSpec
|
||
_SLASH_ALIASES = {"p": "project", "s": "session", "t": "task", "checkpoint": "checkpoints", "recovery": "recover", "work-item": "work-items", "workitems": "work-items"}
|
||
|
||
|
||
def _initial_company_profile(config: OPCConfig) -> str:
|
||
value = str(getattr(getattr(config, "org", None), "company_profile", "") or "corporate").strip().lower()
|
||
return value if value in _VALID_COMPANY_PROFILES else "corporate"
|
||
|
||
|
||
def _normalize_interactive_preferred_agent(value: str | None) -> str | None:
|
||
normalized = str(value or "native").strip().lower().replace("-", "_")
|
||
if normalized in {"none", "auto", "default", "system"}:
|
||
return None
|
||
return normalized if normalized in _VALID_PREFERRED_AGENTS else "native"
|
||
|
||
|
||
def _cli_option_present(*names: str) -> bool:
|
||
argv = list(sys.argv[1:])
|
||
for idx, arg in enumerate(argv):
|
||
if arg in names:
|
||
return True
|
||
if any(arg.startswith(f"{name}=") for name in names):
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _read_persisted_chat_context(state: _InteractiveChatState) -> dict[str, str]:
|
||
try:
|
||
import sqlite3
|
||
|
||
db_path = state.engine.opc_home / "ui_state.db"
|
||
if not db_path.exists():
|
||
return {}
|
||
with sqlite3.connect(str(db_path), timeout=1) as db:
|
||
db.execute(
|
||
"CREATE TABLE IF NOT EXISTS server_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||
)
|
||
rows = db.execute(
|
||
"SELECT key, value FROM server_state WHERE key IN (?, ?, ?)",
|
||
("exec_mode", "company_profile", "task_preferred_agent"),
|
||
).fetchall()
|
||
values = {str(key): str(value) for key, value in rows}
|
||
return {
|
||
"mode": values.get("exec_mode", ""),
|
||
"company_profile": values.get("company_profile", ""),
|
||
"preferred_agent": values.get("task_preferred_agent", ""),
|
||
}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
async def _persist_chat_context(state: _InteractiveChatState) -> None:
|
||
try:
|
||
import sqlite3
|
||
|
||
db_path = state.engine.opc_home / "ui_state.db"
|
||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with sqlite3.connect(str(db_path), timeout=1) as db:
|
||
db.execute(
|
||
"CREATE TABLE IF NOT EXISTS server_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||
)
|
||
db.executemany(
|
||
"INSERT OR REPLACE INTO server_state (key, value) VALUES (?, ?)",
|
||
(
|
||
("exec_mode", state.mode),
|
||
("company_profile", state.company_profile),
|
||
("task_preferred_agent", state.preferred_agent or "native"),
|
||
),
|
||
)
|
||
db.commit()
|
||
except Exception as exc:
|
||
console.print(f"[warning]Could not persist CLI mode state: {escape(str(exc))}[/warning]")
|
||
|
||
|
||
async def _restore_chat_context(
|
||
state: _InteractiveChatState,
|
||
*,
|
||
restore_mode: bool,
|
||
restore_company_profile: bool,
|
||
restore_agent: bool,
|
||
) -> None:
|
||
persisted = await _read_persisted_chat_context(state)
|
||
raw_mode = str(persisted.get("mode", "") or "").strip().lower()
|
||
raw_profile = str(persisted.get("company_profile", "") or "").strip().lower()
|
||
raw_agent = str(persisted.get("preferred_agent", "") or "").strip().lower().replace("-", "_")
|
||
if restore_mode:
|
||
if raw_mode in {"company", "company_mode"}:
|
||
state.mode = "company"
|
||
elif raw_mode in {"org", "custom"}:
|
||
state.mode = "company"
|
||
if restore_company_profile:
|
||
state.company_profile = "custom"
|
||
elif raw_mode in {"task", "project", "task_mode", "project_mode"}:
|
||
state.mode = "task"
|
||
if restore_company_profile and raw_profile in _VALID_COMPANY_PROFILES:
|
||
state.company_profile = raw_profile
|
||
if restore_agent and raw_agent:
|
||
state.preferred_agent = _normalize_interactive_preferred_agent(raw_agent)
|
||
|
||
|
||
def _current_project_id(engine: Any) -> str:
|
||
return str(getattr(engine, "project_id", None) or "default").strip() or "default"
|
||
|
||
|
||
def _safe_project_id(project_id: str) -> bool:
|
||
return bool(re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", project_id or ""))
|
||
|
||
|
||
def _value_text(value: Any, default: str = "") -> str:
|
||
if value is None:
|
||
return default
|
||
if hasattr(value, "value"):
|
||
return str(value.value)
|
||
return str(value)
|
||
|
||
|
||
def _format_datetime(value: Any) -> str:
|
||
if not value:
|
||
return ""
|
||
if hasattr(value, "strftime"):
|
||
return value.strftime("%Y-%m-%d %H:%M")
|
||
return str(value)
|
||
|
||
|
||
def _clip_text(value: Any, limit: int = 240, *, full: bool = False) -> str:
|
||
text = str(value or "").replace("\r\n", "\n").strip()
|
||
text = " ".join(part.strip() for part in text.splitlines() if part.strip())
|
||
if full:
|
||
return text
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: max(0, limit - 3)].rstrip() + "..."
|
||
|
||
|
||
def _json_summary(value: Any, limit: int = 240, *, full: bool = False) -> str:
|
||
if isinstance(value, dict):
|
||
for key in ("summary", "title", "message", "prompt", "original_message"):
|
||
if value.get(key):
|
||
return _clip_text(value.get(key), limit, full=full)
|
||
try:
|
||
return _clip_text(json.dumps(value, ensure_ascii=False, default=str), limit, full=full)
|
||
except TypeError:
|
||
return _clip_text(value, limit, full=full)
|
||
|
||
|
||
def _coerce_limit(value: str, *, default: int = _SLASH_DEFAULT_LIMIT) -> int:
|
||
if not str(value or "").strip():
|
||
return default
|
||
limit = int(str(value).strip())
|
||
return max(1, min(_SLASH_MAX_LIMIT, limit))
|
||
|
||
|
||
def _parse_limit_args(args: list[str], *, default: int = _SLASH_DEFAULT_LIMIT) -> tuple[list[str], int]:
|
||
remaining: list[str] = []
|
||
limit = default
|
||
idx = 0
|
||
while idx < len(args):
|
||
token = args[idx]
|
||
if token == "--limit":
|
||
if idx + 1 >= len(args):
|
||
raise ValueError("Missing value for --limit.")
|
||
limit = _coerce_limit(args[idx + 1], default=default)
|
||
idx += 2
|
||
continue
|
||
if token.startswith("--limit="):
|
||
limit = _coerce_limit(token.split("=", 1)[1], default=default)
|
||
idx += 1
|
||
continue
|
||
remaining.append(token)
|
||
idx += 1
|
||
return remaining, limit
|
||
|
||
|
||
def _parse_view_args(args: list[str], *, default: int = _SLASH_DEFAULT_LIMIT) -> tuple[list[str], int, bool]:
|
||
remaining: list[str] = []
|
||
full = False
|
||
idx = 0
|
||
while idx < len(args):
|
||
token = args[idx]
|
||
if token == "--full":
|
||
full = True
|
||
idx += 1
|
||
continue
|
||
remaining.append(token)
|
||
idx += 1
|
||
remaining, limit = _parse_limit_args(remaining, default=default)
|
||
return remaining, limit, full
|
||
|
||
|
||
def _slash_parts(raw_input: str) -> list[str]:
|
||
command = raw_input[1:] if raw_input.startswith("/") else raw_input
|
||
return shlex.split(command)
|
||
|
||
|
||
def _canonical_slash_command(command: str) -> str:
|
||
return _SLASH_ALIASES.get(command.strip().lower(), command.strip().lower())
|
||
|
||
|
||
def _slash_command_names() -> list[str]:
|
||
names = {
|
||
spec.command.split()[0].lstrip("/")
|
||
for spec in _SLASH_COMMANDS
|
||
if spec.command.startswith("/")
|
||
}
|
||
names.update(_SLASH_ALIASES.keys())
|
||
return sorted(names)
|
||
|
||
|
||
def _slash_subcommands(command: str) -> list[str]:
|
||
canonical = _canonical_slash_command(command)
|
||
values: set[str] = set()
|
||
for spec in _SLASH_COMMANDS:
|
||
root = spec.command.split()[0].lstrip("/")
|
||
if root == canonical:
|
||
values.update(spec.subcommands)
|
||
parts = spec.command.split()
|
||
if len(parts) > 1 and not parts[1].startswith("[") and not parts[1].startswith("<"):
|
||
values.add(parts[1].split("|", 1)[0])
|
||
return sorted(item for item in values if item)
|
||
|
||
|
||
class _OPCSlashCompleter:
|
||
def get_completions(self, document: Any, complete_event: Any): # noqa: ANN201 - prompt-toolkit protocol
|
||
try:
|
||
from prompt_toolkit.completion import Completion
|
||
except ImportError:
|
||
return
|
||
text = str(getattr(document, "text_before_cursor", "") or "")
|
||
if not text.startswith("/"):
|
||
return
|
||
body = text[1:]
|
||
if " " not in body:
|
||
prefix = body.lower()
|
||
if prefix in _slash_command_names():
|
||
for suggestion in _slash_subcommands(prefix):
|
||
yield Completion(f"/{prefix} {suggestion}", start_position=-len(text))
|
||
return
|
||
for command in _slash_command_names():
|
||
if command.startswith(prefix):
|
||
yield Completion(f"/{command}", start_position=-len(text))
|
||
return
|
||
tokens = body.split()
|
||
command = _canonical_slash_command(tokens[0] if tokens else "")
|
||
prefix = "" if body.endswith(" ") else (tokens[-1].lower() if len(tokens) > 1 else "")
|
||
suggestions = _slash_subcommands(command)
|
||
if command == "agent":
|
||
suggestions.extend(sorted([*_VALID_PREFERRED_AGENTS, "none"]))
|
||
if command == "mode":
|
||
suggestions.extend(["task", "company", "corporate", "custom"])
|
||
for suggestion in sorted(set(suggestions)):
|
||
if suggestion.startswith(prefix):
|
||
yield Completion(suggestion, start_position=-len(prefix))
|
||
|
||
async def get_completions_async(self, document: Any, complete_event: Any): # noqa: ANN201 - prompt-toolkit protocol
|
||
for completion in self.get_completions(document, complete_event):
|
||
yield completion
|
||
|
||
|
||
def _print_slash_help() -> None:
|
||
table = Table(title="OPC Slash Commands", show_lines=False)
|
||
table.add_column("Group", style="cyan", no_wrap=True)
|
||
table.add_column("Command", style="bold")
|
||
table.add_column("What it does")
|
||
for spec in _SLASH_COMMANDS:
|
||
table.add_row(spec.group, spec.command, spec.description)
|
||
table.add_row("Aliases", "/p /s /t", "Short aliases for /project, /session, and /task.")
|
||
console.print(table)
|
||
|
||
|
||
def _print_interactive_cost(engine: Any) -> None:
|
||
if not getattr(engine, "llm", None):
|
||
console.print("[warning]LLM runtime is not available.[/warning]")
|
||
return
|
||
stats = getattr(engine.llm, "stats", {}) or {}
|
||
console.print(
|
||
f"Session: tokens_in={stats.get('tokens_in', 0)}, "
|
||
f"tokens_out={stats.get('tokens_out', 0)}, "
|
||
f"cost=${float(stats.get('estimated_cost', 0.0) or 0.0):.4f}"
|
||
)
|
||
|
||
|
||
def _print_context_status(state: _InteractiveChatState) -> None:
|
||
table = Table(title="Current Context", show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
table.add_row("Project", _current_project_id(state.engine))
|
||
table.add_row("Session", state.session_id or "(none)")
|
||
table.add_row("Mode", state.mode)
|
||
table.add_row("Company profile", state.company_profile if state.mode == "company" else "(inactive)")
|
||
if state.org_id:
|
||
table.add_row("Org", state.org_id)
|
||
table.add_row("Preferred agent", state.preferred_agent or "(system default)")
|
||
table.add_row("Domains", ", ".join(state.domains) if state.domains else "(none)")
|
||
table.add_row("Runtime control", state.runtime_control_state or "(running/idle)")
|
||
if state.runtime_control_task_id:
|
||
table.add_row("Runtime task", state.runtime_control_task_id)
|
||
if state.runtime_control_checkpoint_id:
|
||
table.add_row("Runtime checkpoint", state.runtime_control_checkpoint_id)
|
||
console.print(table)
|
||
console.print("[dim]Use /mode, /agent, /project, /session, /stop, or /continue to inspect and control context.[/dim]")
|
||
|
||
|
||
def _list_cli_project_ids(engine: Any) -> list[str]:
|
||
project_ids = {"default", _current_project_id(engine)}
|
||
projects_dir = Path(getattr(engine, "opc_home", get_opc_home())) / "projects"
|
||
if projects_dir.is_dir():
|
||
for entry in sorted(projects_dir.iterdir()):
|
||
if entry.is_dir():
|
||
project_ids.add(entry.name)
|
||
return sorted(project_ids, key=lambda item: (item != "default", item.lower()))
|
||
|
||
|
||
def _cli_project_paths(engine: Any, project_id: str) -> tuple[Path, Path, Path]:
|
||
from opc.core.config import get_project_workplace
|
||
|
||
opc_home = Path(getattr(engine, "opc_home", get_opc_home()))
|
||
return (
|
||
opc_home / "projects" / project_id,
|
||
opc_home / "memory" / "projects" / f"{project_id}.md",
|
||
get_project_workplace(project_id),
|
||
)
|
||
|
||
|
||
def _cli_project_exists(engine: Any, project_id: str) -> bool:
|
||
if project_id == "default":
|
||
return True
|
||
return any(path.exists() for path in _cli_project_paths(engine, project_id))
|
||
|
||
|
||
def _render_project_list(state: _InteractiveChatState) -> None:
|
||
current = _current_project_id(state.engine)
|
||
table = Table(title="Projects")
|
||
table.add_column("Project")
|
||
table.add_column("Current", justify="center")
|
||
table.add_column("Markers")
|
||
for project_id in _list_cli_project_ids(state.engine):
|
||
project_dir, project_memory, workplace = _cli_project_paths(state.engine, project_id)
|
||
markers: list[str] = []
|
||
if (project_dir / "tasks.db").exists():
|
||
markers.append("tasks")
|
||
if project_memory.exists():
|
||
markers.append("memory")
|
||
if workplace.exists():
|
||
markers.append("workplace")
|
||
table.add_row(project_id, "*" if project_id == current else "", ", ".join(markers) or "")
|
||
console.print(table)
|
||
console.print("[dim]Switch with /project switch <id> or /project <id>. Create with /project create <id>. Rename with /project rename <old_id> <new_id>.[/dim]")
|
||
|
||
|
||
def _render_project_picker(state: _InteractiveChatState, project_ids: list[str]) -> None:
|
||
current = _current_project_id(state.engine)
|
||
table = Table(title="Choose Project")
|
||
table.add_column("#", justify="right", style="cyan", no_wrap=True)
|
||
table.add_column("Project")
|
||
table.add_column("Current", justify="center")
|
||
for idx, project_id in enumerate(project_ids, start=1):
|
||
table.add_row(str(idx), project_id, "*" if project_id == current else "")
|
||
console.print(table)
|
||
console.print("[dim]Enter a number or project id. Use `new <id>` to create a project. Blank selects the current project.[/dim]")
|
||
|
||
|
||
def _render_session_picker(sessions: list[Any]) -> None:
|
||
if not sessions:
|
||
console.print("[info]No sessions found for this project.[/info]")
|
||
console.print("[dim]Press Enter to create a new session.[/dim]")
|
||
return
|
||
table = Table(title="Choose Session")
|
||
table.add_column("#", justify="right", style="cyan", no_wrap=True)
|
||
table.add_column("Session ID")
|
||
table.add_column("Title")
|
||
table.add_column("Mode")
|
||
table.add_column("Updated")
|
||
for idx, item in enumerate(sessions, start=1):
|
||
table.add_row(
|
||
str(idx),
|
||
str(getattr(item, "session_id", "") or ""),
|
||
_clip_text(getattr(item, "title", "") or "(untitled)", 72),
|
||
str(getattr(item, "mode", "") or ""),
|
||
_format_datetime(getattr(item, "updated_at", None)),
|
||
)
|
||
console.print(table)
|
||
console.print("[dim]Enter a number or session id. Press Enter, or type `new`, to create a new session.[/dim]")
|
||
|
||
|
||
async def _load_recent_primary_sessions(store: OPCStore | None, project_id: str | None, *, limit: int = 20) -> list[Any]:
|
||
if not store:
|
||
return []
|
||
return await store.list_sessions(project_id=project_id or "default", parent_session_id=None, limit=limit)
|
||
|
||
|
||
def _session_short_id(session_id: str) -> str:
|
||
text = str(session_id or "").strip()
|
||
if len(text) <= 12:
|
||
return text or "(none)"
|
||
return f"{text[:8]}..."
|
||
|
||
|
||
def _chat_bottom_toolbar_text(state: _InteractiveChatState, controller: ChatTurnController | None = None) -> list[tuple[str, str]]:
|
||
profile = f" {state.company_profile}" if state.mode == "company" else ""
|
||
org = f" org:{state.org_id}" if state.org_id else ""
|
||
busy_hint = ""
|
||
if controller is not None:
|
||
if controller.is_busy:
|
||
kanban_hint = " kanban:live" if controller.kanban_watch_active() else ""
|
||
busy_hint = f" running queue:{controller.queue_depth()}{kanban_hint} Enter=queue /kanban /logs /runtime"
|
||
elif controller.kanban_watch_active():
|
||
busy_hint = " kanban:live exit:/kanban stop"
|
||
elif controller.queue_depth():
|
||
busy_hint = f" queue:{controller.queue_depth()}"
|
||
runtime_hint = f" runtime:{state.runtime_control_state}" if state.runtime_control_state else ""
|
||
text = (
|
||
f" project:{_current_project_id(state.engine)}"
|
||
f" session:{_session_short_id(state.session_id)}"
|
||
f" mode:{state.mode}{profile}"
|
||
f"{org}"
|
||
f" agent:{state.preferred_agent or 'system'}"
|
||
f"{runtime_hint}"
|
||
f"{busy_hint}"
|
||
" /mode /agent /project /session /help"
|
||
)
|
||
return [("class:bottom-toolbar", text)]
|
||
|
||
|
||
def _print_chat_hint() -> None:
|
||
console.print("[dim]Hint: /mode switches mode, /agent switches agent, /project switches project, /session switches session.[/dim]")
|
||
|
||
|
||
async def _run_chat_office_service(state: _InteractiveChatState, operation: Any) -> dict[str, Any] | None:
|
||
from opc.plugins.office_ui.services import ServiceError
|
||
from opc.plugins.office_ui.services.factory import OfficeServiceFactory
|
||
|
||
try:
|
||
async with OfficeServiceFactory(config=state.config, project_id=_current_project_id(state.engine)) as services:
|
||
result = await operation(services)
|
||
return {"ok": True, **dict(result.payload)}
|
||
except ServiceError as exc:
|
||
console.print(f"[warning]{escape(exc.message)}[/warning]")
|
||
return None
|
||
except Exception as exc:
|
||
console.print(f"[error]{escape(str(exc))}[/error]")
|
||
return None
|
||
|
||
|
||
async def _run_chat_current_office_service(state: _InteractiveChatState, operation: Any) -> dict[str, Any] | None:
|
||
from opc.plugins.office_ui.services import ModeState, OfficeServiceContext, OfficeServices, ServiceError
|
||
|
||
try:
|
||
context = OfficeServiceContext(
|
||
engine=state.engine,
|
||
agent_store=None,
|
||
chat_store=None,
|
||
event_adapter=None,
|
||
mode_state=ModeState(
|
||
exec_mode=state.mode,
|
||
company_profile=state.company_profile,
|
||
task_preferred_agent=state.preferred_agent or "native",
|
||
),
|
||
)
|
||
context.session_to_task = state.session_to_task
|
||
context.active_runtime_children = state.active_runtime_children
|
||
result = await operation(OfficeServices(context))
|
||
return {"ok": True, **dict(result.payload)}
|
||
except ServiceError as exc:
|
||
console.print(f"[warning]{escape(exc.message)}[/warning]")
|
||
return None
|
||
except Exception as exc:
|
||
console.print(f"[error]{escape(str(exc))}[/error]")
|
||
return None
|
||
|
||
|
||
async def _switch_chat_project(state: _InteractiveChatState, project_id: str, *, restore_session: bool = True) -> None:
|
||
if not project_id:
|
||
console.print("[warning]Usage: /project switch <id>[/warning]")
|
||
return
|
||
if not _safe_project_id(project_id):
|
||
console.print("[warning]Invalid project id. Use letters, numbers, hyphens, and underscores.[/warning]")
|
||
return
|
||
if not _cli_project_exists(state.engine, project_id):
|
||
console.print(f"[warning]Project does not exist: {project_id}[/warning]")
|
||
return
|
||
await state.engine.shutdown()
|
||
engine, runtime_display = _create_cli_engine(state.config, project_id)
|
||
await engine.initialize()
|
||
state.engine = engine
|
||
state.runtime_display = runtime_display
|
||
state.session_to_task.clear()
|
||
state.active_runtime_children.clear()
|
||
state.runtime_control_state = ""
|
||
state.runtime_control_task_id = ""
|
||
state.runtime_control_session_id = ""
|
||
state.runtime_control_checkpoint_id = ""
|
||
state.org_id = ""
|
||
_attach_cli_runtime_callbacks(state)
|
||
console.print(f"[info]Switched to project: {project_id}[/info]")
|
||
if not restore_session:
|
||
state.session_id = ""
|
||
return
|
||
state.session_id, restored_latest = await _resolve_starting_session_id(engine)
|
||
if restored_latest:
|
||
console.print(f"[info]Restored recent session: {state.session_id}[/info]")
|
||
else:
|
||
console.print(f"[info]Started a new session: {state.session_id}[/info]")
|
||
|
||
|
||
async def _create_and_switch_chat_project(state: _InteractiveChatState, project_id: str) -> bool:
|
||
if not _safe_project_id(project_id):
|
||
console.print("[warning]Invalid project id. Use letters, numbers, hyphens, and underscores.[/warning]")
|
||
return False
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.project.create(project_id, active_project_id=_current_project_id(state.engine)),
|
||
)
|
||
if not payload:
|
||
return False
|
||
console.print(f"[success]Created project: {payload.get('project_id') or project_id}[/success]")
|
||
await _switch_chat_project(state, project_id, restore_session=False)
|
||
return True
|
||
|
||
|
||
async def _choose_initial_project(state: _InteractiveChatState, *, explicit_project: bool = False) -> None:
|
||
if explicit_project:
|
||
console.print(f"[info]Project: {_current_project_id(state.engine)}[/info]")
|
||
return
|
||
while True:
|
||
project_ids = _list_cli_project_ids(state.engine)
|
||
_render_project_picker(state, project_ids)
|
||
try:
|
||
choice = console.input("[bold]Project[/bold] [current]: ").strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
choice = ""
|
||
if not choice:
|
||
console.print(f"[info]Project: {_current_project_id(state.engine)}[/info]")
|
||
return
|
||
lowered = choice.lower()
|
||
if lowered in {"quit", "exit", "/quit", "/exit"}:
|
||
raise KeyboardInterrupt
|
||
if lowered.startswith("/project"):
|
||
try:
|
||
parts = _slash_parts(choice)
|
||
project_args = parts[1:]
|
||
before_project = _current_project_id(state.engine)
|
||
await _handle_project_slash(state, project_args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]Could not parse command: {exc}[/warning]")
|
||
continue
|
||
subcommand = project_args[0].lower() if project_args else ""
|
||
if subcommand in {"switch", "use"} and _current_project_id(state.engine) != before_project:
|
||
return
|
||
if len(project_args) == 1 and subcommand not in {"list", "ls", "create", "new", "delete", "rm"} and _current_project_id(state.engine) != before_project:
|
||
return
|
||
continue
|
||
new_prefixes = ("new ", "create ", "+ ")
|
||
if any(lowered.startswith(prefix) for prefix in new_prefixes):
|
||
project_id = choice.split(maxsplit=1)[1].strip() if " " in choice else ""
|
||
if not project_id:
|
||
console.print("[warning]Usage: new <project_id>[/warning]")
|
||
continue
|
||
if await _create_and_switch_chat_project(state, project_id):
|
||
return
|
||
continue
|
||
if choice.isdigit():
|
||
idx = int(choice)
|
||
if 1 <= idx <= len(project_ids):
|
||
await _switch_chat_project(state, project_ids[idx - 1], restore_session=False)
|
||
return
|
||
console.print("[warning]Invalid project number.[/warning]")
|
||
continue
|
||
if choice in project_ids or _cli_project_exists(state.engine, choice):
|
||
await _switch_chat_project(state, choice, restore_session=False)
|
||
return
|
||
console.print(f"[warning]Project not found: {choice}. Type `new {choice}` to create it.[/warning]")
|
||
|
||
|
||
async def _choose_initial_session(state: _InteractiveChatState) -> None:
|
||
sessions = await _load_recent_primary_sessions(
|
||
getattr(state.engine, "store", None),
|
||
_current_project_id(state.engine),
|
||
limit=20,
|
||
)
|
||
while True:
|
||
_render_session_picker(sessions)
|
||
try:
|
||
choice = console.input("[bold]Session[/bold] [new]: ").strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
choice = ""
|
||
lowered = choice.lower()
|
||
if lowered in {"quit", "exit", "/quit", "/exit"}:
|
||
raise KeyboardInterrupt
|
||
if not choice or lowered in {"new", "create", "+"}:
|
||
_start_new_chat_session(state)
|
||
return
|
||
if lowered.startswith("/session"):
|
||
try:
|
||
await _handle_session_slash(state, _slash_parts(choice)[1:])
|
||
except ValueError as exc:
|
||
console.print(f"[warning]Could not parse command: {exc}[/warning]")
|
||
if state.session_id:
|
||
return
|
||
sessions = await _load_recent_primary_sessions(
|
||
getattr(state.engine, "store", None),
|
||
_current_project_id(state.engine),
|
||
limit=20,
|
||
)
|
||
continue
|
||
if choice.isdigit():
|
||
idx = int(choice)
|
||
if 1 <= idx <= len(sessions):
|
||
await _resume_chat_session(state, str(getattr(sessions[idx - 1], "session_id", "") or ""))
|
||
return
|
||
console.print("[warning]Invalid session number.[/warning]")
|
||
continue
|
||
matching = next((item for item in sessions if str(getattr(item, "session_id", "") or "") == choice), None)
|
||
if matching:
|
||
await _resume_chat_session(state, choice)
|
||
return
|
||
console.print(f"[warning]Session not found: {choice}. Type `new` to create a new session.[/warning]")
|
||
|
||
|
||
async def _run_interactive_startup_selector(state: _InteractiveChatState, *, explicit_project: bool = False) -> None:
|
||
await _choose_initial_project(state, explicit_project=explicit_project)
|
||
state.session_id = ""
|
||
await _choose_initial_session(state)
|
||
profile = f" company_profile={state.company_profile}" if state.mode == "company" else ""
|
||
console.print(
|
||
f"[success]Ready:[/success] project={_current_project_id(state.engine)} "
|
||
f"session={state.session_id} mode={state.mode}{profile} agent={state.preferred_agent or 'system'}"
|
||
)
|
||
_print_chat_hint()
|
||
|
||
|
||
async def _handle_project_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print(f"[info]Current project: {_current_project_id(state.engine)}[/info]")
|
||
_render_project_list(state)
|
||
console.print("[dim]Use /project list, /project switch <id>, /project <id>, /project create <id>, /project rename <old_id> <new_id>, or /project delete <id> --yes.[/dim]")
|
||
return
|
||
subcommand = args[0].lower()
|
||
if subcommand in {"list", "ls"}:
|
||
_render_project_list(state)
|
||
return
|
||
if subcommand in {"create", "new"}:
|
||
if len(args) < 2:
|
||
console.print("[warning]Usage: /project create <id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.project.create(args[1], active_project_id=_current_project_id(state.engine)))
|
||
if payload:
|
||
console.print(f"[success]Created project: {payload.get('project_id')}[/success]")
|
||
return
|
||
if subcommand in {"rename", "mv"}:
|
||
if len(args) < 3:
|
||
console.print("[warning]Usage: /project rename <old_id> <new_id>[/warning]")
|
||
return
|
||
old_id, new_id = args[1], args[2]
|
||
renaming_current = old_id == _current_project_id(state.engine)
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.project.rename(old_id, new_id))
|
||
if payload:
|
||
console.print(f"[success]Renamed project:[/success] {payload.get('old_project_id')} -> {payload.get('new_project_id') or payload.get('project_id')}")
|
||
if renaming_current:
|
||
await _switch_chat_project(state, new_id)
|
||
return
|
||
if subcommand in {"delete", "rm"}:
|
||
if len(args) < 2:
|
||
console.print("[warning]Usage: /project delete <id> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(args[2:], usage="/project delete <id> --yes")
|
||
if remaining or not ok:
|
||
return
|
||
deleting_current = args[1] == _current_project_id(state.engine)
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.project.delete(args[1]))
|
||
if payload:
|
||
console.print(f"[success]Deleted project: {payload.get('project_id')}[/success]")
|
||
if deleting_current:
|
||
await _switch_chat_project(state, "default")
|
||
return
|
||
if subcommand in {"switch", "use"}:
|
||
if len(args) < 2:
|
||
console.print("[warning]Usage: /project switch <id>[/warning]")
|
||
return
|
||
await _switch_chat_project(state, args[1])
|
||
return
|
||
if len(args) == 1:
|
||
await _switch_chat_project(state, args[0])
|
||
return
|
||
console.print("[warning]Usage: /project [list|create <id>|rename <old_id> <new_id>|switch <id>|delete <id> --yes][/warning]")
|
||
|
||
|
||
async def _render_sessions(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Session store is not available.[/warning]")
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
if args and args[0].isdigit():
|
||
limit = _coerce_limit(args[0])
|
||
args = args[1:]
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if args:
|
||
console.print("[warning]Usage: /session list [limit][/warning]")
|
||
return
|
||
sessions = await state.engine.store.list_sessions(
|
||
project_id=_current_project_id(state.engine),
|
||
parent_session_id=None,
|
||
limit=limit,
|
||
)
|
||
if not sessions:
|
||
console.print("[info]No sessions found for this project.[/info]")
|
||
return
|
||
table = Table(title=f"Recent Sessions ({len(sessions)})")
|
||
table.add_column("Session ID")
|
||
table.add_column("Current", justify="center")
|
||
table.add_column("Title")
|
||
table.add_column("Mode")
|
||
table.add_column("Status")
|
||
table.add_column("Updated")
|
||
current_session = str(state.session_id or "")
|
||
for item in sessions:
|
||
session_id = str(getattr(item, "session_id", "") or "")
|
||
table.add_row(
|
||
session_id,
|
||
"*" if session_id == current_session else "",
|
||
_clip_text(getattr(item, "title", "") or "(untitled)", 80, full=full),
|
||
str(getattr(item, "mode", "") or ""),
|
||
str(getattr(item, "status", "") or ""),
|
||
_format_datetime(getattr(item, "updated_at", None)),
|
||
)
|
||
console.print(table)
|
||
console.print("[dim]Switch with /session resume <session_id> or /session <session_id>. Create with /session new.[/dim]")
|
||
|
||
|
||
async def _resume_chat_session(state: _InteractiveChatState, session_id: str) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Session store is not available.[/warning]")
|
||
return
|
||
if not session_id:
|
||
console.print("[warning]Usage: /session resume <session_id>[/warning]")
|
||
return
|
||
target = await state.engine.store.get_session(session_id)
|
||
if not target:
|
||
console.print(f"[warning]Session not found: {session_id}[/warning]")
|
||
return
|
||
current_project = _current_project_id(state.engine)
|
||
target_project = str(getattr(target, "project_id", "") or "default")
|
||
if target_project != current_project:
|
||
console.print(
|
||
"[warning]"
|
||
f"Session {session_id} belongs to project '{target_project}'. "
|
||
f"Switch project first with /project {target_project}."
|
||
"[/warning]"
|
||
)
|
||
return
|
||
state.session_id = session_id
|
||
title = getattr(target, "title", "") or "(untitled)"
|
||
console.print(f"[info]Resumed session: {state.session_id} :: {title}[/info]")
|
||
|
||
|
||
def _start_new_chat_session(state: _InteractiveChatState) -> None:
|
||
state.session_id = str(uuid.uuid4())
|
||
console.print(f"[info]Started a new session: {state.session_id}[/info]")
|
||
|
||
|
||
def _render_transcript_parts(parts: list[Any]) -> str:
|
||
lines: list[str] = []
|
||
for part in parts:
|
||
part_type = getattr(part, "part_type", "")
|
||
payload = getattr(part, "payload", {})
|
||
if isinstance(part, dict):
|
||
part_type = str(part.get("part_type", part_type) or "")
|
||
payload = part.get("payload", payload)
|
||
if not isinstance(payload, dict):
|
||
payload = {}
|
||
if part_type == "text":
|
||
text = payload.get("text", "")
|
||
if text:
|
||
lines.append(str(text))
|
||
elif part_type in {"subtask_result", "task_result"}:
|
||
title = payload.get("task_title", "Task")
|
||
summary = payload.get("summary", "")
|
||
lines.append(f"{title}: {summary}".strip(": "))
|
||
return "\n".join(line for line in lines if line).strip()
|
||
|
||
|
||
def _render_transcript_table(transcript: list[dict[str, Any]], *, limit: int) -> None:
|
||
rows: list[tuple[str, str, str, str]] = []
|
||
for item in transcript:
|
||
message = item.get("message") if isinstance(item, dict) else None
|
||
if not message or getattr(message, "summary_flag", False):
|
||
continue
|
||
content = _render_transcript_parts(item.get("parts", []))
|
||
if not content:
|
||
continue
|
||
role = str(getattr(message, "role", "") or "").strip().lower()
|
||
agent_id = str(getattr(message, "agent_id", "") or "").strip()
|
||
sender = {
|
||
"user": "You",
|
||
"assistant": "OPC",
|
||
"system": "System",
|
||
"subagent": agent_id.replace("_", " ").replace("-", " ").title() if agent_id else "Subagent",
|
||
}.get(role, agent_id or role.title() or "OPC")
|
||
rows.append((
|
||
_format_datetime(getattr(message, "created_at", None)),
|
||
role or "assistant",
|
||
sender,
|
||
_clip_text(content),
|
||
))
|
||
rows = rows[-limit:]
|
||
if not rows:
|
||
console.print("[info]No transcript messages found.[/info]")
|
||
return
|
||
table = Table(title=f"Transcript (last {len(rows)})")
|
||
table.add_column("Created")
|
||
table.add_column("Role")
|
||
table.add_column("Sender")
|
||
table.add_column("Content")
|
||
for row in rows:
|
||
table.add_row(*row)
|
||
console.print(table)
|
||
|
||
|
||
async def _resolve_session_or_task(state: _InteractiveChatState, token: str) -> tuple[Any | None, Any | None, str]:
|
||
store = getattr(state.engine, "store", None)
|
||
if not store:
|
||
return None, None, ""
|
||
session = await store.get_session(token) if hasattr(store, "get_session") else None
|
||
task = None
|
||
session_id = str(getattr(session, "session_id", "") or "") if session else ""
|
||
if not session and hasattr(store, "get_task"):
|
||
task = await store.get_task(token)
|
||
session_id = str(getattr(task, "session_id", "") or "") if task else ""
|
||
if session_id and hasattr(store, "get_session"):
|
||
session = await store.get_session(session_id)
|
||
return session, task, session_id
|
||
|
||
|
||
async def _resolve_runtime_control_target(state: _InteractiveChatState, target: str = "") -> tuple[str, str]:
|
||
store = _require_chat_store(state, label="Session store")
|
||
if store is None:
|
||
return "", ""
|
||
raw_target = str(target or "").strip() or str(state.session_id or "").strip()
|
||
if not raw_target:
|
||
console.print("[warning]No current session. Use /session list or /session create first.[/warning]")
|
||
return "", ""
|
||
task = await store.get_task(raw_target) if hasattr(store, "get_task") else None
|
||
if task is not None:
|
||
project_id = str(getattr(task, "project_id", "") or "default")
|
||
if project_id != _current_project_id(state.engine):
|
||
console.print(f"[warning]Target belongs to project '{project_id}'. Switch project first.[/warning]")
|
||
return "", ""
|
||
return str(getattr(task, "id", "") or ""), str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "")
|
||
session = await store.get_session(raw_target) if hasattr(store, "get_session") else None
|
||
if session is None:
|
||
console.print(f"[warning]Task or session not found: {raw_target}[/warning]")
|
||
return "", ""
|
||
project_id = str(getattr(session, "project_id", "") or "default")
|
||
if project_id != _current_project_id(state.engine):
|
||
console.print(f"[warning]Target belongs to project '{project_id}'. Switch project first.[/warning]")
|
||
return "", ""
|
||
if raw_target in state.session_to_task:
|
||
return state.session_to_task[raw_target], raw_target
|
||
tasks = await store.get_tasks(project_id=project_id) if hasattr(store, "get_tasks") else []
|
||
candidates = [
|
||
item for item in tasks
|
||
if str(getattr(item, "session_id", "") or "") == raw_target
|
||
]
|
||
if not candidates:
|
||
console.print(f"[warning]Session is not task-backed: {raw_target}[/warning]")
|
||
return "", raw_target
|
||
candidates.sort(key=lambda item: bool(str(getattr(item, "parent_session_id", "") or "")))
|
||
return str(getattr(candidates[0], "id", "") or ""), raw_target
|
||
|
||
|
||
def _make_cli_runtime_control_context(state: _InteractiveChatState, controller: ChatTurnController | None = None) -> Any:
|
||
from opc.plugins.office_ui.services import ModeState, OfficeServiceContext
|
||
|
||
context = OfficeServiceContext(
|
||
engine=state.engine,
|
||
agent_store=None,
|
||
chat_store=None,
|
||
event_adapter=None,
|
||
mode_state=ModeState(
|
||
exec_mode=state.mode,
|
||
company_profile=state.company_profile,
|
||
task_preferred_agent=state.preferred_agent or "native",
|
||
),
|
||
)
|
||
context.session_to_task = state.session_to_task
|
||
context.active_runtime_children = state.active_runtime_children
|
||
|
||
def cancel_session_tasks(_task_id: str) -> None:
|
||
if controller is not None and controller.active_task is not None and not controller.active_task.done():
|
||
controller.active_task.cancel()
|
||
|
||
context.cancel_session_tasks = cancel_session_tasks
|
||
return context
|
||
|
||
|
||
async def _latest_company_suspend_checkpoint(state: _InteractiveChatState) -> Any | None:
|
||
session_id = str(state.session_id or "").strip()
|
||
if not session_id:
|
||
return None
|
||
for name in ("get_active_company_runtime_suspend_checkpoint", "get_pending_company_runtime_suspend_checkpoint"):
|
||
getter = getattr(state.engine, name, None)
|
||
if callable(getter):
|
||
try:
|
||
checkpoint = await getter(session_id)
|
||
except Exception:
|
||
checkpoint = None
|
||
if checkpoint is not None and str(getattr(checkpoint, "status", "") or "pending") == "pending":
|
||
return checkpoint
|
||
store = getattr(state.engine, "store", None)
|
||
if store is not None and hasattr(store, "get_pending_checkpoints"):
|
||
try:
|
||
checkpoints = await store.get_pending_checkpoints(
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=session_id,
|
||
checkpoint_types=["company_runtime_suspended", "company_runtime_interrupted"],
|
||
)
|
||
except TypeError:
|
||
checkpoints = await store.get_pending_checkpoints(project_id=_current_project_id(state.engine), session_id=session_id)
|
||
except Exception:
|
||
checkpoints = []
|
||
for checkpoint in list(checkpoints or []):
|
||
if str(getattr(checkpoint, "checkpoint_type", "") or "") in {"company_runtime_suspended", "company_runtime_interrupted"}:
|
||
return checkpoint
|
||
return None
|
||
|
||
|
||
async def _handle_stop_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
|
||
if len(args) > 1:
|
||
console.print("[warning]Usage: /stop [task_id|session_id][/warning]")
|
||
return
|
||
target = args[0] if args else ""
|
||
task_id, session_id = await _resolve_runtime_control_target(state, target)
|
||
if not task_id:
|
||
return
|
||
from opc.plugins.office_ui.services import OfficeServices, ServiceError
|
||
|
||
context = _make_cli_runtime_control_context(state, controller)
|
||
try:
|
||
result = await OfficeServices(context).session.stop(project_id=_current_project_id(state.engine), target=target or session_id or task_id)
|
||
except ServiceError as exc:
|
||
console.print(f"[warning]{escape(exc.message)}[/warning]")
|
||
return
|
||
payload = dict(result.payload)
|
||
state.runtime_control_state = str(payload.get("runtime_control_state") or payload.get("status") or "stopped")
|
||
state.runtime_control_task_id = str(payload.get("resume_parent_task_id") or payload.get("task_id") or task_id)
|
||
state.runtime_control_session_id = str(payload.get("resume_parent_session_id") or payload.get("session_id") or session_id)
|
||
state.runtime_control_checkpoint_id = str(payload.get("checkpoint_id") or "")
|
||
console.print("[success]Stopped.[/success] [dim]Send a message to revise, or /continue to resume.[/dim]")
|
||
|
||
|
||
async def _runtime_control_identity_for_task(state: _InteractiveChatState, task_id: str) -> Any:
|
||
from opc.plugins.office_ui.execution_identity import execution_identity_from_task
|
||
|
||
store = getattr(state.engine, "store", None)
|
||
task = None
|
||
if store is not None and hasattr(store, "get_task") and task_id:
|
||
try:
|
||
task = await store.get_task(task_id)
|
||
except Exception:
|
||
task = None
|
||
return execution_identity_from_task(
|
||
task,
|
||
default_exec_mode=state.mode,
|
||
default_company_profile=state.company_profile,
|
||
default_preferred_agent=state.preferred_agent or "native",
|
||
default_org_id=state.org_id,
|
||
)
|
||
|
||
|
||
async def _handle_continue_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
|
||
target = ""
|
||
message_parts = list(args)
|
||
if message_parts:
|
||
first = message_parts[0]
|
||
store = getattr(state.engine, "store", None)
|
||
is_target = False
|
||
if store is not None:
|
||
try:
|
||
is_target = bool((hasattr(store, "get_task") and await store.get_task(first)) or (hasattr(store, "get_session") and await store.get_session(first)))
|
||
except Exception:
|
||
is_target = False
|
||
if is_target:
|
||
target = message_parts.pop(0)
|
||
task_id, session_id = await _resolve_runtime_control_target(state, target)
|
||
if not task_id:
|
||
return
|
||
identity = await _runtime_control_identity_for_task(state, task_id)
|
||
content = " ".join(message_parts).strip() or "Resume the existing runtime."
|
||
if controller is not None and controller.is_busy:
|
||
checkpoint = await _latest_company_suspend_checkpoint(state)
|
||
if checkpoint is None and state.runtime_control_state not in {"suspended", "stopped"}:
|
||
console.print("[warning]Busy: wait for the current turn or /stop it before /continue.[/warning]")
|
||
return
|
||
item = QueuedChatInput(
|
||
text=content,
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=session_id or state.session_id,
|
||
mode=identity.exec_mode,
|
||
company_profile=identity.company_profile,
|
||
org_id=identity.org_id,
|
||
preferred_agent=identity.preferred_agent,
|
||
domains=list(state.domains),
|
||
message_metadata={"ui_force_resume": True},
|
||
)
|
||
state.runtime_control_state = "resuming"
|
||
state.runtime_control_task_id = task_id
|
||
state.runtime_control_session_id = session_id or state.session_id
|
||
state.runtime_control_checkpoint_id = ""
|
||
if controller is not None:
|
||
await controller.submit_item(item)
|
||
else:
|
||
previous_mode = state.mode
|
||
previous_profile = state.company_profile
|
||
previous_org_id = state.org_id
|
||
previous_agent = state.preferred_agent
|
||
state.mode = identity.exec_mode
|
||
state.company_profile = identity.company_profile
|
||
state.org_id = identity.org_id
|
||
state.preferred_agent = identity.preferred_agent
|
||
try:
|
||
await _process_interactive_chat_message(state, content, message_metadata={"ui_force_resume": True})
|
||
finally:
|
||
state.mode = previous_mode
|
||
state.company_profile = previous_profile
|
||
state.org_id = previous_org_id
|
||
state.preferred_agent = previous_agent
|
||
|
||
|
||
async def _show_session_detail(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Session store is not available.[/warning]")
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if not args:
|
||
console.print("[warning]Usage: /session show <session_id|task_id> [--limit N] [--full][/warning]")
|
||
return
|
||
session, task, session_id = await _resolve_session_or_task(state, args[0])
|
||
if not session_id:
|
||
console.print(f"[warning]Session or task not found: {args[0]}[/warning]")
|
||
return
|
||
project_id = str(getattr(session, "project_id", None) or getattr(task, "project_id", None) or "default")
|
||
if project_id != _current_project_id(state.engine):
|
||
console.print(f"[warning]Target belongs to project '{project_id}'. Switch project first.[/warning]")
|
||
return
|
||
table = Table(title=f"Session {session_id}", show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
table.add_row("Session ID", session_id)
|
||
table.add_row("Project", project_id)
|
||
if task:
|
||
table.add_row("Task", str(getattr(task, "id", "") or ""))
|
||
table.add_row("Task title", _clip_text(getattr(task, "title", "") or "(untitled)", 120, full=full))
|
||
if session:
|
||
table.add_row("Title", _clip_text(getattr(session, "title", "") or "(untitled)", 120, full=full))
|
||
table.add_row("Mode", str(getattr(session, "mode", "") or ""))
|
||
table.add_row("Status", str(getattr(session, "status", "") or ""))
|
||
table.add_row("Updated", _format_datetime(getattr(session, "updated_at", None)))
|
||
console.print(table)
|
||
transcript = await state.engine.store.get_session_transcript(session_id)
|
||
_render_transcript_table(transcript, limit=limit)
|
||
|
||
|
||
async def _handle_session_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
|
||
if not args:
|
||
console.print(f"[info]Current session: {state.session_id}[/info]")
|
||
await _render_sessions(state, [])
|
||
console.print("[dim]Use /session list, /session resume <session_id>, /session <session_id>, /session new, /session create [title], or /session delete <task_id> --yes.[/dim]")
|
||
return
|
||
subcommand = args[0].lower()
|
||
rest = args[1:]
|
||
known_subcommands = {
|
||
"list", "ls", "new", "create", "resume", "show", "config", "send", "rename", "delete", "stop", "continue", "complete",
|
||
}
|
||
if len(args) == 1 and subcommand not in known_subcommands:
|
||
await _resume_chat_session(state, args[0])
|
||
return
|
||
if subcommand in {"list", "ls"}:
|
||
await _render_sessions(state, rest)
|
||
return
|
||
if subcommand == "new":
|
||
_start_new_chat_session(state)
|
||
return
|
||
if subcommand == "create":
|
||
try:
|
||
rest, mode = _extract_option(rest, "--mode", default=state.mode)
|
||
rest, profile = _extract_option(rest, "--company-profile", default=state.company_profile)
|
||
rest, agent = _extract_option(rest, "--agent", default=state.preferred_agent)
|
||
rest, org_id = _extract_option(rest, "--org", default=None)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
title = " ".join(rest).strip() or "New Chat"
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.session.create(
|
||
project_id=_current_project_id(state.engine),
|
||
title=title,
|
||
exec_mode=mode,
|
||
company_profile=profile,
|
||
preferred_agent=agent,
|
||
org_id=org_id,
|
||
interface="cli",
|
||
),
|
||
)
|
||
if payload:
|
||
state.session_id = str(payload.get("session_id") or state.session_id)
|
||
console.print(f"[success]Created session:[/success] {state.session_id} task={payload.get('task_id')}")
|
||
return
|
||
if subcommand == "resume":
|
||
await _resume_chat_session(state, rest[0] if rest else "")
|
||
return
|
||
if subcommand == "show":
|
||
await _show_session_detail(state, rest)
|
||
return
|
||
if subcommand == "config":
|
||
if not rest:
|
||
console.print("[warning]Usage: /session config <task_id> [--mode ...] [--agent ...] [--org ...][/warning]")
|
||
return
|
||
task_id = rest[0]
|
||
opts = rest[1:]
|
||
try:
|
||
opts, mode = _extract_option(opts, "--mode", default=None)
|
||
opts, profile = _extract_option(opts, "--company-profile", default=None)
|
||
opts, agent = _extract_option(opts, "--agent", default=None)
|
||
opts, org_id = _extract_option(opts, "--org", default=None)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if opts:
|
||
console.print("[warning]Usage: /session config <task_id> [--mode ...] [--agent ...] [--org ...][/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.session.update_config(
|
||
project_id=_current_project_id(state.engine),
|
||
task_id=task_id,
|
||
exec_mode=mode,
|
||
company_profile=profile,
|
||
preferred_agent=agent,
|
||
org_id=org_id,
|
||
),
|
||
)
|
||
if payload:
|
||
console.print(f"[success]Updated session config:[/success] {task_id}")
|
||
return
|
||
if subcommand == "send":
|
||
if len(rest) < 2:
|
||
console.print("[warning]Usage: /session send <task_id> <message>[/warning]")
|
||
return
|
||
task_id = rest[0]
|
||
message = " ".join(rest[1:]).strip()
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.session.send(
|
||
project_id=_current_project_id(state.engine),
|
||
task_id=task_id,
|
||
content=message,
|
||
mode=state.mode,
|
||
company_profile=state.company_profile,
|
||
preferred_agent=state.preferred_agent,
|
||
domains=list(state.domains),
|
||
),
|
||
)
|
||
if payload and payload.get("response"):
|
||
_print_response(str(payload["response"]), state.no_markdown)
|
||
return
|
||
if subcommand == "rename":
|
||
if len(rest) < 2:
|
||
console.print("[warning]Usage: /session rename <task_id|session_id> <title>[/warning]")
|
||
return
|
||
await _rename_task_and_session(state, rest[0], " ".join(rest[1:]))
|
||
return
|
||
if subcommand == "delete":
|
||
if not rest:
|
||
console.print("[warning]Usage: /session delete <task_id> --yes[/warning]")
|
||
return
|
||
await _delete_task_and_session(state, rest[0], rest[1:], usage="/session delete <task_id> --yes")
|
||
return
|
||
if subcommand == "stop":
|
||
await _handle_stop_slash(state, rest, controller=controller)
|
||
return
|
||
if subcommand == "continue":
|
||
await _handle_continue_slash(state, rest, controller=controller)
|
||
return
|
||
if subcommand == "complete":
|
||
if not rest:
|
||
console.print("[warning]Usage: /session complete <task_id>[/warning]")
|
||
return
|
||
operation = lambda svc: svc.session.complete(project_id=_current_project_id(state.engine), task_id=rest[0])
|
||
payload = await _run_chat_office_service(state, operation)
|
||
if payload:
|
||
console.print(f"[success]Completed session:[/success] {rest[0]}")
|
||
return
|
||
console.print("[warning]Usage: /session [list|new|create|resume <session_id>|show <session_id|task_id>|config <task_id>|send <task_id> <message>|rename <task_id|session_id> <title>|delete <task_id> --yes|stop [target]|continue [target] [message]|complete <task_id>][/warning]")
|
||
|
||
|
||
def _task_title(task: Any) -> str:
|
||
return _clip_text(getattr(task, "title", "") or getattr(task, "description", "") or "(untitled)", 80)
|
||
|
||
|
||
def _task_title_for_view(task: Any, *, full: bool = False) -> str:
|
||
return _clip_text(getattr(task, "title", "") or getattr(task, "description", "") or "(untitled)", 80, full=full)
|
||
|
||
|
||
def _require_chat_store(state: _InteractiveChatState, *, label: str = "Task store") -> Any | None:
|
||
store = getattr(state.engine, "store", None)
|
||
if not store:
|
||
console.print(f"[warning]{label} is not available.[/warning]")
|
||
return None
|
||
return store
|
||
|
||
|
||
async def _get_task_for_current_project(state: _InteractiveChatState, task_id: str) -> Any | None:
|
||
store = _require_chat_store(state)
|
||
if store is None:
|
||
return None
|
||
if not task_id:
|
||
console.print("[warning]Missing task id.[/warning]")
|
||
return None
|
||
task = await store.get_task(task_id)
|
||
if not task:
|
||
console.print(f"[warning]Task not found: {task_id}[/warning]")
|
||
return None
|
||
project_id = str(getattr(task, "project_id", "") or "default")
|
||
if project_id != _current_project_id(state.engine):
|
||
console.print(f"[warning]Task belongs to project '{project_id}'. Switch project first with /project {project_id}.[/warning]")
|
||
return None
|
||
return task
|
||
|
||
|
||
def _parse_delete_yes(args: list[str], *, usage: str) -> bool:
|
||
unknown = [arg for arg in args if arg != "--yes"]
|
||
if unknown:
|
||
console.print(f"[warning]Usage: {usage}[/warning]")
|
||
return False
|
||
if "--yes" not in args:
|
||
console.print(f"[warning]Destructive command requires --yes. Usage: {usage}[/warning]")
|
||
return False
|
||
return True
|
||
|
||
|
||
def _extract_option(args: list[str], name: str, *, default: str | None = None) -> tuple[list[str], str | None]:
|
||
remaining: list[str] = []
|
||
value = default
|
||
idx = 0
|
||
prefix = f"{name}="
|
||
while idx < len(args):
|
||
token = args[idx]
|
||
if token == name:
|
||
if idx + 1 >= len(args):
|
||
raise ValueError(f"Missing value for {name}.")
|
||
value = args[idx + 1]
|
||
idx += 2
|
||
continue
|
||
if token.startswith(prefix):
|
||
value = token.split("=", 1)[1]
|
||
idx += 1
|
||
continue
|
||
remaining.append(token)
|
||
idx += 1
|
||
return remaining, value
|
||
|
||
|
||
def _require_yes_arg(args: list[str], *, usage: str) -> tuple[list[str], bool]:
|
||
remaining = [arg for arg in args if arg != "--yes"]
|
||
if "--yes" not in args:
|
||
console.print(f"[warning]Destructive command requires --yes. Usage: {usage}[/warning]")
|
||
return remaining, False
|
||
return remaining, True
|
||
|
||
|
||
def _save_cli_config(state: _InteractiveChatState) -> None:
|
||
state.config.save(get_opc_home() / "config")
|
||
|
||
|
||
def _refresh_cli_org_runtime(state: _InteractiveChatState) -> None:
|
||
engine = state.engine
|
||
if hasattr(engine, "config"):
|
||
engine.config = state.config
|
||
market = getattr(engine, "talent_market", None)
|
||
if market is not None and hasattr(market, "config"):
|
||
market.config = state.config
|
||
org_engine = getattr(engine, "org_engine", None)
|
||
if org_engine is None:
|
||
return
|
||
try:
|
||
if hasattr(org_engine, "config"):
|
||
org_engine.config = state.config
|
||
reload_from_config = getattr(org_engine, "reload_from_config", None)
|
||
if callable(reload_from_config):
|
||
reload_from_config()
|
||
configure_tools = getattr(org_engine, "configure_task_mode_tools", None)
|
||
tool_names = getattr(engine, "_task_mode_tool_names", None)
|
||
if callable(configure_tools) and callable(tool_names):
|
||
configure_tools(tool_names())
|
||
except Exception as exc:
|
||
console.print(f"[warning]Saved config, but current org runtime refresh failed: {exc}. Restart opc chat if needed.[/warning]")
|
||
|
||
|
||
def _coerce_task_status(value: Any) -> Any | None:
|
||
from opc.core.models import TaskStatus
|
||
|
||
raw = value.value if hasattr(value, "value") else str(value or "")
|
||
try:
|
||
return TaskStatus(raw)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
async def _transition_task_status(
|
||
state: _InteractiveChatState,
|
||
*,
|
||
task_id: str,
|
||
target_status: Any,
|
||
reason: str,
|
||
target_label: str,
|
||
) -> None:
|
||
from opc.core.models import TaskStatus
|
||
from opc.layer2_organization.work_item_transition import apply_task_status_transition
|
||
|
||
store = _require_chat_store(state)
|
||
if store is None:
|
||
return
|
||
task = await _get_task_for_current_project(state, task_id)
|
||
if task is None:
|
||
return
|
||
|
||
old_status = _coerce_task_status(getattr(task, "status", None))
|
||
terminal_statuses = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||
if old_status in terminal_statuses and target_status not in terminal_statuses:
|
||
old_label = old_status.value if old_status else _value_text(getattr(task, "status", ""))
|
||
console.print(f"[warning]Cannot move terminal task {old_label} back to {target_label}.[/warning]")
|
||
return
|
||
|
||
old_label = _value_text(getattr(task, "status", ""))
|
||
try:
|
||
await apply_task_status_transition(
|
||
store,
|
||
task,
|
||
target_status_or_phase=target_status,
|
||
reason=reason,
|
||
)
|
||
except Exception as exc:
|
||
console.print(f"[error]Failed to transition task {task_id}: {exc}[/error]")
|
||
return
|
||
console.print(f"[success]Task {task_id} moved: {old_label} -> {target_status.value}[/success]")
|
||
|
||
|
||
async def _rename_task_and_session(
|
||
state: _InteractiveChatState,
|
||
target: str,
|
||
title: str,
|
||
*,
|
||
usage: str = "/session rename <task_id|session_id> <title>",
|
||
) -> None:
|
||
store = _require_chat_store(state)
|
||
if store is None:
|
||
return
|
||
title = str(title or "").strip()
|
||
if not title:
|
||
console.print(f"[warning]Usage: {usage}[/warning]")
|
||
return
|
||
target = str(target or "").strip()
|
||
if not target:
|
||
console.print(f"[warning]Usage: {usage}[/warning]")
|
||
return
|
||
current_project = _current_project_id(state.engine)
|
||
task = await store.get_task(target) if hasattr(store, "get_task") else None
|
||
if task and str(getattr(task, "project_id", "") or "default") != current_project:
|
||
console.print(f"[warning]Task belongs to project '{getattr(task, 'project_id', '')}'. Switch project first with /project {getattr(task, 'project_id', '')}.[/warning]")
|
||
return
|
||
session = None
|
||
session_id = ""
|
||
if task is None and hasattr(store, "get_session"):
|
||
session = await store.get_session(target)
|
||
if session and str(getattr(session, "project_id", "") or "default") != current_project:
|
||
console.print(f"[warning]Session belongs to project '{getattr(session, 'project_id', '')}'. Switch project first with /project {getattr(session, 'project_id', '')}.[/warning]")
|
||
return
|
||
if session and hasattr(store, "get_tasks"):
|
||
session_id = str(getattr(session, "session_id", "") or target)
|
||
tasks = await store.get_tasks(project_id=current_project)
|
||
task = next((item for item in tasks if str(getattr(item, "session_id", "") or "") == session_id), None)
|
||
if task is None and session is None:
|
||
console.print(f"[warning]Task or session not found: {target}[/warning]")
|
||
return
|
||
if task is not None:
|
||
if not hasattr(store, "save_task"):
|
||
console.print("[warning]Task store cannot save tasks.[/warning]")
|
||
return
|
||
task.title = title
|
||
await store.save_task(task)
|
||
session_id = str(getattr(task, "session_id", "") or session_id)
|
||
memory = getattr(state.engine, "memory", None)
|
||
if session_id and memory and hasattr(memory, "update_session_title"):
|
||
await memory.update_session_title(session_id, title)
|
||
elif session is not None and hasattr(store, "save_session"):
|
||
session.title = title
|
||
await store.save_session(session)
|
||
session_id = str(getattr(session, "session_id", "") or session_id)
|
||
task_id = str(getattr(task, "id", "") or "") if task is not None else ""
|
||
console.print(f"[success]Renamed {f'task {task_id}' if task_id else 'session'}{f' / session {session_id}' if session_id else ''}: {title}[/success]")
|
||
|
||
|
||
async def _delete_task_and_session(state: _InteractiveChatState, task_id: str, args: list[str], *, usage: str) -> None:
|
||
store = _require_chat_store(state)
|
||
if store is None:
|
||
return
|
||
if not _parse_delete_yes(args, usage=usage):
|
||
return
|
||
if not hasattr(store, "hard_delete_task"):
|
||
console.print("[warning]Task store cannot hard-delete tasks.[/warning]")
|
||
return
|
||
task = await _get_task_for_current_project(state, task_id)
|
||
if task is None:
|
||
return
|
||
session_id = str(getattr(task, "session_id", "") or "")
|
||
await store.hard_delete_task(task_id, session_id or None)
|
||
console.print(f"[success]Deleted task {task_id}{f' / session {session_id}' if session_id else ''}.[/success]")
|
||
|
||
|
||
async def _handle_task_move_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if len(args) != 2:
|
||
console.print("[warning]Usage: /task move <task_id> todo|in-progress|done|blocked|failed|cancelled[/warning]")
|
||
return
|
||
from opc.presentation.kanban import column_to_task_status
|
||
|
||
target_label = args[1].strip().lower().replace("_", "-")
|
||
target_status = column_to_task_status(target_label)
|
||
if target_status is None:
|
||
console.print("[warning]Unsupported target. Use todo, in-progress, done, blocked, failed, or cancelled.[/warning]")
|
||
return
|
||
await _transition_task_status(
|
||
state,
|
||
task_id=args[0],
|
||
target_status=target_status,
|
||
reason="cli_chat_task_move",
|
||
target_label=target_label,
|
||
)
|
||
|
||
|
||
async def _handle_task_done_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if len(args) != 1:
|
||
console.print("[warning]Usage: /task done <task_id>[/warning]")
|
||
return
|
||
from opc.core.models import TaskStatus
|
||
|
||
await _transition_task_status(
|
||
state,
|
||
task_id=args[0],
|
||
target_status=TaskStatus.DONE,
|
||
reason="cli_chat_task_done",
|
||
target_label="done",
|
||
)
|
||
|
||
|
||
async def _handle_tasks_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Task store is not available.[/warning]")
|
||
return
|
||
from opc.core.models import TaskStatus
|
||
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
if args and args[0].isdigit():
|
||
limit = _coerce_limit(args[0])
|
||
args = args[1:]
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
status = None
|
||
if args:
|
||
status_token = args[0].strip().lower().replace("-", "_")
|
||
try:
|
||
status = TaskStatus(status_token)
|
||
except ValueError:
|
||
valid = ", ".join(item.value for item in TaskStatus)
|
||
console.print(f"[warning]Unknown task status '{args[0]}'. Valid: {valid}[/warning]")
|
||
return
|
||
tasks = await state.engine.store.get_tasks(project_id=_current_project_id(state.engine), status=status)
|
||
tasks = tasks[:limit]
|
||
if not tasks:
|
||
console.print("[info]No tasks found for this project.[/info]")
|
||
return
|
||
table = Table(title=f"Tasks ({len(tasks)})")
|
||
table.add_column("Task ID")
|
||
table.add_column("Title")
|
||
table.add_column("Status")
|
||
table.add_column("Priority", justify="right")
|
||
table.add_column("Assigned")
|
||
table.add_column("Session ID")
|
||
table.add_column("Created")
|
||
for task in tasks:
|
||
table.add_row(
|
||
str(getattr(task, "id", "") or ""),
|
||
_task_title_for_view(task, full=full),
|
||
_value_text(getattr(task, "status", "")),
|
||
str(getattr(task, "priority", "") or ""),
|
||
str(getattr(task, "assigned_to", "") or ""),
|
||
str(getattr(task, "session_id", "") or ""),
|
||
_format_datetime(getattr(task, "created_at", None)),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _show_task_detail(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Task store is not available.[/warning]")
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if not args:
|
||
console.print("[warning]Usage: /task show <task_id> [--limit N] [--full][/warning]")
|
||
return
|
||
task = await state.engine.store.get_task(args[0])
|
||
if not task:
|
||
console.print(f"[warning]Task not found: {args[0]}[/warning]")
|
||
return
|
||
project_id = str(getattr(task, "project_id", "") or "default")
|
||
if project_id != _current_project_id(state.engine):
|
||
console.print(f"[warning]Task belongs to project '{project_id}'. Switch project first.[/warning]")
|
||
return
|
||
table = Table(title=f"Task {getattr(task, 'id', '')}", show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
table.add_row("Title", _clip_text(getattr(task, "title", "") or "(untitled)", 120, full=full))
|
||
table.add_row("Description", _clip_text(getattr(task, "description", "") or "", 240, full=full))
|
||
table.add_row("Status", _value_text(getattr(task, "status", "")))
|
||
table.add_row("Project", project_id)
|
||
table.add_row("Session", str(getattr(task, "session_id", "") or ""))
|
||
table.add_row("Assignee", str(getattr(task, "assigned_to", "") or ""))
|
||
table.add_row("Priority", str(getattr(task, "priority", "") or ""))
|
||
tags = getattr(task, "tags", []) or []
|
||
table.add_row("Tags", ", ".join(str(tag) for tag in tags) if tags else "")
|
||
result = getattr(task, "result", None) or {}
|
||
if isinstance(result, dict):
|
||
table.add_row("Result", _clip_text(result.get("content", "") or result.get("summary", ""), 240, full=full))
|
||
artifacts = result.get("artifacts", []) or []
|
||
if not isinstance(artifacts, list):
|
||
artifacts = [artifacts]
|
||
table.add_row("Artifacts", _clip_text(", ".join(str(item) for item in artifacts), 240, full=full))
|
||
metadata = getattr(task, "metadata", {}) or {}
|
||
table.add_row("Metadata keys", ", ".join(sorted(str(key) for key in metadata.keys())) if isinstance(metadata, dict) else "")
|
||
console.print(table)
|
||
session_id = str(getattr(task, "session_id", "") or "")
|
||
if session_id:
|
||
transcript = await state.engine.store.get_session_transcript(session_id)
|
||
_render_transcript_table(transcript, limit=limit)
|
||
|
||
|
||
async def _handle_task_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print("[warning]Usage: /task [show|move|done|rename|delete] ...[/warning]")
|
||
return
|
||
subcommand = args[0].lower()
|
||
if subcommand == "show":
|
||
await _show_task_detail(state, args[1:])
|
||
return
|
||
if subcommand == "move":
|
||
await _handle_task_move_slash(state, args[1:])
|
||
return
|
||
if subcommand == "done":
|
||
await _handle_task_done_slash(state, args[1:])
|
||
return
|
||
if subcommand == "rename":
|
||
rest = args[1:]
|
||
if len(rest) < 2:
|
||
console.print("[warning]Usage: /task rename <task_id> <title>[/warning]")
|
||
return
|
||
await _rename_task_and_session(state, rest[0], " ".join(rest[1:]), usage="/task rename <task_id> <title>")
|
||
return
|
||
if subcommand == "delete":
|
||
rest = args[1:]
|
||
if not rest:
|
||
console.print("[warning]Usage: /task delete <task_id> --yes[/warning]")
|
||
return
|
||
await _delete_task_and_session(state, rest[0], rest[1:], usage="/task delete <task_id> --yes")
|
||
return
|
||
await _show_task_detail(state, args)
|
||
|
||
|
||
async def _handle_checkpoints_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Checkpoint store is not available.[/warning]")
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if args:
|
||
console.print("[warning]Usage: /checkpoints [--limit N] [--full][/warning]")
|
||
return
|
||
checkpoints = await state.engine.store.get_pending_checkpoints(project_id=_current_project_id(state.engine))
|
||
checkpoints = checkpoints[:limit]
|
||
if not checkpoints:
|
||
console.print("[info]No pending checkpoints for this project.[/info]")
|
||
return
|
||
table = Table(title=f"Pending Checkpoints ({len(checkpoints)})")
|
||
table.add_column("Checkpoint ID")
|
||
table.add_column("Type")
|
||
table.add_column("Task ID")
|
||
table.add_column("Session ID")
|
||
table.add_column("Updated")
|
||
table.add_column("Payload")
|
||
for checkpoint in checkpoints:
|
||
table.add_row(
|
||
str(getattr(checkpoint, "checkpoint_id", "") or ""),
|
||
str(getattr(checkpoint, "checkpoint_type", "") or ""),
|
||
str(getattr(checkpoint, "task_id", "") or ""),
|
||
str(getattr(checkpoint, "session_id", "") or ""),
|
||
_format_datetime(getattr(checkpoint, "updated_at", None)),
|
||
_json_summary(getattr(checkpoint, "payload", {}) or {}, full=full),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_org_slash(state: _InteractiveChatState) -> None:
|
||
org = state.config.org
|
||
summary = Table(title="Organization", show_header=False)
|
||
summary.add_column("Field", style="cyan", no_wrap=True)
|
||
summary.add_column("Value")
|
||
summary.add_row("Organization", str(getattr(org, "organization_name", "") or getattr(org, "company_name", "") or ""))
|
||
summary.add_row("Company", str(getattr(org, "company_name", "") or ""))
|
||
summary.add_row("Default mode", str(getattr(org, "default_mode", "") or ""))
|
||
summary.add_row("Company profile", str(getattr(org, "company_profile", "") or ""))
|
||
summary.add_row("Topology", str(getattr(org, "topology", "") or ""))
|
||
summary.add_row("Execution model", str(getattr(org, "execution_model", "") or ""))
|
||
summary.add_row("Final decider", str(getattr(org, "final_decider_role_id", "") or ""))
|
||
console.print(summary)
|
||
|
||
roles = list(getattr(org, "roles", []) or [])
|
||
if roles:
|
||
table = Table(title=f"Roles ({len(roles)})")
|
||
table.add_column("ID")
|
||
table.add_column("Name")
|
||
table.add_column("Reports To")
|
||
table.add_column("Type")
|
||
table.add_column("Agent")
|
||
table.add_column("Responsibility")
|
||
for role in roles:
|
||
table.add_row(
|
||
str(getattr(role, "id", "") or ""),
|
||
str(getattr(role, "name", "") or ""),
|
||
str(getattr(role, "reports_to", "") or ""),
|
||
str(getattr(role, "role_type", "") or ""),
|
||
str(getattr(role, "preferred_external_agent", "") or ""),
|
||
_clip_text(getattr(role, "responsibility", "") or "", 120),
|
||
)
|
||
console.print(table)
|
||
|
||
employees = list(getattr(org, "employees", []) or [])
|
||
if employees:
|
||
table = Table(title=f"Employees ({len(employees)})")
|
||
table.add_column("Employee ID")
|
||
table.add_column("Name")
|
||
table.add_column("Role")
|
||
table.add_column("Template")
|
||
table.add_column("Status")
|
||
table.add_column("Agent")
|
||
for employee in employees:
|
||
table.add_row(
|
||
str(getattr(employee, "employee_id", "") or ""),
|
||
str(getattr(employee, "name", "") or ""),
|
||
str(getattr(employee, "role_id", "") or ""),
|
||
str(getattr(employee, "template_id", "") or ""),
|
||
str(getattr(employee, "status", "") or ""),
|
||
str(getattr(employee, "preferred_external_agent", "") or ""),
|
||
)
|
||
console.print(table)
|
||
|
||
teams = list(getattr(org, "teams", []) or [])
|
||
if teams:
|
||
table = Table(title=f"Teams ({len(teams)})")
|
||
table.add_column("Team ID")
|
||
table.add_column("Name")
|
||
table.add_column("Description")
|
||
table.add_column("Seat IDs")
|
||
for team in teams:
|
||
table.add_row(
|
||
str(getattr(team, "team_id", "") or ""),
|
||
str(getattr(team, "name", "") or ""),
|
||
_clip_text(getattr(team, "description", "") or "", 100),
|
||
", ".join(str(item) for item in (getattr(team, "seat_ids", []) or [])),
|
||
)
|
||
console.print(table)
|
||
|
||
seats = []
|
||
for team in teams:
|
||
for seat in getattr(team, "seats", []) or []:
|
||
seats.append((team, seat))
|
||
if seats:
|
||
seat_table = Table(title=f"Seats ({len(seats)})")
|
||
seat_table.add_column("Team")
|
||
seat_table.add_column("Seat ID")
|
||
seat_table.add_column("Name")
|
||
seat_table.add_column("Role")
|
||
seat_table.add_column("Manager")
|
||
seat_table.add_column("Kind")
|
||
for team, seat in seats:
|
||
seat_table.add_row(
|
||
str(getattr(team, "team_id", "") or ""),
|
||
str(getattr(seat, "seat_id", "") or ""),
|
||
str(getattr(seat, "name", "") or ""),
|
||
str(getattr(seat, "role_id", "") or ""),
|
||
str(getattr(seat, "manager_role_id", "") or getattr(seat, "manager_seat_id", "") or ""),
|
||
str(getattr(seat, "seat_kind", "") or ""),
|
||
)
|
||
console.print(seat_table)
|
||
|
||
|
||
async def _handle_org_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args or args[0].lower() in {"info", "show"}:
|
||
if not args:
|
||
_render_org_slash(state)
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.info())
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
command = args[0].lower()
|
||
rest = args[1:]
|
||
|
||
if command == "role":
|
||
if not rest:
|
||
console.print("[warning]Usage: /org role add|update|delete|bulk-add ...[/warning]")
|
||
return
|
||
action = rest[0].lower()
|
||
values = rest[1:]
|
||
if action == "add":
|
||
try:
|
||
values, name = _extract_option(values, "--name")
|
||
values, responsibility = _extract_option(values, "--responsibility", default="")
|
||
values, reports_to = _extract_option(values, "--reports-to", default="owner")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(values) != 1:
|
||
console.print("[warning]Usage: /org role add <role_id> [--name ...] [--responsibility ...] [--reports-to ...][/warning]")
|
||
return
|
||
role_id = values[0]
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.org.add_role({
|
||
"role_id": role_id,
|
||
"name": name or role_id,
|
||
"responsibility": responsibility or "",
|
||
"reports_to": reports_to or "owner",
|
||
}),
|
||
)
|
||
elif action == "update":
|
||
try:
|
||
values, name = _extract_option(values, "--name")
|
||
values, responsibility = _extract_option(values, "--responsibility")
|
||
values, reports_to = _extract_option(values, "--reports-to")
|
||
values, can_spawn = _extract_option(values, "--can-spawn")
|
||
values, tools = _extract_option(values, "--tools")
|
||
values, agent = _extract_option(values, "--agent")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(values) != 1:
|
||
console.print("[warning]Usage: /org role update <role_id> [--name ...] [--responsibility ...][/warning]")
|
||
return
|
||
updates = {
|
||
key: value
|
||
for key, value in {
|
||
"name": name,
|
||
"responsibility": responsibility,
|
||
"reports_to": reports_to,
|
||
"preferred_external_agent": agent,
|
||
}.items()
|
||
if value is not None
|
||
}
|
||
if can_spawn is not None:
|
||
updates["can_spawn"] = _split_csv(can_spawn)
|
||
if tools is not None:
|
||
updates["tools"] = _split_csv(tools)
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.update_role(values[0], updates))
|
||
elif action == "delete":
|
||
if not values:
|
||
console.print("[warning]Usage: /org role delete <role_id> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(values[1:], usage="/org role delete <role_id> --yes")
|
||
if not ok or remaining:
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.delete_role(values[0]))
|
||
elif action == "bulk-add":
|
||
if len(values) != 1:
|
||
console.print("[warning]Usage: /org role bulk-add <json-or-yaml-file>[/warning]")
|
||
return
|
||
loaded = _load_structured_payload(file_path=values[0])
|
||
roles = loaded.get("roles", []) if isinstance(loaded, dict) else loaded if isinstance(loaded, list) else []
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.bulk_add_roles(list(roles or [])))
|
||
else:
|
||
console.print("[warning]Usage: /org role add|update|delete|bulk-add ...[/warning]")
|
||
return
|
||
if payload:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "policy" and rest[:1] == ["update"]:
|
||
values = rest[1:]
|
||
try:
|
||
values, payload_text = _extract_option(values, "--payload")
|
||
values, file_path = _extract_option(values, "--file")
|
||
values, profile = _extract_option(values, "--profile", default="custom")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if values:
|
||
console.print("[warning]Usage: /org policy update [--payload JSON/YAML|--file path] [--profile custom][/warning]")
|
||
return
|
||
data = _load_structured_payload(payload=payload_text, file_path=file_path)
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.update_runtime_policy(data, profile=profile or "custom"))
|
||
if payload:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "strategy" and rest[:1] == ["update"]:
|
||
values = rest[1:]
|
||
try:
|
||
values, final_decider = _extract_option(values, "--final-decider")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if values:
|
||
console.print("[warning]Usage: /org strategy update --final-decider <role_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.update_org_strategy(final_decider_role_id=final_decider))
|
||
if payload:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "reset":
|
||
remaining, ok = _require_yes_arg(rest, usage="/org reset --yes")
|
||
if not ok or remaining:
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.reset_architecture())
|
||
if payload:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "saved":
|
||
if not rest:
|
||
console.print("[warning]Usage: /org saved list|save|load|delete ...[/warning]")
|
||
return
|
||
action = rest[0].lower()
|
||
values = rest[1:]
|
||
if action == "list":
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.saved_list())
|
||
elif action == "save":
|
||
if not values:
|
||
console.print("[warning]Usage: /org saved save <name> [--overwrite][/warning]")
|
||
return
|
||
overwrite = "--overwrite" in values[1:]
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.saved_save_as(values[0], overwrite=overwrite))
|
||
elif action == "load":
|
||
if len(values) != 1:
|
||
console.print("[warning]Usage: /org saved load <name>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.saved_load(values[0]))
|
||
elif action == "delete":
|
||
if not values:
|
||
console.print("[warning]Usage: /org saved delete <name> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(values[1:], usage="/org saved delete <name> --yes")
|
||
if not ok or remaining:
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.saved_delete(values[0]))
|
||
else:
|
||
console.print("[warning]Usage: /org saved list|save|load|delete ...[/warning]")
|
||
return
|
||
if payload:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "export":
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.export_config())
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
if command == "import":
|
||
if not rest:
|
||
console.print("[warning]Usage: /org import <path> [--dry-run][/warning]")
|
||
return
|
||
dry_run = "--dry-run" in rest[1:]
|
||
raw = Path(rest[0]).read_text(encoding="utf-8")
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.org.import_config(raw, dry_run=dry_run))
|
||
if payload:
|
||
if not dry_run:
|
||
_refresh_cli_org_runtime(state)
|
||
_emit_payload(payload)
|
||
return
|
||
|
||
console.print("[warning]Usage: /org [info|role|policy|strategy|reset|saved|export|import] ...[/warning]")
|
||
|
||
|
||
def _render_talent_templates(templates: list[Any], *, title: str = "Talent Templates") -> None:
|
||
if not templates:
|
||
console.print("[info]No talent templates found.[/info]")
|
||
return
|
||
table = Table(title=f"{title} ({len(templates)})")
|
||
table.add_column("Template ID")
|
||
table.add_column("Name")
|
||
table.add_column("Category")
|
||
table.add_column("Domains")
|
||
table.add_column("Agent")
|
||
table.add_column("Description")
|
||
for template in templates:
|
||
table.add_row(
|
||
str(getattr(template, "id", "") or ""),
|
||
str(getattr(template, "name", "") or ""),
|
||
str(getattr(template, "category", "") or ""),
|
||
", ".join(str(item) for item in (getattr(template, "domains", []) or [])[:4]),
|
||
str(getattr(template, "preferred_external_agent", "") or ""),
|
||
_clip_text(getattr(template, "description", "") or "", 100),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_talent_employees(employees: list[Any]) -> None:
|
||
if not employees:
|
||
console.print("[info]No employees hired yet.[/info]")
|
||
return
|
||
table = Table(title=f"Employees ({len(employees)})")
|
||
table.add_column("Employee ID")
|
||
table.add_column("Name")
|
||
table.add_column("Role")
|
||
table.add_column("Template")
|
||
table.add_column("Status")
|
||
table.add_column("Agent")
|
||
for employee in employees:
|
||
table.add_row(
|
||
str(getattr(employee, "employee_id", "") or ""),
|
||
str(getattr(employee, "name", "") or ""),
|
||
str(getattr(employee, "role_id", "") or ""),
|
||
str(getattr(employee, "template_id", "") or ""),
|
||
str(getattr(employee, "status", "") or ""),
|
||
str(getattr(employee, "preferred_external_agent", "") or ""),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_talent_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print("[warning]Usage: /talent [list|employees|scan|import|import-repo|hire|employee|import-agent] ...[/warning]")
|
||
return
|
||
market = TalentMarket(get_opc_home(), state.config)
|
||
command = args[0].lower()
|
||
rest = args[1:]
|
||
try:
|
||
if command in {"list", "templates"}:
|
||
_render_talent_templates(market.list_templates())
|
||
return
|
||
if command == "employees":
|
||
_render_talent_employees(market.list_employees())
|
||
return
|
||
if command == "scan":
|
||
_render_talent_templates(market.scan_local_talent(), title="Local Talent Templates")
|
||
return
|
||
if command == "import":
|
||
if not rest:
|
||
console.print("[warning]Usage: /talent import <template_id...>[/warning]")
|
||
return
|
||
templates = market.import_local_templates(rest)
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Resolved {len(templates)} local talent templates.[/success]")
|
||
_render_talent_templates(templates)
|
||
return
|
||
if command == "import-repo":
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /talent import-repo <path>[/warning]")
|
||
return
|
||
templates = market.import_from_repo(_talent_repo_path(rest[0]))
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Imported {len(templates)} talent templates from repo.[/success]")
|
||
_render_talent_templates(templates)
|
||
return
|
||
if command == "hire":
|
||
rest, employee_name = _extract_option(rest, "--name")
|
||
if len(rest) != 2:
|
||
console.print("[warning]Usage: /talent hire <template_id> <role_id> [--name ...][/warning]")
|
||
return
|
||
employee = market.hire_template(rest[0], rest[1], employee_name=employee_name)
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Hired {employee.name} into role `{employee.role_id}` as `{employee.employee_id}`.[/success]")
|
||
return
|
||
if command in {"employee", "detail"}:
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /talent employee <employee_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.talent.employee_detail(rest[0]))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command in {"import-agent", "import-employee-as-agent"}:
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /talent import-agent <employee_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.talent.import_employee_as_agent(employee_id=rest[0]))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
except Exception as exc:
|
||
console.print(f"[error]Talent command failed: {exc}[/error]")
|
||
return
|
||
console.print("[warning]Usage: /talent [list|employees|scan|import|import-repo|hire|employee|import-agent] ...[/warning]")
|
||
|
||
|
||
def _package_value(package: Any, name: str, default: Any = "") -> Any:
|
||
if hasattr(package, name):
|
||
return getattr(package, name)
|
||
if isinstance(package, dict):
|
||
return package.get(name, default)
|
||
return default
|
||
|
||
|
||
def _render_market_packages(packages: list[Any]) -> None:
|
||
if not packages:
|
||
console.print("[info]No packages installed.[/info]")
|
||
return
|
||
table = Table(title=f"Installed Packages ({len(packages)})")
|
||
table.add_column("Package ID")
|
||
table.add_column("Name")
|
||
table.add_column("Version")
|
||
table.add_column("Roles")
|
||
table.add_column("Templates")
|
||
for package in packages:
|
||
role_ids = _package_value(package, "role_ids", []) or []
|
||
template_ids = _package_value(package, "template_ids", []) or []
|
||
table.add_row(
|
||
str(_package_value(package, "package_id", "?") or "?"),
|
||
str(_package_value(package, "name", "") or ""),
|
||
str(_package_value(package, "version", "") or ""),
|
||
str(len(role_ids)),
|
||
str(len(template_ids)),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_market_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print("[warning]Usage: /market [browse|preview|list|presets|apply-preset|install|uninstall|export] ...[/warning]")
|
||
return
|
||
command = args[0].lower()
|
||
rest = args[1:]
|
||
try:
|
||
if command == "browse":
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.market.browse())
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "preview":
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /market preview <preset_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.market.preview(rest[0]))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "list":
|
||
_render_market_packages(list(getattr(state.config.org, "installed_packages", []) or []))
|
||
return
|
||
if command == "presets":
|
||
from opc.market.architecture_registry import get_all_presets
|
||
|
||
_render_architecture_presets(get_all_presets())
|
||
return
|
||
if command == "apply-preset":
|
||
rest, strategy = _extract_option(rest, "--strategy", default="overwrite")
|
||
if len(rest) != 1 or strategy not in {"namespace", "overwrite"}:
|
||
console.print("[warning]Usage: /market apply-preset <preset_id> [--strategy namespace|overwrite][/warning]")
|
||
return
|
||
from opc.market.architecture_registry import apply_architecture_preset_to_config
|
||
|
||
info = apply_architecture_preset_to_config(
|
||
state.config,
|
||
rest[0],
|
||
strategy=strategy or "overwrite",
|
||
clear_existing=True,
|
||
)
|
||
state.mode = "company"
|
||
state.company_profile = "custom"
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(
|
||
f"[success]Applied preset {info.package_id}: "
|
||
f"{len(info.role_ids)} roles, {len(info.work_item_template_ids or info.template_ids)} templates.[/success]"
|
||
)
|
||
console.print("[info]Mode set to company; company_profile=custom[/info]")
|
||
return
|
||
if command == "install":
|
||
rest, strategy = _extract_option(rest, "--strategy", default="namespace")
|
||
if len(rest) != 1 or strategy not in {"namespace", "overwrite"}:
|
||
console.print("[warning]Usage: /market install <path> [--strategy namespace|overwrite][/warning]")
|
||
return
|
||
from opc.market import PackageLoader, SandboxChecker
|
||
|
||
loader = PackageLoader(state.config, get_opc_home())
|
||
package = loader.load_from_path(Path(rest[0]))
|
||
report = SandboxChecker().validate(package)
|
||
for warning in report.warnings:
|
||
console.print(f"[warning]{warning}[/warning]")
|
||
if not report.passed:
|
||
for error in report.errors:
|
||
console.print(f"[error]{error}[/error]")
|
||
console.print("[error]Package failed security check. Installation aborted.[/error]")
|
||
return
|
||
conflicts = loader.detect_conflicts(package)
|
||
if conflicts.has_conflicts:
|
||
for role_id in conflicts.role_conflicts:
|
||
console.print(f"[warning]Role conflict: {role_id}[/warning]")
|
||
for template_id in conflicts.template_conflicts:
|
||
console.print(f"[warning]Template conflict: {template_id}[/warning]")
|
||
info = loader.install(package, strategy=strategy or "namespace")
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Installed {info.package_id}: {len(info.role_ids)} roles, {len(info.template_ids)} templates.[/success]")
|
||
return
|
||
if command == "uninstall":
|
||
if not rest:
|
||
console.print("[warning]Usage: /market uninstall <package_id> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(rest[1:], usage="/market uninstall <package_id> --yes")
|
||
if not ok or remaining:
|
||
if remaining:
|
||
console.print("[warning]Usage: /market uninstall <package_id> --yes[/warning]")
|
||
return
|
||
from opc.market import PackageLoader
|
||
|
||
loader = PackageLoader(state.config, get_opc_home())
|
||
if not loader.uninstall(rest[0]):
|
||
console.print(f"[warning]Package not found: {rest[0]}[/warning]")
|
||
return
|
||
_save_cli_config(state)
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Uninstalled package {rest[0]}.[/success]")
|
||
return
|
||
if command == "export":
|
||
rest, package_id = _extract_option(rest, "--id")
|
||
rest, name = _extract_option(rest, "--name")
|
||
rest, description = _extract_option(rest, "--desc", default="")
|
||
rest, version = _extract_option(rest, "--version", default="1.0.0")
|
||
rest, output_dir = _extract_option(rest, "--output-dir", default=".")
|
||
if rest or not package_id or not name:
|
||
console.print("[warning]Usage: /market export --id <package_id> --name <name> [--desc ...] [--version ...] [--output-dir ...][/warning]")
|
||
return
|
||
from opc.market import PackageExporter
|
||
|
||
exporter = PackageExporter(state.config, get_opc_home())
|
||
package = exporter.export_current(
|
||
package_id=package_id,
|
||
name=name,
|
||
description=description or "",
|
||
version=version or "1.0.0",
|
||
)
|
||
out_path = exporter.write_to_path(package, Path(output_dir or "."))
|
||
console.print(f"[success]Exported package {package_id} to {out_path}.[/success]")
|
||
return
|
||
except Exception as exc:
|
||
console.print(f"[error]Market command failed: {exc}[/error]")
|
||
return
|
||
console.print("[warning]Usage: /market [browse|preview|list|presets|apply-preset|install|uninstall|export] ...[/warning]")
|
||
|
||
|
||
def _render_reorg_proposals(proposals: list[Any]) -> None:
|
||
if not proposals:
|
||
console.print("[info]No reorg proposals found.[/info]")
|
||
return
|
||
table = Table(title=f"Reorg Proposals ({len(proposals)})")
|
||
table.add_column("Proposal ID")
|
||
table.add_column("Title")
|
||
table.add_column("Status")
|
||
table.add_column("Risk")
|
||
table.add_column("Scope")
|
||
table.add_column("Updated")
|
||
table.add_column("Summary")
|
||
for proposal in proposals:
|
||
table.add_row(
|
||
str(getattr(proposal, "proposal_id", "") or ""),
|
||
_clip_text(getattr(proposal, "title", "") or "", 50),
|
||
_value_text(getattr(proposal, "status", "")),
|
||
_value_text(getattr(proposal, "risk_level", "")),
|
||
_value_text(getattr(proposal, "scope", "")),
|
||
_format_datetime(getattr(proposal, "updated_at", None)),
|
||
_clip_text(getattr(proposal, "summary", "") or "", 100),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_reorg_detail(proposal: Any) -> None:
|
||
table = Table(title=f"Reorg {getattr(proposal, 'proposal_id', '')}", show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
for field_name in ("proposal_id", "title", "status", "scope", "risk_level", "initiated_by", "summary", "rationale", "approval_notes"):
|
||
table.add_row(field_name, _clip_text(_value_text(getattr(proposal, field_name, "")), 260))
|
||
console.print(table)
|
||
detail = {
|
||
"changeset": getattr(getattr(proposal, "changeset", None), "__dict__", getattr(proposal, "changeset", None)),
|
||
"impact_summary": getattr(proposal, "impact_summary", {}) or {},
|
||
"metadata": getattr(proposal, "metadata", {}) or {},
|
||
}
|
||
console.print(json.dumps(detail, ensure_ascii=False, indent=2, default=str))
|
||
|
||
|
||
async def _handle_reorg_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print("[warning]Usage: /reorg [list|show|approve|deny|apply] ...[/warning]")
|
||
return
|
||
command = args[0].lower()
|
||
rest = args[1:]
|
||
try:
|
||
if command == "list":
|
||
if not getattr(state.engine, "store", None):
|
||
console.print("[warning]Reorg store is not available.[/warning]")
|
||
return
|
||
rest, limit = _parse_limit_args(rest)
|
||
if rest:
|
||
console.print("[warning]Usage: /reorg list [--limit N][/warning]")
|
||
return
|
||
proposals = await state.engine.store.list_reorg_proposals(_current_project_id(state.engine), limit=limit)
|
||
_render_reorg_proposals(proposals)
|
||
return
|
||
if command == "show":
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /reorg show <proposal_id>[/warning]")
|
||
return
|
||
proposal = await state.engine.show_company_reorg(rest[0])
|
||
if not proposal:
|
||
console.print(f"[warning]Reorg proposal not found: {rest[0]}[/warning]")
|
||
return
|
||
_render_reorg_detail(proposal)
|
||
return
|
||
if command in {"approve", "deny"}:
|
||
rest, notes = _extract_option(rest, "--notes", default="")
|
||
if len(rest) != 1:
|
||
console.print(f"[warning]Usage: /reorg {command} <proposal_id> [--notes ...][/warning]")
|
||
return
|
||
approved = command == "approve"
|
||
proposal = await state.engine.approve_company_reorg(
|
||
rest[0],
|
||
approved=approved,
|
||
notes=notes or ("Approved via CLI." if approved else "Denied via CLI."),
|
||
)
|
||
console.print(f"[success]Reorg {proposal.proposal_id} {'approved' if approved else 'denied'}.[/success]")
|
||
return
|
||
if command == "apply":
|
||
if not rest:
|
||
console.print("[warning]Usage: /reorg apply <proposal_id> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(rest[1:], usage="/reorg apply <proposal_id> --yes")
|
||
if not ok or remaining:
|
||
if remaining:
|
||
console.print("[warning]Usage: /reorg apply <proposal_id> --yes[/warning]")
|
||
return
|
||
result = await state.engine.apply_company_reorg(rest[0])
|
||
_refresh_cli_org_runtime(state)
|
||
console.print(f"[success]Applied reorg {rest[0]}.[/success]")
|
||
console.print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||
return
|
||
except Exception as exc:
|
||
console.print(f"[error]Reorg command failed: {exc}[/error]")
|
||
return
|
||
console.print("[warning]Usage: /reorg [list|show|approve|deny|apply] ...[/warning]")
|
||
|
||
|
||
def _render_key_value_table(title: str, rows: list[tuple[str, Any]], *, full: bool = False) -> None:
|
||
table = Table(title=title, show_header=False)
|
||
table.add_column("Field", style="cyan", no_wrap=True)
|
||
table.add_column("Value")
|
||
for key, value in rows:
|
||
table.add_row(key, _clip_text(_value_text(value), 260, full=full))
|
||
console.print(table)
|
||
|
||
|
||
def _runtime_session_id_from_task(task: Any) -> str:
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
runtime_v2 = dict(metadata.get("runtime_v2", {}) or {})
|
||
return str(runtime_v2.get("runtime_session_id", "") or "").strip()
|
||
|
||
|
||
def _row_runtime_session_id(row: Any) -> str:
|
||
if isinstance(row, dict):
|
||
return str(row.get("runtime_session_id", "") or "").strip()
|
||
return str(getattr(row, "runtime_session_id", "") or "").strip()
|
||
|
||
|
||
def _dedupe_runtime_rows(rows: list[Any]) -> list[Any]:
|
||
seen: set[str] = set()
|
||
results: list[Any] = []
|
||
for row in rows:
|
||
runtime_session_id = _row_runtime_session_id(row)
|
||
if not runtime_session_id or runtime_session_id in seen:
|
||
continue
|
||
seen.add(runtime_session_id)
|
||
results.append(row)
|
||
return results
|
||
|
||
|
||
def _runtime_row_metadata(row: Any) -> dict[str, Any]:
|
||
if isinstance(row, dict):
|
||
return dict(row.get("metadata", {}) or {})
|
||
return dict(getattr(row, "metadata", {}) or {})
|
||
|
||
|
||
def _runtime_row_value(row: Any, key: str, default: Any = "") -> Any:
|
||
if isinstance(row, dict):
|
||
return row.get(key, default)
|
||
return getattr(row, key, default)
|
||
|
||
|
||
async def _handle_work_items_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
args = ["list"]
|
||
command = args[0].lower()
|
||
rest = args[1:]
|
||
try:
|
||
if command in {"list", "ls"}:
|
||
rest, limit, full = _parse_view_args(rest, default=20)
|
||
role_id = None
|
||
status = None
|
||
rest, role_id = _extract_option(rest, "--role", default=None)
|
||
rest, status = _extract_option(rest, "--status", default=None)
|
||
if rest:
|
||
console.print("[warning]Usage: /work-items list [--role <role_id>] [--status <status>] [--limit N][/warning]")
|
||
return
|
||
payload = await _run_chat_current_office_service(
|
||
state,
|
||
lambda svc: svc.work_item.list(
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=state.session_id,
|
||
role_id=role_id,
|
||
status=status,
|
||
limit=limit,
|
||
),
|
||
)
|
||
if payload:
|
||
items = list(payload.get("items", []) or payload.get("work_items", []) or [])
|
||
if not items:
|
||
console.print("[info]No work items found.[/info]")
|
||
return
|
||
table = Table(title=f"Work Items ({len(items)})")
|
||
table.add_column("Work Item")
|
||
table.add_column("Title")
|
||
table.add_column("Role")
|
||
table.add_column("Status")
|
||
table.add_column("Task")
|
||
table.add_column("Session")
|
||
for item in items:
|
||
table.add_row(
|
||
str(item.get("work_item_id", "") or item.get("id", "") or ""),
|
||
_clip_text(item.get("title", "") or "", 70, full=full),
|
||
str(item.get("role_id", "") or ""),
|
||
str(item.get("status", "") or item.get("phase", "") or ""),
|
||
str(item.get("task_id", "") or item.get("runtime_task_id", "") or ""),
|
||
str(item.get("session_id", "") or ""),
|
||
)
|
||
console.print(table)
|
||
return
|
||
if command == "show":
|
||
rest, limit, _full = _parse_view_args(rest, default=50)
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /work-items show <work_item_id> [--limit N][/warning]")
|
||
return
|
||
payload = await _run_chat_current_office_service(
|
||
state,
|
||
lambda svc: svc.work_item.show(project_id=_current_project_id(state.engine), work_item_id=rest[0], limit=limit),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "logs":
|
||
rest, limit, _full = _parse_view_args(rest, default=50)
|
||
rest, role_id = _extract_option(rest, "--role", default=None)
|
||
work_item_id = rest[0] if rest else ""
|
||
if len(rest) > 1:
|
||
console.print("[warning]Usage: /work-items logs [work_item_id] [--role <role_id>] [--limit N][/warning]")
|
||
return
|
||
payload = await _run_chat_current_office_service(
|
||
state,
|
||
lambda svc: svc.work_item.logs(
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=state.session_id,
|
||
work_item_id=work_item_id,
|
||
role_id=role_id or "",
|
||
limit=limit,
|
||
),
|
||
)
|
||
if payload:
|
||
_render_work_item_logs_payload(payload, limit=limit, full=_full)
|
||
return
|
||
if command in {"role-status", "status-by-role"}:
|
||
if rest:
|
||
console.print("[warning]Usage: /work-items role-status[/warning]")
|
||
return
|
||
payload = await _run_chat_current_office_service(
|
||
state,
|
||
lambda svc: svc.work_item.status_by_role(
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=state.session_id,
|
||
),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
console.print("[warning]Usage: /work-items list|show|logs|role-status ...[/warning]")
|
||
|
||
|
||
async def _list_runtime_sessions_for_scope(
|
||
store: Any,
|
||
*,
|
||
project_id: str,
|
||
task_id: str | None = None,
|
||
session_id: str | None = None,
|
||
limit: int = _SLASH_DEFAULT_LIMIT,
|
||
) -> list[Any]:
|
||
if not hasattr(store, "list_runtime_sessions"):
|
||
return []
|
||
try:
|
||
return await store.list_runtime_sessions(
|
||
project_id=project_id,
|
||
task_id=task_id,
|
||
session_id=session_id,
|
||
limit=limit,
|
||
)
|
||
except TypeError:
|
||
rows = await store.list_runtime_sessions(project_id=project_id)
|
||
if task_id:
|
||
rows = [row for row in rows if str(_runtime_row_value(row, "task_id", "") or "") == task_id]
|
||
if session_id:
|
||
rows = [row for row in rows if str(_runtime_row_value(row, "session_id", "") or "") == session_id]
|
||
return rows[:limit]
|
||
|
||
|
||
def _render_runtime_sessions(rows: list[Any], *, title: str = "Runtime Sessions", full: bool = False) -> None:
|
||
if not rows:
|
||
console.print("[info]No runtime sessions found.[/info]")
|
||
return
|
||
table = Table(title=f"{title} ({len(rows)})")
|
||
table.add_column("Runtime ID")
|
||
table.add_column("Status")
|
||
table.add_column("Task")
|
||
table.add_column("Session")
|
||
table.add_column("Updated")
|
||
table.add_column("Summary")
|
||
for row in rows:
|
||
metadata = _runtime_row_metadata(row)
|
||
table.add_row(
|
||
_row_runtime_session_id(row),
|
||
str(_runtime_row_value(row, "status", "") or ""),
|
||
str(_runtime_row_value(row, "task_id", "") or ""),
|
||
str(_runtime_row_value(row, "session_id", "") or ""),
|
||
_format_datetime(_runtime_row_value(row, "updated_at", "")),
|
||
_json_summary(metadata, full=full),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_external_sessions(rows: list[Any], *, full: bool = False) -> None:
|
||
if not rows:
|
||
console.print("[info]No external agent sessions found.[/info]")
|
||
return
|
||
table = Table(title=f"External Agent Sessions ({len(rows)})")
|
||
table.add_column("Agent")
|
||
table.add_column("Status")
|
||
table.add_column("Task")
|
||
table.add_column("OPC Session")
|
||
table.add_column("Provider Session")
|
||
table.add_column("Workspace")
|
||
table.add_column("Updated")
|
||
for row in rows:
|
||
table.add_row(
|
||
str(getattr(row, "agent_type", "") or ""),
|
||
str(getattr(row, "status", "") or ""),
|
||
str(getattr(row, "task_id", "") or ""),
|
||
str(getattr(row, "opc_session_id", "") or ""),
|
||
_clip_text(getattr(row, "session_id", "") or "", 48, full=full),
|
||
_clip_text(getattr(row, "workspace_path", "") or "", 80, full=full),
|
||
_format_datetime(getattr(row, "updated_at", None)),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_checkpoint_table(checkpoints: list[Any], *, title: str, full: bool = False) -> None:
|
||
if not checkpoints:
|
||
console.print("[info]No checkpoints found.[/info]")
|
||
return
|
||
table = Table(title=f"{title} ({len(checkpoints)})")
|
||
table.add_column("Checkpoint ID")
|
||
table.add_column("Status")
|
||
table.add_column("Type")
|
||
table.add_column("Task")
|
||
table.add_column("Session")
|
||
table.add_column("Updated")
|
||
table.add_column("Payload")
|
||
for checkpoint in checkpoints:
|
||
table.add_row(
|
||
str(getattr(checkpoint, "checkpoint_id", "") or ""),
|
||
str(getattr(checkpoint, "status", "") or ""),
|
||
str(getattr(checkpoint, "checkpoint_type", "") or ""),
|
||
str(getattr(checkpoint, "task_id", "") or ""),
|
||
str(getattr(checkpoint, "session_id", "") or ""),
|
||
_format_datetime(getattr(checkpoint, "updated_at", None)),
|
||
_json_summary(getattr(checkpoint, "payload", {}) or {}, full=full),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_runtime_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
store = _require_chat_store(state, label="Runtime store")
|
||
if store is None:
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}. Try /runtime --limit 20.[/warning]")
|
||
return
|
||
if args:
|
||
console.print("[warning]Usage: /runtime [--limit N] [--full]. Try /help.[/warning]")
|
||
return
|
||
|
||
snapshot = {}
|
||
if hasattr(state.runtime_display, "status_snapshot"):
|
||
snapshot = state.runtime_display.status_snapshot()
|
||
if snapshot:
|
||
_render_key_value_table(
|
||
"Current Runtime Snapshot",
|
||
[
|
||
("agent", snapshot.get("agent_id") or snapshot.get("role_id") or ""),
|
||
("task", snapshot.get("task_id") or ""),
|
||
("tool", snapshot.get("current_tool") or ""),
|
||
("queue", snapshot.get("queue_depth", 0)),
|
||
("stream_queue", snapshot.get("stream_queue_depth", 0)),
|
||
("context_remaining", f"{snapshot.get('context_remaining_pct')}%" if snapshot.get("context_remaining_pct") not in (None, "") else ""),
|
||
("turn_cost", f"${float(snapshot.get('turn_cost_usd', 0.0) or 0.0):.4f}"),
|
||
("session_cost", f"${float(snapshot.get('session_cost_usd', 0.0) or 0.0):.4f}"),
|
||
("approvals", snapshot.get("pending_permission_count", 0)),
|
||
("checkpoint", snapshot.get("checkpoint_hint", "")),
|
||
],
|
||
full=full,
|
||
)
|
||
|
||
from opc.core.models import TaskStatus
|
||
|
||
tasks = await store.get_tasks(project_id=_current_project_id(state.engine))
|
||
terminal = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
|
||
active_tasks = [task for task in tasks if getattr(task, "status", None) not in terminal]
|
||
if active_tasks:
|
||
table = Table(title=f"Active Tasks ({len(active_tasks[:limit])})")
|
||
table.add_column("Task ID")
|
||
table.add_column("Title")
|
||
table.add_column("Status")
|
||
table.add_column("Agent")
|
||
table.add_column("Session")
|
||
for task in active_tasks[:limit]:
|
||
table.add_row(
|
||
str(getattr(task, "id", "") or ""),
|
||
_clip_text(getattr(task, "title", "") or "", 70, full=full),
|
||
_value_text(getattr(task, "status", "")),
|
||
str(getattr(task, "assigned_to", "") or ""),
|
||
str(getattr(task, "session_id", "") or ""),
|
||
)
|
||
console.print(table)
|
||
else:
|
||
console.print("[info]No active tasks for this project.[/info]")
|
||
|
||
runtime_rows = await _list_runtime_sessions_for_scope(
|
||
store,
|
||
project_id=_current_project_id(state.engine),
|
||
limit=limit,
|
||
)
|
||
_render_runtime_sessions(_dedupe_runtime_rows(runtime_rows), full=full)
|
||
|
||
if hasattr(store, "list_external_sessions"):
|
||
external = await store.list_external_sessions(project_id=_current_project_id(state.engine), limit=limit)
|
||
else:
|
||
external = []
|
||
_render_external_sessions(external, full=full)
|
||
|
||
checkpoints = await store.get_pending_checkpoints(project_id=_current_project_id(state.engine))
|
||
_render_checkpoint_table(checkpoints[:limit], title="Pending Checkpoints", full=full)
|
||
|
||
|
||
class _ChatRecoveryFacade:
|
||
def __init__(self, state: _InteractiveChatState) -> None:
|
||
self._state = state
|
||
self.project_id = _current_project_id(state.engine)
|
||
|
||
@property
|
||
def store(self): # noqa: ANN201 - preserve recovery facade shape
|
||
return getattr(self._state.engine, "store", None)
|
||
|
||
@property
|
||
def opc_home(self) -> Path:
|
||
return Path(getattr(self._state.engine, "opc_home", get_opc_home()))
|
||
|
||
async def ensure_ready(self):
|
||
return self._state.engine
|
||
|
||
|
||
def _get_chat_recovery_manager(state: _InteractiveChatState) -> Any:
|
||
cached = getattr(state, "_cli_chat_recovery_manager", None)
|
||
engine_id = id(state.engine)
|
||
if cached and cached[0] == engine_id:
|
||
return cached[1]
|
||
from opc.plugins.cli_board.services.recovery import CliRecoveryManager
|
||
|
||
manager = CliRecoveryManager(_ChatRecoveryFacade(state))
|
||
setattr(state, "_cli_chat_recovery_manager", (engine_id, manager))
|
||
return manager
|
||
|
||
|
||
def _render_recovery_status(status: Any, *, limit: int, full: bool = False) -> None:
|
||
workflows = list(getattr(status, "interrupted", []) or [])[:limit]
|
||
if workflows:
|
||
table = Table(title=f"Interrupted Company Runtimes ({len(workflows)})")
|
||
table.add_column("Parent Task")
|
||
table.add_column("Parent Session")
|
||
table.add_column("Title")
|
||
table.add_column("Profile")
|
||
table.add_column("Interrupted")
|
||
table.add_column("Work Items")
|
||
for workflow in workflows:
|
||
interrupted_items = [
|
||
item for item in list(getattr(workflow, "work_items", []) or [])
|
||
if bool(getattr(item, "interrupted", False))
|
||
]
|
||
table.add_row(
|
||
str(getattr(workflow, "parent_task_id", "") or ""),
|
||
str(getattr(workflow, "parent_session_id", "") or ""),
|
||
_clip_text(getattr(workflow, "title", "") or "", 70, full=full),
|
||
str(getattr(workflow, "profile", "") or ""),
|
||
str(getattr(workflow, "interrupted_at", "") or ""),
|
||
f"{len(interrupted_items)}/{len(list(getattr(workflow, 'work_items', []) or []))}",
|
||
)
|
||
console.print(table)
|
||
else:
|
||
console.print("[info]No interrupted company runtimes found.[/info]")
|
||
|
||
active = list(getattr(status, "active_recoveries", []) or [])
|
||
if active:
|
||
console.print(f"[info]Active recoveries: {', '.join(active)}[/info]")
|
||
|
||
|
||
async def _find_checkpoint_by_id(store: Any, project_id: str, checkpoint_id: str) -> Any | None:
|
||
if hasattr(store, "get_execution_checkpoints"):
|
||
checkpoints = await store.get_execution_checkpoints(project_id=project_id)
|
||
else:
|
||
checkpoints = await store.get_pending_checkpoints(project_id=project_id)
|
||
return next((item for item in checkpoints if str(getattr(item, "checkpoint_id", "") or "") == checkpoint_id), None)
|
||
|
||
|
||
async def _handle_recover_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
store = _require_chat_store(state, label="Recovery store")
|
||
if store is None:
|
||
return
|
||
if args and args[0].lower() == "resume":
|
||
if len(args) != 2:
|
||
console.print("[warning]Usage: /recover resume <parent_task_id>. Try /recover.[/warning]")
|
||
return
|
||
manager = _get_chat_recovery_manager(state)
|
||
result = await manager.resume(args[1])
|
||
if result.get("ok"):
|
||
resumed = result.get("resumed_work_item_projection_ids", []) or []
|
||
console.print(f"[success]Recovery started for {args[1]}: {', '.join(resumed) or 'runtime queued'}.[/success]")
|
||
return
|
||
if result.get("error") == "not_found":
|
||
checkpoint = await _find_checkpoint_by_id(store, _current_project_id(state.engine), args[1])
|
||
if checkpoint:
|
||
session_id = str(getattr(checkpoint, "session_id", "") or "")
|
||
suffix = f" Try /session resume {session_id}." if session_id else " Try /checkpoints."
|
||
console.print(f"[warning]Checkpoint {args[1]} is not resumed directly.{suffix}[/warning]")
|
||
return
|
||
console.print(f"[warning]Recovery could not start: {result.get('error', 'unknown_error')}[/warning]")
|
||
return
|
||
if args and args[0].lower() in {"cancel", "retry"}:
|
||
action = args[0].lower()
|
||
if len(args) < 2:
|
||
console.print(f"[warning]Usage: /recover {action} <parent_task_id>{' --yes' if action == 'cancel' else ''}. Try /recover.[/warning]")
|
||
return
|
||
if action == "cancel":
|
||
remaining, ok = _require_yes_arg(args[2:], usage="/recover cancel <parent_task_id> --yes")
|
||
if not ok or remaining:
|
||
return
|
||
elif len(args) > 2:
|
||
console.print(f"[warning]Usage: /recover {action} <parent_task_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.runtime.recovery_action(project_id=_current_project_id(state.engine), action=action, parent_task_id=args[1]),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
try:
|
||
if args and args[0].lower() == "scan":
|
||
args = args[1:]
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}. Try /recover --limit 20.[/warning]")
|
||
return
|
||
if args:
|
||
console.print("[warning]Usage: /recover [--limit N] [--full] or /recover resume <parent_task_id>.[/warning]")
|
||
return
|
||
manager = _get_chat_recovery_manager(state)
|
||
status = await manager.get_status()
|
||
_render_recovery_status(status, limit=limit, full=full)
|
||
if hasattr(store, "get_execution_checkpoints"):
|
||
checkpoints = await store.get_execution_checkpoints(
|
||
project_id=_current_project_id(state.engine),
|
||
statuses=["pending", "resuming"],
|
||
)
|
||
else:
|
||
checkpoints = await store.get_pending_checkpoints(project_id=_current_project_id(state.engine))
|
||
_render_checkpoint_table(checkpoints[:limit], title="Recovery Checkpoints", full=full)
|
||
|
||
|
||
async def _resolve_logs_target(
|
||
state: _InteractiveChatState,
|
||
target: str,
|
||
*,
|
||
limit: int,
|
||
) -> tuple[Any | None, Any | None, list[Any], str | None]:
|
||
store = _require_chat_store(state, label="Log store")
|
||
if store is None:
|
||
return None, None, [], None
|
||
project_id = _current_project_id(state.engine)
|
||
task = await store.get_task(target) if hasattr(store, "get_task") else None
|
||
session = None
|
||
runtime_rows: list[Any] = []
|
||
runtime_row = None
|
||
if task:
|
||
task_project = str(getattr(task, "project_id", "") or "default")
|
||
if task_project != project_id:
|
||
console.print(f"[warning]Task belongs to project '{task_project}'. Switch project first with /project {task_project}.[/warning]")
|
||
return None, None, [], None
|
||
session_id = str(getattr(task, "session_id", "") or "")
|
||
if session_id and hasattr(store, "get_session"):
|
||
session = await store.get_session(session_id)
|
||
runtime_rows.extend(await _list_runtime_sessions_for_scope(store, project_id=project_id, task_id=target, limit=limit))
|
||
runtime_id = _runtime_session_id_from_task(task)
|
||
if runtime_id and hasattr(store, "get_runtime_session"):
|
||
row = await store.get_runtime_session(runtime_id)
|
||
if row:
|
||
runtime_rows.append(row)
|
||
return task, session, _dedupe_runtime_rows(runtime_rows), None
|
||
if hasattr(store, "get_session"):
|
||
session = await store.get_session(target)
|
||
if session:
|
||
session_project = str(getattr(session, "project_id", "") or "default")
|
||
if session_project != project_id:
|
||
console.print(f"[warning]Session belongs to project '{session_project}'. Switch project first with /project {session_project}.[/warning]")
|
||
return None, None, [], None
|
||
runtime_rows.extend(await _list_runtime_sessions_for_scope(store, project_id=project_id, session_id=target, limit=limit))
|
||
return None, session, _dedupe_runtime_rows(runtime_rows), None
|
||
if hasattr(store, "get_runtime_session"):
|
||
runtime_row = await store.get_runtime_session(target)
|
||
if runtime_row:
|
||
runtime_project = str(runtime_row.get("project_id", "") or "default")
|
||
if runtime_project != project_id:
|
||
console.print(f"[warning]Runtime session belongs to project '{runtime_project}'. Switch project first with /project {runtime_project}.[/warning]")
|
||
return None, None, [], None
|
||
task_id = str(runtime_row.get("task_id", "") or "")
|
||
session_id = str(runtime_row.get("session_id", "") or "")
|
||
if task_id and hasattr(store, "get_task"):
|
||
task = await store.get_task(task_id)
|
||
if session_id and hasattr(store, "get_session"):
|
||
session = await store.get_session(session_id)
|
||
return task, session, [runtime_row], None
|
||
return None, None, [], target
|
||
|
||
|
||
def _render_task_progress(task: Any, *, limit: int, full: bool = False) -> None:
|
||
if not task:
|
||
return
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
progress = list(metadata.get("progress_log", []) or [])
|
||
if not progress:
|
||
console.print("[info]No task progress log found.[/info]")
|
||
return
|
||
table = Table(title=f"Task Progress ({len(progress[-limit:])})")
|
||
table.add_column("Entry")
|
||
for entry in progress[-limit:]:
|
||
table.add_row(_clip_text(entry, 300, full=full))
|
||
console.print(table)
|
||
|
||
|
||
async def _render_runtime_log_tables(store: Any, runtime_rows: list[Any], *, limit: int, full: bool = False) -> None:
|
||
runtime_ids = [_row_runtime_session_id(row) for row in runtime_rows if _row_runtime_session_id(row)]
|
||
if not runtime_ids:
|
||
console.print("[info]No runtime session logs found.[/info]")
|
||
return
|
||
_render_runtime_sessions(runtime_rows, title="Runtime Log Scope", full=full)
|
||
for runtime_id in runtime_ids[:limit]:
|
||
if hasattr(store, "list_runtime_events"):
|
||
events = await store.list_runtime_events(runtime_id, limit=limit)
|
||
if events:
|
||
table = Table(title=f"Runtime Events: {runtime_id} ({len(events)})")
|
||
table.add_column("Created")
|
||
table.add_column("Type")
|
||
table.add_column("Payload")
|
||
for event in events:
|
||
table.add_row(
|
||
_format_datetime(event.get("created_at")),
|
||
str(event.get("event_type", "") or ""),
|
||
_json_summary(event.get("payload", {}) or {}, full=full),
|
||
)
|
||
console.print(table)
|
||
if hasattr(store, "list_runtime_transcript_entries"):
|
||
entries = (await store.list_runtime_transcript_entries(runtime_id))[-limit:]
|
||
if entries:
|
||
table = Table(title=f"Runtime Transcript: {runtime_id} ({len(entries)})")
|
||
table.add_column("Created")
|
||
table.add_column("Role")
|
||
table.add_column("Type")
|
||
table.add_column("Content")
|
||
for entry in entries:
|
||
table.add_row(
|
||
_format_datetime(entry.get("created_at")),
|
||
str(entry.get("role", "") or ""),
|
||
str(entry.get("entry_type", "") or ""),
|
||
_clip_text(entry.get("content", "") or "", 300, full=full),
|
||
)
|
||
console.print(table)
|
||
if hasattr(store, "list_runtime_tool_calls"):
|
||
calls = (await store.list_runtime_tool_calls(runtime_id))[-limit:]
|
||
if calls:
|
||
table = Table(title=f"Tool Calls: {runtime_id} ({len(calls)})")
|
||
table.add_column("Created")
|
||
table.add_column("Tool")
|
||
table.add_column("Arguments")
|
||
for call in calls:
|
||
table.add_row(
|
||
_format_datetime(call.get("created_at")),
|
||
str(call.get("tool_name", "") or ""),
|
||
_json_summary(call.get("arguments", {}) or {}, full=full),
|
||
)
|
||
console.print(table)
|
||
if hasattr(store, "list_runtime_tool_results"):
|
||
results = (await store.list_runtime_tool_results(runtime_id))[-limit:]
|
||
if results:
|
||
table = Table(title=f"Tool Results: {runtime_id} ({len(results)})")
|
||
table.add_column("Created")
|
||
table.add_column("Tool")
|
||
table.add_column("Payload")
|
||
for result in results:
|
||
table.add_row(
|
||
_format_datetime(result.get("created_at")),
|
||
str(result.get("tool_name", "") or ""),
|
||
_json_summary(result.get("payload", {}) or {}, full=full),
|
||
)
|
||
console.print(table)
|
||
if hasattr(store, "list_runtime_permission_grants"):
|
||
grants = (await store.list_runtime_permission_grants(runtime_session_id=runtime_id))[-limit:]
|
||
if grants:
|
||
table = Table(title=f"Permission Grants: {runtime_id} ({len(grants)})")
|
||
table.add_column("Scope")
|
||
table.add_column("Tool")
|
||
table.add_column("Candidate")
|
||
table.add_column("Created")
|
||
for grant in grants:
|
||
table.add_row(
|
||
str(grant.get("scope", "") or ""),
|
||
str(grant.get("tool_name", "") or ""),
|
||
_clip_text(grant.get("candidate", "") or "", 120, full=full),
|
||
_format_datetime(grant.get("created_at")),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _clip_log_text(value: Any, limit: int = 1600, *, full: bool = False) -> str:
|
||
text = str(value or "").replace("\r\n", "\n").strip()
|
||
if full or len(text) <= limit:
|
||
return text
|
||
return text[: max(0, limit - 3)].rstrip() + "..."
|
||
|
||
|
||
_EXTERNAL_TRANSCRIPT_RE = re.compile(r"^\[External:(?P<agent>[^:\]]+):(?P<kind>[^\]]+)\]\s*(?P<body>.*)$", re.S)
|
||
_STAFFING_CONTROL_REPLIES = {"approve", "approved", "auto", "auto recruit", "deny", "stop", "cancel"}
|
||
_IMPORTANT_WORK_ITEM_EVENTS = {
|
||
"approval_requested",
|
||
"approval_resolved",
|
||
"checkpoint_created",
|
||
"checkpoint_resolved",
|
||
"escalation_created",
|
||
"escalation_resolved",
|
||
"work_item_failed",
|
||
"work_item_blocked",
|
||
"work_item_completed",
|
||
"runtime_failed",
|
||
"error",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class _WorkItemLogCell:
|
||
kind: str
|
||
created_at: Any
|
||
label: str
|
||
content: str
|
||
order: int = 0
|
||
status: str = ""
|
||
exit_code: Any = None
|
||
|
||
|
||
def _runtime_tool_payload_text(value: dict[str, Any], *, full: bool = False) -> str:
|
||
if not isinstance(value, dict):
|
||
return _clip_log_text(value, full=full)
|
||
for key in ("command", "cmd", "summary", "result_summary", "stdout", "stderr", "output", "text", "message"):
|
||
if value.get(key):
|
||
text = str(value.get(key) or "")
|
||
if key in {"command", "cmd"}:
|
||
return f"$ {text}"
|
||
return text
|
||
return _json_summary(value, limit=1200, full=full)
|
||
|
||
|
||
def _runtime_tool_command_text(value: Any, *, full: bool = False) -> str:
|
||
if not isinstance(value, dict):
|
||
return _clip_log_text(value, full=full)
|
||
for key in ("command", "cmd"):
|
||
if value.get(key):
|
||
return f"$ {str(value.get(key) or '').strip()}"
|
||
return _runtime_tool_payload_text(value, full=full)
|
||
|
||
|
||
def _runtime_tool_result_status(value: Any) -> tuple[str, Any]:
|
||
if not isinstance(value, dict):
|
||
return "", None
|
||
status = str(value.get("status", "") or "").strip()
|
||
exit_code = value.get("exit_code")
|
||
if exit_code is None:
|
||
exit_code = value.get("returncode")
|
||
if not status and "success" in value:
|
||
status = "completed" if bool(value.get("success")) else "failed"
|
||
if not status and exit_code is not None:
|
||
status = "completed" if str(exit_code) == "0" else "failed"
|
||
return status, exit_code
|
||
|
||
|
||
def _runtime_tool_result_text(value: Any, *, full: bool = False) -> str:
|
||
if not isinstance(value, dict):
|
||
return _clip_log_text(value, full=full)
|
||
parts: list[str] = []
|
||
for key in ("stdout", "stderr", "output", "result_summary", "summary", "text", "message"):
|
||
if value.get(key):
|
||
parts.append(str(value.get(key) or "").strip())
|
||
if parts:
|
||
return "\n".join(part for part in parts if part)
|
||
return _json_summary(value, limit=1200, full=full)
|
||
|
||
|
||
def _runtime_agent_label(entry: dict[str, Any]) -> str:
|
||
runtime_id = str(entry.get("runtime_session_id", "") or "")
|
||
if ":" in runtime_id:
|
||
prefix = runtime_id.split(":", 1)[0].strip()
|
||
if prefix:
|
||
return prefix
|
||
return str(entry.get("role", "") or "runtime").strip() or "runtime"
|
||
|
||
|
||
def _parse_runtime_stream_event(content: str) -> dict[str, Any] | None:
|
||
try:
|
||
event = json.loads(str(content or "").strip())
|
||
except Exception:
|
||
return None
|
||
if isinstance(event, dict) and isinstance(event.get("msg"), dict):
|
||
event = event["msg"]
|
||
if not isinstance(event, dict):
|
||
return None
|
||
event_type = str(event.get("type", "") or "").strip()
|
||
return event if event_type else None
|
||
|
||
|
||
def _command_text_from_codex_item(item: dict[str, Any], *, full: bool = False) -> str:
|
||
command = item.get("command")
|
||
if isinstance(command, list):
|
||
text = " ".join(str(part) for part in command if str(part).strip())
|
||
else:
|
||
text = str(command or "").strip()
|
||
if not text:
|
||
text = "command execution"
|
||
return f"$ {_clip_log_text(text, limit=420, full=full)}"
|
||
|
||
|
||
def _codex_command_output_text(item: dict[str, Any], *, full: bool = False) -> str:
|
||
output = str(item.get("aggregated_output", "") or "").strip()
|
||
if not output:
|
||
return ""
|
||
if full:
|
||
return output
|
||
if len(output) > 600 and output[:1] in {"{", "["}:
|
||
return "output omitted; use --full to inspect raw JSON output"
|
||
return _clip_log_text(output, limit=900, full=False)
|
||
|
||
|
||
def _runtime_stream_event_cells(
|
||
entry: dict[str, Any],
|
||
*,
|
||
full: bool = False,
|
||
skip_tool_events: bool = False,
|
||
) -> list[_WorkItemLogCell]:
|
||
event = _parse_runtime_stream_event(str(entry.get("content", "") or ""))
|
||
if not event:
|
||
return []
|
||
event_type = str(event.get("type", "") or "").strip()
|
||
created_at = entry.get("created_at")
|
||
agent = _runtime_agent_label(entry)
|
||
item = event.get("item") if isinstance(event.get("item"), dict) else {}
|
||
item_type = str(item.get("type", "") or "").strip()
|
||
|
||
if event_type in {"thread.started", "turn.started", "turn.completed", "turn.failed"}:
|
||
if not full:
|
||
return []
|
||
return [_WorkItemLogCell(
|
||
kind="event",
|
||
created_at=created_at,
|
||
label=f"runtime {event_type}",
|
||
content=_json_summary(event, limit=1000, full=full),
|
||
order=50,
|
||
)]
|
||
|
||
if item_type == "agent_message" and event_type in {"item.completed", "item.started"}:
|
||
text = str(item.get("text", "") or "").strip()
|
||
if not text:
|
||
return []
|
||
return [_WorkItemLogCell(
|
||
kind="thinking",
|
||
created_at=created_at,
|
||
label=agent,
|
||
content=text,
|
||
order=15,
|
||
)]
|
||
|
||
if item_type == "command_execution":
|
||
if skip_tool_events:
|
||
return []
|
||
if event_type == "item.started":
|
||
return [_WorkItemLogCell(
|
||
kind="tool_call",
|
||
created_at=created_at,
|
||
label="command_execution",
|
||
content=_command_text_from_codex_item(item, full=full),
|
||
order=30,
|
||
)]
|
||
if event_type == "item.completed":
|
||
status = str(item.get("status", "") or "").strip()
|
||
exit_code = item.get("exit_code")
|
||
content = _codex_command_output_text(item, full=full)
|
||
return [_WorkItemLogCell(
|
||
kind="tool_result",
|
||
created_at=created_at,
|
||
label="command_execution",
|
||
content=content,
|
||
order=35,
|
||
status=status,
|
||
exit_code=exit_code,
|
||
)]
|
||
|
||
if not full:
|
||
return []
|
||
return [_WorkItemLogCell(
|
||
kind="event",
|
||
created_at=created_at,
|
||
label=f"runtime {event_type}",
|
||
content=_json_summary(event, limit=1000, full=full),
|
||
order=50,
|
||
)]
|
||
|
||
|
||
def _is_staffing_transcript_noise(role: str, content: str) -> bool:
|
||
normalized = " ".join(str(content or "").strip().lower().split())
|
||
if not normalized:
|
||
return False
|
||
if role == "assistant" and normalized.startswith("company mode has a pending manual staffing selection"):
|
||
return True
|
||
if role == "user" and normalized in _STAFFING_CONTROL_REPLIES:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _short_runtime_ref(value: str, *, full: bool = False) -> str:
|
||
text = str(value or "").strip()
|
||
if full or len(text) <= 48:
|
||
return text
|
||
parts = text.split("::")
|
||
if len(parts) >= 3 and parts[0] == "role-runtime":
|
||
return f"{parts[0]}::{parts[1][:8]}::{parts[-1]}"
|
||
return _clip_text(text, 48, full=False)
|
||
|
||
|
||
def _event_is_high_signal(event_type: str) -> bool:
|
||
normalized = str(event_type or "").strip().lower()
|
||
if not normalized:
|
||
return False
|
||
if normalized in _IMPORTANT_WORK_ITEM_EVENTS:
|
||
return True
|
||
return any(token in normalized for token in ("approval", "checkpoint", "escalation", "fail", "error", "blocked"))
|
||
|
||
|
||
def _render_codex_block(content: Any, *, prefix: str = " ", style: str = "", full: bool = False) -> None:
|
||
body = _clip_log_text(content, full=full)
|
||
if not body:
|
||
return
|
||
for line in body.splitlines():
|
||
text = f"{prefix}{line}" if line else ""
|
||
if style:
|
||
console.print(f"[{style}]{escape(text)}[/{style}]")
|
||
else:
|
||
console.print(escape(text))
|
||
|
||
|
||
def _render_codex_assistant_body(content: str, *, full: bool = False) -> None:
|
||
body = _clip_log_text(content, full=full)
|
||
if not body:
|
||
return
|
||
try:
|
||
console.print(Markdown(body))
|
||
except Exception:
|
||
_render_codex_block(body, prefix=" ", full=full)
|
||
|
||
|
||
def _render_work_item_log_cell(cell: _WorkItemLogCell, *, full: bool = False) -> None:
|
||
time_text = _format_datetime(cell.created_at)
|
||
timestamp = f"[dim]{escape(time_text)}[/dim] " if time_text else ""
|
||
label = escape(cell.label)
|
||
if cell.kind == "user":
|
||
console.print(f"{timestamp}[success]user[/success]")
|
||
_render_codex_block(cell.content, prefix="› ", style="success", full=full)
|
||
elif cell.kind == "assistant":
|
||
console.print(f"{timestamp}[agent]• {label}[/agent]")
|
||
_render_codex_assistant_body(cell.content, full=full)
|
||
elif cell.kind == "thinking":
|
||
console.print(f"{timestamp}[dim]thinking {label}[/dim]")
|
||
_render_codex_block(cell.content, prefix=" ", style="dim", full=full)
|
||
elif cell.kind == "tool_call":
|
||
text = cell.content.strip()
|
||
if text.startswith("$ "):
|
||
console.print(f"{timestamp}[tool]{escape(text)}[/tool]")
|
||
else:
|
||
console.print(f"{timestamp}[tool]tool {label}[/tool]")
|
||
_render_codex_block(text, prefix=" ", full=full)
|
||
elif cell.kind == "tool_result":
|
||
heading = f"tool result {cell.label}".strip()
|
||
status_bits: list[str] = []
|
||
if cell.status:
|
||
status_bits.append(f"status={cell.status}")
|
||
if cell.exit_code is not None and str(cell.exit_code) != "":
|
||
status_bits.append(f"exit_code={cell.exit_code}")
|
||
suffix = f" ({', '.join(status_bits)})" if status_bits else ""
|
||
style = "success" if cell.status in {"", "completed", "success", "succeeded"} else "error"
|
||
console.print(f"{timestamp}[{style}]{escape(heading + suffix)}[/{style}]")
|
||
_render_codex_block(cell.content, prefix=" ", full=full)
|
||
elif cell.kind in {"permission", "event", "handoff"}:
|
||
style = "warning" if cell.kind == "permission" else "info"
|
||
console.print(f"{timestamp}[{style}]• {label}[/{style}]")
|
||
_render_codex_block(cell.content, prefix=" ", full=full)
|
||
else:
|
||
console.print(f"{timestamp}[info]• {label}[/info]")
|
||
_render_codex_block(cell.content, prefix=" ", full=full)
|
||
|
||
|
||
def _render_work_item_logs_payload(payload: dict[str, Any], *, limit: int, full: bool = False) -> None:
|
||
title_bits = []
|
||
if payload.get("role_id"):
|
||
title_bits.append(f"role={payload['role_id']}")
|
||
if payload.get("work_item_id"):
|
||
title_bits.append(f"work_item={str(payload['work_item_id'])[:12]}")
|
||
if payload.get("session_id"):
|
||
title_bits.append(f"session={str(payload['session_id'])[:8]}")
|
||
scope_title = "Work Item Logs" + (f" ({', '.join(title_bits)})" if title_bits else "")
|
||
console.print(f"[bold]{escape(scope_title)}[/bold]")
|
||
|
||
work_items = list(payload.get("work_items", []) or [])[:limit]
|
||
for item in work_items:
|
||
status = str(item.get("phase", "") or item.get("kanban_column", "") or "")
|
||
runtime = str(item.get("role_runtime_session_id", "") or item.get("claimed_by_role_runtime_session_id", "") or "").strip()
|
||
suffix = f" [{status}]" if status else ""
|
||
if runtime:
|
||
suffix += f" runtime={_short_runtime_ref(runtime, full=full)}"
|
||
console.print(
|
||
f"[dim]work-item[/dim] [bold]{escape(str(item.get('work_item_id', '') or '')[:12])}[/bold] "
|
||
f"{escape(str(item.get('role_id', '') or ''))} "
|
||
f"{escape(_clip_text(item.get('title', '') or '', 96, full=full))}{escape(suffix)}"
|
||
)
|
||
if work_items:
|
||
console.print()
|
||
|
||
timeline: list[_WorkItemLogCell] = []
|
||
structured_tool_calls = list(payload.get("runtime_tool_calls", []) or [])
|
||
|
||
for item in list(payload.get("transcript", []) or [])[-limit:]:
|
||
message = item.get("message") if isinstance(item, dict) else None
|
||
if not message or getattr(message, "summary_flag", False):
|
||
continue
|
||
content = _render_transcript_parts(item.get("parts", []))
|
||
if not content:
|
||
continue
|
||
role = str(getattr(message, "role", "") or "assistant").strip().lower()
|
||
if not full and _is_staffing_transcript_noise(role, content):
|
||
continue
|
||
agent_id = str(getattr(message, "agent_id", "") or "").strip()
|
||
sender = {
|
||
"user": "user",
|
||
"assistant": "assistant",
|
||
"system": "system",
|
||
"subagent": agent_id or "subagent",
|
||
}.get(role, agent_id or role or "assistant")
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="user" if role == "user" else "assistant",
|
||
created_at=getattr(message, "created_at", None),
|
||
label=sender,
|
||
content=content,
|
||
order=10,
|
||
))
|
||
|
||
for entry in list(payload.get("runtime_transcript_entries", []) or [])[-limit:]:
|
||
content = str(entry.get("content", "") or "")
|
||
if not content:
|
||
continue
|
||
stream_cells = _runtime_stream_event_cells(entry, full=full, skip_tool_events=bool(structured_tool_calls))
|
||
if stream_cells:
|
||
timeline.extend(stream_cells)
|
||
continue
|
||
if _parse_runtime_stream_event(content):
|
||
continue
|
||
label = str(entry.get("entry_type", "") or "runtime")
|
||
kind = "assistant"
|
||
order = 20
|
||
match = _EXTERNAL_TRANSCRIPT_RE.match(content)
|
||
if match:
|
||
agent = match.group("agent")
|
||
external_kind = match.group("kind").strip().lower()
|
||
content = match.group("body").strip()
|
||
label = agent
|
||
if external_kind == "tool":
|
||
if structured_tool_calls:
|
||
continue
|
||
kind = "tool_call"
|
||
order = 30
|
||
elif external_kind == "thinking":
|
||
kind = "thinking"
|
||
order = 15
|
||
elif external_kind in {"error", "failed", "failure"}:
|
||
kind = "event"
|
||
label = f"{agent} {external_kind}"
|
||
order = 55
|
||
else:
|
||
kind = "assistant"
|
||
label = agent
|
||
timeline.append(_WorkItemLogCell(
|
||
kind=kind,
|
||
created_at=entry.get("created_at"),
|
||
label=label,
|
||
content=content,
|
||
order=order,
|
||
))
|
||
|
||
for call in structured_tool_calls[-limit:]:
|
||
tool_name = str(call.get("tool_name", "") or "tool")
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="tool_call",
|
||
created_at=call.get("created_at"),
|
||
label=tool_name,
|
||
content=_runtime_tool_command_text(call.get("arguments", {}) or {}, full=full),
|
||
order=30,
|
||
))
|
||
|
||
for result in list(payload.get("runtime_tool_results", []) or [])[-limit:]:
|
||
tool_name = str(result.get("tool_name", "") or "tool")
|
||
status, exit_code = _runtime_tool_result_status(result.get("payload", {}) or {})
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="tool_result",
|
||
created_at=result.get("created_at"),
|
||
label=tool_name,
|
||
content=_runtime_tool_result_text(result.get("payload", {}) or {}, full=full),
|
||
order=35,
|
||
status=status,
|
||
exit_code=exit_code,
|
||
))
|
||
|
||
for event in list(payload.get("events", []) or [])[-limit:]:
|
||
event_type = str(event.get("event_type", "") or "event")
|
||
if not full and not _event_is_high_signal(event_type):
|
||
continue
|
||
work_item_id = str(event.get("work_item_id", "") or "")[:12]
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="event",
|
||
created_at=event.get("created_at"),
|
||
label=f"event {event_type}",
|
||
content=f"{work_item_id} {_json_summary(event.get('payload', {}) or {}, limit=1000, full=full)}".strip(),
|
||
order=50,
|
||
))
|
||
|
||
for event in list(payload.get("runtime_events", []) or [])[-limit:]:
|
||
event_type = str(event.get("event_type", "") or "runtime")
|
||
if not full and not _event_is_high_signal(event_type):
|
||
continue
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="event",
|
||
created_at=event.get("created_at"),
|
||
label=f"runtime {event_type}",
|
||
content=_json_summary(event.get("payload", {}) or {}, limit=1000, full=full),
|
||
order=50,
|
||
))
|
||
|
||
for grant in list(payload.get("runtime_permission_grants", []) or [])[-limit:]:
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="permission",
|
||
created_at=grant.get("created_at"),
|
||
label="permission",
|
||
content=f"{grant.get('scope', '')} {grant.get('tool_name', '')}: {grant.get('candidate', '')}".strip(),
|
||
order=45,
|
||
))
|
||
|
||
for handoff in list(payload.get("handoffs", []) or [])[-limit:]:
|
||
timeline.append(_WorkItemLogCell(
|
||
kind="handoff",
|
||
created_at=handoff.get("created_at"),
|
||
label=f"handoff {handoff.get('from_role', '')}->{handoff.get('to_role', '')}",
|
||
content=str(handoff.get("summary", "") or _json_summary(handoff, limit=1000, full=full)),
|
||
order=40,
|
||
))
|
||
|
||
if not timeline and not work_items:
|
||
console.print("[info]No role/work-item logs found for the current session.[/info]")
|
||
return
|
||
|
||
timeline.sort(key=lambda item: (str(item.created_at or ""), item.order, item.label))
|
||
for index, item in enumerate(timeline[-limit:]):
|
||
if index:
|
||
console.print()
|
||
_render_work_item_log_cell(item, full=full)
|
||
|
||
|
||
async def _handle_logs_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
store = _require_chat_store(state, label="Log store")
|
||
if store is None:
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}. Try /logs <task_id|session_id> --limit 20.[/warning]")
|
||
return
|
||
if len(args) != 1:
|
||
console.print("[warning]Usage: /logs <task_id|session_id> [--limit N] [--full]. Try /session list or /tasks.[/warning]")
|
||
return
|
||
task, session, runtime_rows, missing = await _resolve_logs_target(state, args[0], limit=limit)
|
||
if missing:
|
||
console.print(f"[warning]No task, session, or runtime session found for {missing}. Try /session list or /tasks.[/warning]")
|
||
return
|
||
if task:
|
||
_render_key_value_table(
|
||
f"Task Log Target {getattr(task, 'id', '')}",
|
||
[
|
||
("title", getattr(task, "title", "")),
|
||
("status", _value_text(getattr(task, "status", ""))),
|
||
("session", getattr(task, "session_id", "")),
|
||
("runtime", _runtime_session_id_from_task(task)),
|
||
],
|
||
full=full,
|
||
)
|
||
_render_task_progress(task, limit=limit, full=full)
|
||
if session:
|
||
session_id = str(getattr(session, "session_id", "") or "")
|
||
_render_key_value_table(
|
||
f"Session Log Target {session_id}",
|
||
[
|
||
("title", getattr(session, "title", "")),
|
||
("status", getattr(session, "status", "")),
|
||
("mode", getattr(session, "mode", "")),
|
||
],
|
||
full=full,
|
||
)
|
||
if hasattr(store, "get_session_transcript"):
|
||
transcript = await store.get_session_transcript(session_id)
|
||
_render_transcript_table(transcript, limit=limit)
|
||
await _render_runtime_log_tables(store, runtime_rows, limit=limit, full=full)
|
||
|
||
|
||
async def _task_scope_ids_for_comms(state: _InteractiveChatState, task: Any) -> list[str]:
|
||
task_id = str(getattr(task, "id", "") or "").strip()
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
scope = {task_id}
|
||
scope.update(str(item).strip() for item in list(metadata.get("execution_task_ids", []) or []) if str(item).strip())
|
||
parent_session_id = str(getattr(task, "session_id", "") or "").strip()
|
||
store = getattr(state.engine, "store", None)
|
||
if parent_session_id and store and hasattr(store, "get_tasks"):
|
||
try:
|
||
tasks = await store.get_tasks(project_id=_current_project_id(state.engine))
|
||
for candidate in tasks:
|
||
if str(getattr(candidate, "parent_session_id", "") or "").strip() == parent_session_id:
|
||
scope.add(str(getattr(candidate, "id", "") or "").strip())
|
||
except Exception:
|
||
pass
|
||
scope.discard("")
|
||
return sorted(scope)
|
||
|
||
|
||
def _render_agent_messages(messages: list[Any], *, full: bool = False) -> None:
|
||
if not messages:
|
||
console.print("[info]No company-mode messages found for this task scope.[/info]")
|
||
return
|
||
table = Table(title=f"Company Messages ({len(messages)})")
|
||
table.add_column("Time")
|
||
table.add_column("From")
|
||
table.add_column("To")
|
||
table.add_column("Type")
|
||
table.add_column("Status")
|
||
table.add_column("Subject")
|
||
table.add_column("Body")
|
||
for message in messages:
|
||
to_agents = getattr(message, "to_agents", []) or []
|
||
table.add_row(
|
||
_format_datetime(getattr(message, "timestamp", None)),
|
||
str(getattr(message, "from_agent", "") or ""),
|
||
", ".join(str(item) for item in to_agents),
|
||
_value_text(getattr(message, "msg_type", "")),
|
||
_value_text(getattr(message, "status", "")),
|
||
_clip_text(getattr(message, "subject", "") or "", 80, full=full),
|
||
_clip_text(getattr(message, "body", "") or "", 180, full=full),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
def _render_handoff_records(records: list[Any], *, full: bool = False) -> None:
|
||
if not records:
|
||
console.print("[info]No handoff records found for this task scope.[/info]")
|
||
return
|
||
table = Table(title=f"Handoffs ({len(records)})")
|
||
table.add_column("Handoff ID")
|
||
table.add_column("From")
|
||
table.add_column("To")
|
||
table.add_column("Status")
|
||
table.add_column("Task")
|
||
table.add_column("Summary")
|
||
table.add_column("Created")
|
||
for record in records:
|
||
table.add_row(
|
||
str(getattr(record, "handoff_id", "") or ""),
|
||
str(getattr(record, "from_role", "") or ""),
|
||
str(getattr(record, "to_role", "") or ""),
|
||
str(getattr(record, "status", "") or ""),
|
||
str(getattr(record, "task_id", "") or ""),
|
||
_clip_text(getattr(record, "summary", "") or "", 180, full=full),
|
||
_format_datetime(getattr(record, "created_at", None)),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_comms_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
store = _require_chat_store(state, label="Comms store")
|
||
if store is None:
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}. Try /comms <task_id>.[/warning]")
|
||
return
|
||
if len(args) != 1:
|
||
console.print("[warning]Usage: /comms <task_id> [--limit N] [--full]. Try /tasks.[/warning]")
|
||
return
|
||
task = await _get_task_for_current_project(state, args[0])
|
||
if task is None:
|
||
return
|
||
scope_ids = await _task_scope_ids_for_comms(state, task)
|
||
console.print(f"[info]Comms task scope: {', '.join(scope_ids)}[/info]")
|
||
messages = await store.list_agent_messages_for_tasks(scope_ids, limit=limit) if hasattr(store, "list_agent_messages_for_tasks") else []
|
||
_render_agent_messages(messages, full=full)
|
||
|
||
records: list[Any] = []
|
||
if hasattr(store, "get_handoff_records"):
|
||
seen: set[str] = set()
|
||
for task_id in scope_ids:
|
||
for record in await store.get_handoff_records(project_id=_current_project_id(state.engine), task_id=task_id, limit=limit):
|
||
handoff_id = str(getattr(record, "handoff_id", "") or "")
|
||
if handoff_id and handoff_id not in seen:
|
||
seen.add(handoff_id)
|
||
records.append(record)
|
||
_render_handoff_records(records[:limit], full=full)
|
||
|
||
metadata = dict(getattr(task, "metadata", {}) or {})
|
||
context_snapshot = dict(getattr(task, "context_snapshot", {}) or {})
|
||
notes = []
|
||
for key in ("structured_review_verdict", "review_verdict", "review_notes", "handoff_context", "handoff_to"):
|
||
if metadata.get(key):
|
||
notes.append((key, _json_summary(metadata.get(key), full=full)))
|
||
if context_snapshot.get(key):
|
||
notes.append((f"context.{key}", _json_summary(context_snapshot.get(key), full=full)))
|
||
if notes:
|
||
_render_key_value_table("Review and Handoff Notes", notes, full=full)
|
||
else:
|
||
console.print("[info]No review notes or handoff context found on the task.[/info]")
|
||
|
||
|
||
def _collect_attachment_refs(value: Any, source: str, out: list[tuple[str, dict[str, Any]]]) -> None:
|
||
if isinstance(value, dict):
|
||
if value.get("attachment_id") or value.get("disk_path"):
|
||
if value.get("filename") or value.get("mime_type") or value.get("disk_path"):
|
||
out.append((source, dict(value)))
|
||
for key, nested in value.items():
|
||
if key in {"attachment_refs", "attachments"} or isinstance(nested, (dict, list)):
|
||
_collect_attachment_refs(nested, source, out)
|
||
return
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
_collect_attachment_refs(item, source, out)
|
||
|
||
|
||
async def _handle_attachments_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
store = _require_chat_store(state, label="Attachment store")
|
||
if store is None:
|
||
return
|
||
try:
|
||
args, limit, full = _parse_view_args(args)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}. Try /attachments --limit 20.[/warning]")
|
||
return
|
||
if args:
|
||
console.print("[warning]Usage: /attachments [--limit N] [--full].[/warning]")
|
||
return
|
||
refs: list[tuple[str, dict[str, Any]]] = []
|
||
if state.session_id and hasattr(store, "get_session"):
|
||
session = await store.get_session(state.session_id)
|
||
if session:
|
||
_collect_attachment_refs(getattr(session, "metadata", {}) or {}, f"session:{state.session_id}", refs)
|
||
if state.session_id and hasattr(store, "get_session_transcript"):
|
||
transcript = await store.get_session_transcript(state.session_id)
|
||
for idx, item in enumerate(transcript):
|
||
if isinstance(item, dict):
|
||
message = item.get("message")
|
||
_collect_attachment_refs(getattr(message, "metadata", {}) or {}, f"message:{idx}", refs)
|
||
for part in item.get("parts", []) or []:
|
||
payload = part.get("payload", {}) if isinstance(part, dict) else getattr(part, "payload", {})
|
||
_collect_attachment_refs(payload, f"message:{idx}", refs)
|
||
if hasattr(store, "get_tasks"):
|
||
for task in await store.get_tasks(project_id=_current_project_id(state.engine)):
|
||
if str(getattr(task, "session_id", "") or "") == state.session_id:
|
||
_collect_attachment_refs(getattr(task, "metadata", {}) or {}, f"task:{getattr(task, 'id', '')}", refs)
|
||
_collect_attachment_refs(getattr(task, "result", {}) or {}, f"task:{getattr(task, 'id', '')}", refs)
|
||
deduped: list[tuple[str, dict[str, Any]]] = []
|
||
seen: set[str] = set()
|
||
for source, ref in refs:
|
||
key = str(ref.get("attachment_id") or ref.get("disk_path") or ref)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
deduped.append((source, ref))
|
||
if not deduped:
|
||
console.print("[info]No attachments found for the current session.[/info]")
|
||
return
|
||
table = Table(title=f"Attachments ({len(deduped[:limit])})")
|
||
table.add_column("Attachment ID")
|
||
table.add_column("Filename")
|
||
table.add_column("MIME")
|
||
table.add_column("Size")
|
||
table.add_column("Source")
|
||
table.add_column("Path")
|
||
for source, ref in deduped[:limit]:
|
||
table.add_row(
|
||
str(ref.get("attachment_id", "") or ""),
|
||
str(ref.get("filename", "") or ""),
|
||
str(ref.get("mime_type", "") or ""),
|
||
str(ref.get("size_bytes", "") or ""),
|
||
source,
|
||
_clip_text(ref.get("disk_path", "") or "", 100, full=full),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_mode_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
table = Table(title="Mode")
|
||
table.add_column("Mode", style="cyan")
|
||
table.add_column("Current", justify="center")
|
||
table.add_column("Command")
|
||
table.add_row("task", "*" if state.mode == "task" else "", "/mode task")
|
||
table.add_row("company/corporate", "*" if state.mode == "company" and state.company_profile == "corporate" else "", "/mode company corporate")
|
||
table.add_row("company/custom", "*" if state.mode == "company" and state.company_profile == "custom" else "", "/mode company custom")
|
||
console.print(table)
|
||
console.print(f"[info]Current mode: {state.mode}; company_profile={state.company_profile}[/info]")
|
||
return
|
||
mode = args[0].strip().lower()
|
||
if mode == "project":
|
||
mode = "task"
|
||
if mode not in _VALID_CHAT_MODES:
|
||
console.print("[warning]Usage: /mode [task|company] [corporate|custom][/warning]")
|
||
return
|
||
next_company_profile = state.company_profile
|
||
if mode == "company":
|
||
if len(args) >= 2:
|
||
profile = args[1].strip().lower()
|
||
if profile not in _VALID_COMPANY_PROFILES:
|
||
console.print("[warning]Company profile must be corporate or custom.[/warning]")
|
||
return
|
||
next_company_profile = profile
|
||
state.mode = mode
|
||
state.company_profile = next_company_profile
|
||
await _persist_chat_context(state)
|
||
console.print(f"[success]Mode set to {state.mode}[/success]")
|
||
if state.mode == "company":
|
||
console.print(f"[info]Company profile: {state.company_profile}[/info]")
|
||
|
||
|
||
async def _handle_agent_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
table = Table(title="Agent")
|
||
table.add_column("Agent", style="cyan")
|
||
table.add_column("Current", justify="center")
|
||
table.add_column("Command")
|
||
for agent in ["native", "codex", "claude_code", "cursor", "opencode"]:
|
||
table.add_row(agent, "*" if state.preferred_agent == agent else "", f"/agent {agent}")
|
||
table.add_row("system default", "*" if state.preferred_agent is None else "", "/agent none")
|
||
console.print(table)
|
||
console.print(f"[info]Preferred agent: {state.preferred_agent or '(system default)'}[/info]")
|
||
return
|
||
command = args[0].strip().lower()
|
||
if command in {"list", "ls"}:
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.agent.list())
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "detail":
|
||
if len(args) != 2:
|
||
console.print("[warning]Usage: /agent detail <agent_id>[/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.agent.detail(project_id=_current_project_id(state.engine), agent_id=args[1]))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "create":
|
||
rest = args[1:]
|
||
try:
|
||
rest, description = _extract_option(rest, "--description", default="")
|
||
rest, office_id = _extract_option(rest, "--office", default="office-0")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(rest) != 2:
|
||
console.print("[warning]Usage: /agent create <name> <role_id> [--description ...] [--office ...][/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.agent.create(name=rest[0], role_id=rest[1], office_id=office_id or "office-0", description=description or ""),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "create-from-template":
|
||
rest = args[1:]
|
||
try:
|
||
rest, role_id = _extract_option(rest, "--role")
|
||
rest, office_id = _extract_option(rest, "--office", default="office-0")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /agent create-from-template <template_id> [--role <role_id>] [--office ...][/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.agent.create_from_template(template_id=rest[0], role_id=role_id or rest[0], office_id=office_id or "office-0"),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command in {"import-employee", "import-agent"}:
|
||
rest = args[1:]
|
||
try:
|
||
rest, office_id = _extract_option(rest, "--office", default="office-0")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(rest) != 1:
|
||
console.print("[warning]Usage: /agent import-employee <employee_id> [--office ...][/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.agent.import_employee(employee_id=rest[0], office_id=office_id or "office-0"))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "delete":
|
||
if len(args) < 2:
|
||
console.print("[warning]Usage: /agent delete <agent_id> --yes[/warning]")
|
||
return
|
||
remaining, ok = _require_yes_arg(args[2:], usage="/agent delete <agent_id> --yes")
|
||
if not ok or remaining:
|
||
return
|
||
payload = await _run_chat_office_service(state, lambda svc: svc.agent.delete(args[1]))
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
if command == "move":
|
||
rest = args[1:]
|
||
try:
|
||
rest, seat_zone = _extract_option(rest, "--seat-zone")
|
||
rest, desk_id = _extract_option(rest, "--desk")
|
||
except ValueError as exc:
|
||
console.print(f"[warning]{exc}[/warning]")
|
||
return
|
||
if len(rest) != 2:
|
||
console.print("[warning]Usage: /agent move <agent_id> <office_id> [--seat-zone ...] [--desk ...][/warning]")
|
||
return
|
||
payload = await _run_chat_office_service(
|
||
state,
|
||
lambda svc: svc.agent.move(agent_id=rest[0], office_id=rest[1], seat_zone=seat_zone, desk_id=desk_id),
|
||
)
|
||
if payload:
|
||
_emit_payload(payload)
|
||
return
|
||
agent = args[0].strip().lower().replace("-", "_")
|
||
if agent in {"none", "auto", "default"}:
|
||
state.preferred_agent = None
|
||
await _persist_chat_context(state)
|
||
console.print("[success]Preferred agent cleared; using system default.[/success]")
|
||
return
|
||
if agent not in _VALID_PREFERRED_AGENTS:
|
||
valid = ", ".join(sorted([*_VALID_PREFERRED_AGENTS, "none"]))
|
||
console.print(f"[warning]Unknown agent '{args[0]}'. Valid: {valid}[/warning]")
|
||
return
|
||
state.preferred_agent = agent
|
||
await _persist_chat_context(state)
|
||
console.print(f"[success]Preferred agent set to {agent}[/success]")
|
||
|
||
|
||
def _handle_domains_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
console.print(f"[info]Domains: {', '.join(state.domains) if state.domains else '(none)'}[/info]")
|
||
return
|
||
if len(args) == 1 and args[0].strip().lower() in {"clear", "none", "reset"}:
|
||
state.domains = []
|
||
console.print("[success]Domain hints cleared.[/success]")
|
||
return
|
||
state.domains = [arg.strip().lower() for arg in args if arg.strip()]
|
||
console.print(f"[success]Domains set to: {', '.join(state.domains) if state.domains else '(none)'}[/success]")
|
||
|
||
|
||
def _staffing_checkpoint_key(checkpoint: Any) -> str:
|
||
return str(
|
||
getattr(checkpoint, "checkpoint_id", "")
|
||
or getattr(checkpoint, "session_id", "")
|
||
or "company_staffing_selection"
|
||
)
|
||
|
||
|
||
def _company_staffing_payload(checkpoint: Any) -> dict[str, Any]:
|
||
payload = getattr(checkpoint, "payload", {}) or {}
|
||
return dict(payload) if isinstance(payload, dict) else {}
|
||
|
||
|
||
def _normalize_cli_staffing_selection(value: Any) -> dict[str, str]:
|
||
if not isinstance(value, dict):
|
||
return {"kind": "fallback", "id": ""}
|
||
kind = str(value.get("kind", "") or "").strip().lower()
|
||
if kind in {"employee", "emp"}:
|
||
selected_id = str(value.get("id") or value.get("employee_id") or "").strip()
|
||
return {"kind": "employee", "id": selected_id} if selected_id else {"kind": "fallback", "id": ""}
|
||
if kind in {"template", "tpl"}:
|
||
selected_id = str(value.get("id") or value.get("template_id") or "").strip()
|
||
return {"kind": "template", "id": selected_id} if selected_id else {"kind": "fallback", "id": ""}
|
||
return {"kind": "fallback", "id": ""}
|
||
|
||
|
||
def _company_staffing_default_draft(payload: dict[str, Any]) -> dict[str, Any]:
|
||
selections: dict[str, dict[str, str]] = {}
|
||
role_agents: dict[str, str] = {}
|
||
for role in list(payload.get("staffing_roles", []) or []):
|
||
role_id = str(role.get("role_id", "") or "").strip()
|
||
if not role_id:
|
||
continue
|
||
selections[role_id] = _normalize_cli_staffing_selection(role.get("default_selection"))
|
||
role_agents[role_id] = (
|
||
str(role.get("selected_agent") or role.get("default_agent") or "codex").strip().lower().replace("-", "_")
|
||
or "codex"
|
||
)
|
||
return {"staffing_selections": selections, "recruitment_role_agents": role_agents}
|
||
|
||
|
||
def _company_staffing_resume_metadata(draft: dict[str, Any], checkpoint: Any | None = None) -> dict[str, Any]:
|
||
metadata = {
|
||
"staffing_action": "manual_approve",
|
||
"staffing_selections": dict(draft.get("staffing_selections", {}) or {}),
|
||
"recruitment_role_agents": dict(draft.get("recruitment_role_agents", {}) or {}),
|
||
}
|
||
if checkpoint is not None:
|
||
checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "").strip()
|
||
if checkpoint_id:
|
||
metadata["response_to_checkpoint_id"] = checkpoint_id
|
||
metadata["response_to_checkpoint_type"] = "company_staffing_selection"
|
||
return metadata
|
||
|
||
|
||
def _staffing_pool_maps(payload: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
||
pool = dict(payload.get("staffing_pool", {}) or {})
|
||
employees = {
|
||
str(item.get("employee_id", "") or "").strip(): dict(item)
|
||
for item in list(pool.get("employees", []) or [])
|
||
if str(item.get("employee_id", "") or "").strip()
|
||
}
|
||
templates = {
|
||
str(item.get("template_id", "") or "").strip(): dict(item)
|
||
for item in list(pool.get("templates", []) or [])
|
||
if str(item.get("template_id", "") or "").strip()
|
||
}
|
||
return employees, templates
|
||
|
||
|
||
def _company_staffing_selection_label(selection: Any, payload: dict[str, Any]) -> str:
|
||
normalized = _normalize_cli_staffing_selection(selection)
|
||
employees, templates = _staffing_pool_maps(payload)
|
||
selected_id = normalized.get("id", "")
|
||
if normalized["kind"] == "employee":
|
||
employee = employees.get(selected_id, {})
|
||
name = str(employee.get("employee_name", "") or selected_id)
|
||
return f"{name} ({selected_id})"
|
||
if normalized["kind"] == "template":
|
||
template = templates.get(selected_id, {})
|
||
name = str(template.get("template_name", "") or selected_id)
|
||
return f"Hire template: {name} ({selected_id})"
|
||
return "role-only fallback"
|
||
|
||
|
||
def _company_staffing_agent_choices(state: _InteractiveChatState) -> list[dict[str, Any]]:
|
||
available: set[str] = {"native"}
|
||
registry = getattr(state.engine, "adapter_registry", None)
|
||
if registry and callable(getattr(registry, "list_available", None)):
|
||
try:
|
||
available.update(str(item).strip().lower().replace("-", "_") for item in registry.list_available())
|
||
except Exception:
|
||
pass
|
||
ordered = ["native", "codex", "claude_code", "cursor", "opencode"]
|
||
return [
|
||
{
|
||
"agent": agent,
|
||
"label": f"{agent} ({'available' if agent in available else 'unavailable'})",
|
||
"available": agent in available,
|
||
"search": agent.replace("_", " "),
|
||
}
|
||
for agent in ordered
|
||
]
|
||
|
||
|
||
def _company_staffing_employee_choices(payload: dict[str, Any], role: dict[str, Any]) -> list[dict[str, Any]]:
|
||
employees, templates = _staffing_pool_maps(payload)
|
||
role_id = str(role.get("role_id", "") or "").strip()
|
||
same_role_ids = [
|
||
str(item or "").strip()
|
||
for item in list(role.get("same_role_employee_ids", []) or [])
|
||
if str(item or "").strip()
|
||
]
|
||
choices: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
|
||
def _append_employee(employee_id: str, prefix: str) -> None:
|
||
employee = employees.get(employee_id)
|
||
if not employee or employee_id in seen:
|
||
return
|
||
seen.add(employee_id)
|
||
label = f"{prefix}: {employee.get('employee_name') or employee_id} ({employee_id})"
|
||
choices.append(
|
||
{
|
||
"kind": "employee",
|
||
"id": employee_id,
|
||
"label": label,
|
||
"search": " ".join(
|
||
str(part or "")
|
||
for part in [
|
||
employee_id,
|
||
employee.get("employee_name"),
|
||
employee.get("role_id"),
|
||
employee.get("category"),
|
||
" ".join(employee.get("domains", []) or []),
|
||
" ".join(employee.get("tags", []) or []),
|
||
employee.get("description"),
|
||
]
|
||
).lower(),
|
||
}
|
||
)
|
||
|
||
for employee_id in same_role_ids:
|
||
_append_employee(employee_id, "same role")
|
||
for employee_id, employee in employees.items():
|
||
employee_role = str(employee.get("role_id", "") or "").strip()
|
||
prefix = "same role" if employee_role == role_id else "employee"
|
||
_append_employee(employee_id, prefix)
|
||
for template_id, template in templates.items():
|
||
choices.append(
|
||
{
|
||
"kind": "template",
|
||
"id": template_id,
|
||
"label": f"template: {template.get('template_name') or template_id} ({template_id})",
|
||
"search": " ".join(
|
||
str(part or "")
|
||
for part in [
|
||
template_id,
|
||
template.get("template_name"),
|
||
template.get("category"),
|
||
" ".join(template.get("domains", []) or []),
|
||
" ".join(template.get("tags", []) or []),
|
||
template.get("description"),
|
||
]
|
||
).lower(),
|
||
}
|
||
)
|
||
if bool(role.get("fallback_available", True)):
|
||
choices.append({"kind": "fallback", "id": "", "label": "fallback: role-only execution", "search": f"fallback role only {role_id}"})
|
||
return choices
|
||
|
||
|
||
def _company_staffing_filter_options(options: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
|
||
normalized = str(query or "").strip().lower()
|
||
if not normalized:
|
||
return options
|
||
tokens = [token for token in re.split(r"\s+", normalized) if token]
|
||
if not tokens:
|
||
return options
|
||
return [
|
||
option
|
||
for option in options
|
||
if all(token in f"{option.get('label', '')} {option.get('search', '')}".lower() for token in tokens)
|
||
]
|
||
|
||
|
||
def _render_company_staffing_summary(
|
||
state: _InteractiveChatState,
|
||
checkpoint: Any,
|
||
draft: dict[str, Any],
|
||
) -> None:
|
||
payload = _company_staffing_payload(checkpoint)
|
||
selections = dict(draft.get("staffing_selections", {}) or {})
|
||
role_agents = dict(draft.get("recruitment_role_agents", {}) or {})
|
||
agent_status = {item["agent"]: item for item in _company_staffing_agent_choices(state)}
|
||
table = Table(title="Company Staffing Preflight")
|
||
table.add_column("Role", style="cyan")
|
||
table.add_column("Employee / Template")
|
||
table.add_column("Agent")
|
||
table.add_column("Status")
|
||
for role in list(payload.get("staffing_roles", []) or []):
|
||
role_id = str(role.get("role_id", "") or "").strip()
|
||
if not role_id:
|
||
continue
|
||
role_label = str(role.get("role_label", "") or role_id)
|
||
agent = str(role_agents.get(role_id) or role.get("selected_agent") or role.get("default_agent") or "codex").strip().lower().replace("-", "_")
|
||
status = "available" if agent_status.get(agent, {}).get("available") else "unavailable"
|
||
table.add_row(
|
||
f"{role_label}\n[dim]{role_id}[/dim]",
|
||
_company_staffing_selection_label(selections.get(role_id), payload),
|
||
agent,
|
||
status,
|
||
)
|
||
console.print(table)
|
||
recommended = str(payload.get("recommended_action", "") or "").strip()
|
||
summary = str(payload.get("summary", "") or "").strip()
|
||
if summary:
|
||
console.print(f"[dim]{summary}[/dim]")
|
||
if recommended == "auto_recruit":
|
||
console.print("[info]r auto recruit (recommended) | e edit | a approve selections | c context | d deny | /staffing reopen this editor later[/info]")
|
||
else:
|
||
console.print("[info]a approve selections | e edit | c context | r auto recruit | d deny | /staffing reopen this editor later[/info]")
|
||
|
||
|
||
def _render_company_staffing_context_preview(
|
||
state: _InteractiveChatState,
|
||
checkpoint: Any,
|
||
draft: dict[str, Any],
|
||
) -> None:
|
||
payload = _company_staffing_payload(checkpoint)
|
||
original_message = _clip_text(payload.get("original_message"), 360, full=False) or "(empty request)"
|
||
selections = dict(draft.get("staffing_selections", {}) or {})
|
||
role_agents = dict(draft.get("recruitment_role_agents", {}) or {})
|
||
table = Table(title="Initial Role Context Preview")
|
||
table.add_column("Role", style="cyan")
|
||
table.add_column("Agent")
|
||
table.add_column("Staffing")
|
||
table.add_column("Context Preview")
|
||
for role in list(payload.get("staffing_roles", []) or []):
|
||
role_id = str(role.get("role_id", "") or "").strip()
|
||
if not role_id:
|
||
continue
|
||
role_label = str(role.get("role_label", "") or role_id).strip()
|
||
role_responsibility = _clip_text(role.get("role_responsibility"), 180, full=False)
|
||
selected_agent = str(role_agents.get(role_id) or role.get("selected_agent") or role.get("default_agent") or "codex").strip()
|
||
staffing_label = _company_staffing_selection_label(selections.get(role_id), payload)
|
||
context_lines = [
|
||
f"Owner request: {original_message}",
|
||
]
|
||
if role_responsibility:
|
||
context_lines.append(f"Role responsibility: {role_responsibility}")
|
||
context_lines.append(
|
||
"Runtime note: exact child WorkItem briefs are created by the manager at execution time; "
|
||
"inspect them later with /work-items show or role logs."
|
||
)
|
||
table.add_row(
|
||
f"{role_label}\n[dim]{role_id}[/dim]",
|
||
selected_agent,
|
||
_clip_text(staffing_label, 120, full=False),
|
||
"\n".join(context_lines),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _latest_company_staffing_checkpoint(state: _InteractiveChatState) -> Any | None:
|
||
getter = getattr(state.engine, "get_latest_pending_checkpoint_for_session", None)
|
||
if not callable(getter) or not state.session_id:
|
||
return None
|
||
try:
|
||
checkpoint = await getter(state.session_id)
|
||
except Exception:
|
||
return None
|
||
if str(getattr(checkpoint, "checkpoint_type", "") or "") != "company_staffing_selection":
|
||
return None
|
||
return checkpoint
|
||
|
||
|
||
def _apply_staffing_choice(draft: dict[str, Any], role_id: str, choice: dict[str, Any], *, column: str) -> None:
|
||
if column == "agent":
|
||
agent = str(choice.get("agent", "") or "").strip().lower().replace("-", "_")
|
||
if agent:
|
||
draft.setdefault("recruitment_role_agents", {})[role_id] = agent
|
||
return
|
||
kind = str(choice.get("kind", "") or "fallback").strip().lower()
|
||
selected_id = str(choice.get("id", "") or "").strip()
|
||
if kind not in {"employee", "template"}:
|
||
draft.setdefault("staffing_selections", {})[role_id] = {"kind": "fallback", "id": ""}
|
||
else:
|
||
draft.setdefault("staffing_selections", {})[role_id] = {"kind": kind, "id": selected_id}
|
||
|
||
|
||
def _render_staffing_editor_text(
|
||
state: _InteractiveChatState,
|
||
payload: dict[str, Any],
|
||
draft: dict[str, Any],
|
||
row_index: int,
|
||
column_index: int,
|
||
query: str,
|
||
choice_index: int,
|
||
) -> str:
|
||
roles = [dict(role) for role in list(payload.get("staffing_roles", []) or []) if str(role.get("role_id", "") or "").strip()]
|
||
if not roles:
|
||
return "No staffing roles are available.\nPress q to exit."
|
||
row_index = max(0, min(row_index, len(roles) - 1))
|
||
role = roles[row_index]
|
||
role_id = str(role.get("role_id", "") or "")
|
||
column_name = "Employee/Template" if column_index == 0 else "Agent"
|
||
options = (
|
||
_company_staffing_employee_choices(payload, role)
|
||
if column_index == 0
|
||
else _company_staffing_agent_choices(state)
|
||
)
|
||
matches = _company_staffing_filter_options(options, query)[:8]
|
||
lines = [
|
||
"Company staffing editor",
|
||
"Up/Down: role or match | Left/Right/Tab: column | type to search | Enter: select | Esc/q: save | Ctrl-C: cancel",
|
||
"",
|
||
]
|
||
selections = dict(draft.get("staffing_selections", {}) or {})
|
||
role_agents = dict(draft.get("recruitment_role_agents", {}) or {})
|
||
for idx, item in enumerate(roles):
|
||
item_role_id = str(item.get("role_id", "") or "")
|
||
marker = ">" if idx == row_index else " "
|
||
selected = _company_staffing_selection_label(selections.get(item_role_id), payload)
|
||
agent = str(role_agents.get(item_role_id) or item.get("selected_agent") or item.get("default_agent") or "codex")
|
||
lines.append(f"{marker} {item.get('role_label') or item_role_id} [{item_role_id}]")
|
||
lines.append(f" Employee/Template: {selected}")
|
||
lines.append(f" Agent: {agent}")
|
||
lines.extend(["", f"Editing {role.get('role_label') or role_id} / {column_name}", f"Search: {query or '(all)'}"])
|
||
if not matches:
|
||
lines.append(" no matches")
|
||
for idx, option in enumerate(matches):
|
||
marker = ">" if idx == choice_index else " "
|
||
lines.append(f" {marker} {option.get('label')}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _edit_company_staffing_draft(
|
||
state: _InteractiveChatState,
|
||
checkpoint: Any,
|
||
draft: dict[str, Any],
|
||
) -> dict[str, Any] | None:
|
||
payload = _company_staffing_payload(checkpoint)
|
||
roles = [dict(role) for role in list(payload.get("staffing_roles", []) or []) if str(role.get("role_id", "") or "").strip()]
|
||
if not roles:
|
||
console.print("[warning]This staffing checkpoint has no roles to edit.[/warning]")
|
||
return draft
|
||
working = {
|
||
"staffing_selections": dict(draft.get("staffing_selections", {}) or {}),
|
||
"recruitment_role_agents": dict(draft.get("recruitment_role_agents", {}) or {}),
|
||
}
|
||
try:
|
||
from prompt_toolkit.application import Application
|
||
from prompt_toolkit.key_binding import KeyBindings
|
||
from prompt_toolkit.layout import Layout
|
||
from prompt_toolkit.layout.containers import HSplit, Window
|
||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||
except Exception:
|
||
return _edit_company_staffing_draft_fallback(payload, working)
|
||
|
||
row_index = 0
|
||
column_index = 0
|
||
query = ""
|
||
choice_index = 0
|
||
kb = KeyBindings()
|
||
|
||
def _current_options() -> list[dict[str, Any]]:
|
||
role = roles[row_index]
|
||
options = (
|
||
_company_staffing_employee_choices(payload, role)
|
||
if column_index == 0
|
||
else _company_staffing_agent_choices(state)
|
||
)
|
||
return _company_staffing_filter_options(options, query)
|
||
|
||
def _reset_choice() -> None:
|
||
nonlocal choice_index
|
||
choice_index = 0
|
||
|
||
def _render() -> str:
|
||
return _render_staffing_editor_text(state, payload, working, row_index, column_index, query, choice_index)
|
||
|
||
@kb.add("up")
|
||
def _up(event) -> None: # pragma: no cover - exercised through prompt_toolkit in real terminals
|
||
nonlocal row_index, choice_index
|
||
if query:
|
||
choice_index = max(0, choice_index - 1)
|
||
else:
|
||
row_index = max(0, row_index - 1)
|
||
_reset_choice()
|
||
|
||
@kb.add("down")
|
||
def _down(event) -> None: # pragma: no cover
|
||
nonlocal row_index, choice_index
|
||
if query:
|
||
choice_index = min(max(0, len(_current_options()) - 1), choice_index + 1)
|
||
else:
|
||
row_index = min(len(roles) - 1, row_index + 1)
|
||
_reset_choice()
|
||
|
||
@kb.add("left")
|
||
def _left(event) -> None: # pragma: no cover
|
||
nonlocal column_index, query
|
||
column_index = 0
|
||
query = ""
|
||
_reset_choice()
|
||
|
||
@kb.add("right")
|
||
@kb.add("tab")
|
||
def _right(event) -> None: # pragma: no cover
|
||
nonlocal column_index, query
|
||
column_index = 1 if column_index == 0 else 0
|
||
query = ""
|
||
_reset_choice()
|
||
|
||
@kb.add("backspace")
|
||
def _backspace(event) -> None: # pragma: no cover
|
||
nonlocal query
|
||
query = query[:-1]
|
||
_reset_choice()
|
||
|
||
@kb.add("enter")
|
||
def _enter(event) -> None: # pragma: no cover
|
||
nonlocal query
|
||
options = _current_options()
|
||
if not options:
|
||
return
|
||
role_id = str(roles[row_index].get("role_id", "") or "")
|
||
_apply_staffing_choice(working, role_id, options[min(choice_index, len(options) - 1)], column="agent" if column_index else "employee")
|
||
query = ""
|
||
_reset_choice()
|
||
|
||
@kb.add("escape")
|
||
@kb.add("q")
|
||
def _save(event) -> None: # pragma: no cover
|
||
event.app.exit(result=True)
|
||
|
||
@kb.add("c-c")
|
||
def _cancel(event) -> None: # pragma: no cover
|
||
event.app.exit(result=False)
|
||
|
||
@kb.add("/")
|
||
def _slash(event) -> None: # pragma: no cover
|
||
nonlocal query
|
||
query = ""
|
||
_reset_choice()
|
||
|
||
@kb.add("<any>")
|
||
def _typing(event) -> None: # pragma: no cover
|
||
nonlocal query
|
||
data = getattr(event, "data", "") or ""
|
||
if data and data.isprintable():
|
||
query += data
|
||
_reset_choice()
|
||
|
||
app_editor = Application(
|
||
layout=Layout(HSplit([Window(content=FormattedTextControl(_render), wrap_lines=False)])),
|
||
key_bindings=kb,
|
||
full_screen=False,
|
||
)
|
||
try:
|
||
saved = await app_editor.run_async()
|
||
except Exception:
|
||
return _edit_company_staffing_draft_fallback(payload, working)
|
||
return working if saved else None
|
||
|
||
|
||
def _edit_company_staffing_draft_fallback(payload: dict[str, Any], draft: dict[str, Any]) -> dict[str, Any]:
|
||
console.print("[warning]Interactive editor unavailable. Use text overrides; blank line saves.[/warning]")
|
||
console.print("Examples: senior_engineer=emp:employee-id | senior_engineer=tpl:template-id | senior_engineer=fallback | agent senior_engineer=codex")
|
||
roles = {
|
||
str(role.get("role_id", "") or "").strip()
|
||
for role in list(payload.get("staffing_roles", []) or [])
|
||
if str(role.get("role_id", "") or "").strip()
|
||
}
|
||
while True:
|
||
try:
|
||
raw = console.input("[bold]staffing edit> [/bold]").strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
return draft
|
||
if not raw or raw.lower() in {"q", "quit", "save", "done"}:
|
||
return draft
|
||
if raw.lower().startswith("agent "):
|
||
raw = raw.split(" ", 1)[1].strip()
|
||
if "=" not in raw:
|
||
console.print("[warning]Usage: agent <role_id>=<agent>[/warning]")
|
||
continue
|
||
role_id, agent = [part.strip() for part in raw.split("=", 1)]
|
||
if role_id not in roles:
|
||
console.print(f"[warning]Unknown role: {role_id}[/warning]")
|
||
continue
|
||
draft.setdefault("recruitment_role_agents", {})[role_id] = agent.lower().replace("-", "_")
|
||
continue
|
||
if "=" not in raw:
|
||
console.print("[warning]Usage: <role_id>=emp:<id> | <role_id>=tpl:<id> | <role_id>=fallback[/warning]")
|
||
continue
|
||
role_id, selection = [part.strip() for part in raw.split("=", 1)]
|
||
if role_id not in roles:
|
||
console.print(f"[warning]Unknown role: {role_id}[/warning]")
|
||
continue
|
||
if selection.lower().startswith("emp:"):
|
||
draft.setdefault("staffing_selections", {})[role_id] = {"kind": "employee", "id": selection.split(":", 1)[1].strip()}
|
||
elif selection.lower().startswith("tpl:"):
|
||
draft.setdefault("staffing_selections", {})[role_id] = {"kind": "template", "id": selection.split(":", 1)[1].strip()}
|
||
else:
|
||
draft.setdefault("staffing_selections", {})[role_id] = {"kind": "fallback", "id": ""}
|
||
|
||
|
||
async def _resume_company_staffing_checkpoint(
|
||
state: _InteractiveChatState,
|
||
checkpoint: Any,
|
||
*,
|
||
action: str,
|
||
draft: dict[str, Any] | None = None,
|
||
controller: ChatTurnController | None = None,
|
||
) -> None:
|
||
metadata: dict[str, Any]
|
||
content: str
|
||
if action == "auto":
|
||
content = "auto"
|
||
metadata = {"staffing_action": "auto_recruit"}
|
||
elif action == "deny":
|
||
content = "deny"
|
||
metadata = {"staffing_action": "deny"}
|
||
else:
|
||
content = "approve"
|
||
metadata = _company_staffing_resume_metadata(draft or _company_staffing_default_draft(_company_staffing_payload(checkpoint)), checkpoint)
|
||
if action in {"auto", "deny"}:
|
||
checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "").strip()
|
||
if checkpoint_id:
|
||
metadata["response_to_checkpoint_id"] = checkpoint_id
|
||
metadata["response_to_checkpoint_type"] = "company_staffing_selection"
|
||
|
||
if controller is not None:
|
||
await controller.submit_control_message(
|
||
content,
|
||
message_metadata=metadata,
|
||
mode="company",
|
||
company_profile=state.company_profile,
|
||
preferred_agent=state.preferred_agent,
|
||
domains=list(state.domains),
|
||
)
|
||
return
|
||
|
||
state.runtime_display.begin_turn()
|
||
response = await state.engine.process_message(
|
||
content,
|
||
project_id=getattr(state.engine, "project_id", None),
|
||
session_id=state.session_id,
|
||
mode="company",
|
||
company_profile=state.company_profile,
|
||
preferred_agent=state.preferred_agent,
|
||
domains=list(state.domains),
|
||
message_metadata=metadata,
|
||
)
|
||
await state.runtime_display.flush()
|
||
await _sync_runtime_checkpoint_hint(state)
|
||
if hasattr(state.runtime_display, "render_status"):
|
||
await state.runtime_display.render_status(force=True)
|
||
if not state.runtime_display.has_streamed_content:
|
||
_print_response(response, state.no_markdown)
|
||
|
||
|
||
async def _maybe_run_company_staffing_preflight(state: _InteractiveChatState, controller: ChatTurnController | None = None) -> bool:
|
||
checkpoint = await _latest_company_staffing_checkpoint(state)
|
||
if checkpoint is None:
|
||
return False
|
||
key = _staffing_checkpoint_key(checkpoint)
|
||
draft = state.company_staffing_drafts.setdefault(key, _company_staffing_default_draft(_company_staffing_payload(checkpoint)))
|
||
while True:
|
||
_render_company_staffing_summary(state, checkpoint, draft)
|
||
try:
|
||
choice = console.input("[bold]Company preflight [a/e/c/r/d]: [/bold]").strip().lower()
|
||
except (EOFError, KeyboardInterrupt):
|
||
console.print("[warning]Staffing selection left pending. Reopen with /staffing.[/warning]")
|
||
return True
|
||
if choice in {"a", "approve", "y", "yes", ""}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="approve", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
if choice in {"e", "edit"}:
|
||
edited = await _edit_company_staffing_draft(state, checkpoint, draft)
|
||
if edited is not None:
|
||
draft.clear()
|
||
draft.update(edited)
|
||
state.company_staffing_drafts[key] = draft
|
||
continue
|
||
if choice in {"c", "context", "ctx", "preview"}:
|
||
_render_company_staffing_context_preview(state, checkpoint, draft)
|
||
continue
|
||
if choice in {"r", "auto", "auto recruit", "autorecruit"}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="auto", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
if choice in {"d", "deny", "cancel", "stop", "n", "no"}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="deny", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
console.print("[warning]Choose a, e, c, r, or d.[/warning]")
|
||
|
||
|
||
async def _handle_company_staffing_shortcut(
|
||
state: _InteractiveChatState,
|
||
user_input: str,
|
||
controller: ChatTurnController | None,
|
||
) -> bool:
|
||
choice = str(user_input or "").strip().lower()
|
||
if choice not in {
|
||
"a",
|
||
"approve",
|
||
"y",
|
||
"yes",
|
||
"e",
|
||
"edit",
|
||
"c",
|
||
"context",
|
||
"ctx",
|
||
"preview",
|
||
"r",
|
||
"auto",
|
||
"auto recruit",
|
||
"autorecruit",
|
||
"d",
|
||
"deny",
|
||
"cancel",
|
||
"stop",
|
||
"n",
|
||
"no",
|
||
}:
|
||
return False
|
||
checkpoint = await _latest_company_staffing_checkpoint(state)
|
||
if checkpoint is None:
|
||
return False
|
||
key = _staffing_checkpoint_key(checkpoint)
|
||
draft = state.company_staffing_drafts.setdefault(
|
||
key,
|
||
_company_staffing_default_draft(_company_staffing_payload(checkpoint)),
|
||
)
|
||
if choice in {"a", "approve", "y", "yes"}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="approve", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
if choice in {"e", "edit"}:
|
||
edited = await _edit_company_staffing_draft(state, checkpoint, draft)
|
||
if edited is not None:
|
||
draft.clear()
|
||
draft.update(edited)
|
||
state.company_staffing_drafts[key] = draft
|
||
_render_company_staffing_summary(state, checkpoint, draft)
|
||
return True
|
||
if choice in {"c", "context", "ctx", "preview"}:
|
||
_render_company_staffing_context_preview(state, checkpoint, draft)
|
||
return True
|
||
if choice in {"r", "auto", "auto recruit", "autorecruit"}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="auto", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
if choice in {"d", "deny", "cancel", "stop", "n", "no"}:
|
||
await _resume_company_staffing_checkpoint(state, checkpoint, action="deny", draft=draft, controller=controller)
|
||
state.company_staffing_drafts.pop(key, None)
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _handle_staffing_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
|
||
if args and args[0].strip().lower() not in {"context", "ctx", "preview"}:
|
||
console.print("[warning]Usage: /staffing [context][/warning]")
|
||
return
|
||
if not args and not await _maybe_run_company_staffing_preflight(state, controller=controller):
|
||
console.print("[info]No pending company staffing selection for this session.[/info]")
|
||
return
|
||
if args:
|
||
checkpoint = await _latest_company_staffing_checkpoint(state)
|
||
if checkpoint is None:
|
||
console.print("[info]No pending company staffing selection for this session.[/info]")
|
||
return
|
||
key = _staffing_checkpoint_key(checkpoint)
|
||
draft = state.company_staffing_drafts.setdefault(key, _company_staffing_default_draft(_company_staffing_payload(checkpoint)))
|
||
_render_company_staffing_context_preview(state, checkpoint, draft)
|
||
|
||
|
||
def _busy_slash_policy(command: str, args: list[str]) -> BusyCommandPolicy:
|
||
readonly_roots = {
|
||
"help",
|
||
"?",
|
||
"status",
|
||
"cost",
|
||
"runtime",
|
||
"tasks",
|
||
"checkpoints",
|
||
"checkpoint",
|
||
"logs",
|
||
"comms",
|
||
"attachments",
|
||
"work-items",
|
||
"work-item",
|
||
"queue",
|
||
"kanban",
|
||
"board",
|
||
"stop",
|
||
"continue",
|
||
}
|
||
if command in readonly_roots:
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
if command == "recover":
|
||
if not args or args[0].lower() in {"scan", "status", "list"}:
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
if command == "task":
|
||
if args and args[0].lower() == "show":
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
if command == "project":
|
||
if not args or args[0].lower() in {"list", "show"}:
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
if command == "session":
|
||
if not args or args[0].lower() in {"list", "show", "stop", "continue"}:
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
if command == "staffing":
|
||
if args and args[0].lower() == "context":
|
||
return BusyCommandPolicy.IMMEDIATE_READONLY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
return BusyCommandPolicy.BLOCKED_WHEN_BUSY
|
||
|
||
|
||
def _print_busy_slash_block(command: str) -> None:
|
||
console.print(
|
||
f"[warning]Busy: /{escape(command)} changes chat context or runtime state, so it is blocked while a turn is running.[/warning]"
|
||
)
|
||
console.print("[dim]Available now: /stop, /kanban, /runtime, /work-items, /logs, /comms, /queue, /status.[/dim]")
|
||
|
||
|
||
async def _handle_queue_slash(controller: ChatTurnController | None, args: list[str]) -> None:
|
||
if controller is None:
|
||
console.print("[warning]/queue is only available in interactive chat.[/warning]")
|
||
return
|
||
command = args[0].lower() if args else "list"
|
||
if command in {"list", "ls"}:
|
||
items = controller.queued_items()
|
||
if not items:
|
||
console.print("[info]No queued prompts.[/info]")
|
||
return
|
||
table = Table(title=f"Queued Prompts ({len(items)})")
|
||
table.add_column("#", justify="right", style="cyan")
|
||
table.add_column("Project")
|
||
table.add_column("Session")
|
||
table.add_column("Mode")
|
||
table.add_column("Prompt")
|
||
for idx, item in enumerate(items, start=1):
|
||
table.add_row(
|
||
str(idx),
|
||
item.project_id,
|
||
_session_short_id(item.session_id),
|
||
item.mode,
|
||
_clip_text(item.text, 100),
|
||
)
|
||
console.print(table)
|
||
return
|
||
if command == "drop":
|
||
if len(args) != 2:
|
||
console.print("[warning]Usage: /queue drop <n>[/warning]")
|
||
return
|
||
try:
|
||
index = int(args[1])
|
||
except ValueError:
|
||
console.print("[warning]Queue index must be a number.[/warning]")
|
||
return
|
||
item = controller.drop(index)
|
||
if item is None:
|
||
console.print("[warning]No queued prompt at that index.[/warning]")
|
||
return
|
||
console.print(f"[success]Dropped queued prompt #{index}:[/success] {_clip_text(item.text, 80)}")
|
||
return
|
||
if command == "clear":
|
||
count = controller.clear()
|
||
console.print(f"[success]Cleared {count} queued prompt(s).[/success]")
|
||
return
|
||
console.print("[warning]Usage: /queue list|drop <n>|clear[/warning]")
|
||
|
||
|
||
def _board_launch_args(state: _InteractiveChatState, args: list[str], *, default_view: str = "kanban") -> list[str]:
|
||
view = default_view
|
||
work_item = ""
|
||
role = ""
|
||
target = ""
|
||
rest = list(args)
|
||
if rest:
|
||
candidate = rest.pop(0).lower()
|
||
if candidate in {"kanban", "pipeline", "work-item", "role", "logs"}:
|
||
view = candidate
|
||
else:
|
||
rest.insert(0, candidate)
|
||
if view == "work-item" and rest:
|
||
work_item = rest[0]
|
||
elif view == "role" and rest:
|
||
role = rest[0]
|
||
elif view == "logs" and rest:
|
||
target = rest[0]
|
||
|
||
cmd = [
|
||
sys.executable,
|
||
"-m",
|
||
"opc.cli.app",
|
||
"board",
|
||
"--project",
|
||
_current_project_id(state.engine),
|
||
"--attach",
|
||
"--readonly",
|
||
"--refresh-interval",
|
||
"1.0",
|
||
"--view",
|
||
view,
|
||
]
|
||
if state.session_id:
|
||
cmd.extend(["--session", state.session_id])
|
||
if work_item:
|
||
cmd.extend(["--work-item", work_item])
|
||
if role:
|
||
cmd.extend(["--role", role])
|
||
if target:
|
||
cmd.extend(["--target", target])
|
||
return cmd
|
||
|
||
|
||
async def _launch_board_inspector(state: _InteractiveChatState, args: list[str], *, default_view: str = "kanban") -> None:
|
||
cmd = _board_launch_args(state, args, default_view=default_view)
|
||
console.print("[dim]Opening read-only board inspector. Press q or Ctrl-Q in board to return to chat.[/dim]")
|
||
display = state.runtime_display
|
||
if hasattr(display, "enter_sidecar_quiet"):
|
||
display.enter_sidecar_quiet()
|
||
try:
|
||
process = await asyncio.create_subprocess_exec(*cmd)
|
||
await process.wait()
|
||
except FileNotFoundError as exc:
|
||
console.print(f"[warning]Could not launch board inspector: {escape(str(exc))}[/warning]")
|
||
finally:
|
||
quiet_events = 0
|
||
if hasattr(display, "exit_sidecar_quiet"):
|
||
quiet_events = int(display.exit_sidecar_quiet() or 0)
|
||
suffix = f" Suppressed {quiet_events} runtime event(s); use /logs or /board logs to inspect them." if quiet_events else ""
|
||
console.print(f"[dim]Returned to chat.{suffix}[/dim]")
|
||
|
||
|
||
async def _fetch_kanban_items(
|
||
state: _InteractiveChatState,
|
||
*,
|
||
limit: int = 100,
|
||
session_id: str | None = None,
|
||
project_scope: bool = False,
|
||
) -> list[dict[str, Any]]:
|
||
from opc.plugins.office_ui.services.context import ModeState, OfficeServiceContext
|
||
from opc.plugins.office_ui.services.work_item import WorkItemService
|
||
|
||
context = OfficeServiceContext(
|
||
engine=state.engine,
|
||
agent_store=None,
|
||
chat_store=None,
|
||
event_adapter=None,
|
||
mode_state=ModeState(
|
||
exec_mode=state.mode,
|
||
company_profile=state.company_profile,
|
||
task_preferred_agent=state.preferred_agent or "native",
|
||
),
|
||
)
|
||
scoped_session_id = None if project_scope else str(session_id or state.session_id or "").strip()
|
||
result = await WorkItemService(context).list(
|
||
project_id=_current_project_id(state.engine),
|
||
session_id=scoped_session_id,
|
||
limit=limit,
|
||
kanban_visible_only=True,
|
||
)
|
||
return list(result.payload.get("work_items", []) or [])
|
||
|
||
|
||
def _kanban_items_fingerprint(items: list[dict[str, Any]]) -> str:
|
||
rows = []
|
||
for item in items:
|
||
rows.append((
|
||
str(item.get("work_item_id", "") or ""),
|
||
str(item.get("title", "") or ""),
|
||
str(item.get("role_id", "") or ""),
|
||
str(item.get("phase", "") or ""),
|
||
str(item.get("kanban_column", "") or ""),
|
||
str(item.get("runtime_task_id", "") or ""),
|
||
str(item.get("session_id", "") or ""),
|
||
str(item.get("updated_at", "") or ""),
|
||
))
|
||
return json.dumps(sorted(rows), ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def _render_kanban_items(items: list[dict[str, Any]], *, title: str = "Kanban Work Items") -> None:
|
||
if not items:
|
||
console.print("[info]No company work items found yet.[/info]")
|
||
return
|
||
counts: dict[str, int] = {}
|
||
for item in items:
|
||
column = str(item.get("kanban_column") or item.get("phase") or "unknown")
|
||
counts[column] = counts.get(column, 0) + 1
|
||
summary = ", ".join(f"{column}:{count}" for column, count in sorted(counts.items()))
|
||
table = Table(title=f"{title} ({len(items)} | {summary})")
|
||
table.add_column("Work Item")
|
||
table.add_column("Title")
|
||
table.add_column("Role")
|
||
table.add_column("Column")
|
||
table.add_column("Phase")
|
||
table.add_column("Runtime Task")
|
||
table.add_column("Updated")
|
||
for item in items:
|
||
table.add_row(
|
||
str(item.get("work_item_id", "") or "")[:12],
|
||
_clip_text(item.get("title", "") or "", 64),
|
||
str(item.get("role_id", "") or ""),
|
||
str(item.get("kanban_column", "") or ""),
|
||
str(item.get("phase", "") or ""),
|
||
str(item.get("runtime_task_id", "") or "")[:12],
|
||
_clip_text(item.get("updated_at", "") or "", 19),
|
||
)
|
||
console.print(table)
|
||
|
||
|
||
async def _handle_kanban_slash(state: _InteractiveChatState, args: list[str], controller: ChatTurnController | None = None) -> None:
|
||
normalized = [arg.strip().lower() for arg in args]
|
||
if normalized and normalized[0] in {"stop", "off", "clear"}:
|
||
if controller is None:
|
||
console.print("[warning]Kanban live watch is only available in interactive chat.[/warning]")
|
||
return
|
||
await controller.stop_kanban_watch()
|
||
return
|
||
watch = True
|
||
project_scope = False
|
||
for token in normalized:
|
||
if token in {"once", "--once", "--summary", "summary"}:
|
||
watch = False
|
||
elif token in {"all", "project", "--all", "--project"}:
|
||
project_scope = True
|
||
else:
|
||
console.print("[warning]Usage: /kanban [once|stop|all][/warning]")
|
||
return
|
||
session_id = str(state.session_id or "").strip()
|
||
title = "Kanban Work Items (project)" if project_scope else f"Kanban Work Items (session {session_id[:8]})"
|
||
try:
|
||
items = await _fetch_kanban_items(state, limit=100, session_id=session_id, project_scope=project_scope)
|
||
except Exception as exc:
|
||
console.print(f"[warning]Could not load kanban work items: {escape(str(exc))}[/warning]")
|
||
return
|
||
_render_kanban_items(items, title=title)
|
||
if watch and controller is not None:
|
||
await controller.start_kanban_watch(
|
||
initial_fingerprint=_kanban_items_fingerprint(items),
|
||
session_id=session_id,
|
||
project_scope=project_scope,
|
||
)
|
||
elif watch:
|
||
console.print("[dim]Live watch is only available in interactive chat.[/dim]")
|
||
|
||
|
||
async def _handle_board_slash(state: _InteractiveChatState, args: list[str]) -> None:
|
||
if not args:
|
||
args = ["kanban"]
|
||
await _launch_board_inspector(state, args, default_view="kanban")
|
||
|
||
|
||
async def _handle_chat_slash_command(state: _InteractiveChatState, user_input: str, controller: ChatTurnController | None = None) -> bool:
|
||
if not user_input.startswith("/"):
|
||
return False
|
||
try:
|
||
parts = _slash_parts(user_input)
|
||
except ValueError as exc:
|
||
console.print(f"[warning]Could not parse command: {exc}[/warning]")
|
||
return True
|
||
if not parts:
|
||
_print_slash_help()
|
||
return True
|
||
|
||
command = _canonical_slash_command(parts[0])
|
||
args = parts[1:]
|
||
if controller is not None and controller.is_busy and command in _slash_command_names():
|
||
policy = _busy_slash_policy(command, args)
|
||
if policy == BusyCommandPolicy.BLOCKED_WHEN_BUSY:
|
||
_print_busy_slash_block(command)
|
||
return True
|
||
if command in {"help", "?"}:
|
||
_print_slash_help()
|
||
elif command == "queue":
|
||
await _handle_queue_slash(controller, args)
|
||
elif command == "status":
|
||
_print_interactive_status(state.engine, state)
|
||
elif command == "cost":
|
||
_print_interactive_cost(state.engine)
|
||
elif command == "mode":
|
||
await _handle_mode_slash(state, args)
|
||
elif command == "agent":
|
||
await _handle_agent_slash(state, args)
|
||
elif command == "domains":
|
||
_handle_domains_slash(state, args)
|
||
elif command == "project":
|
||
await _handle_project_slash(state, args)
|
||
elif command in {"new-session", "new_session"}:
|
||
_start_new_chat_session(state)
|
||
elif command == "sessions":
|
||
await _render_sessions(state, args)
|
||
elif command == "resume":
|
||
await _resume_chat_session(state, args[0] if args else "")
|
||
elif command == "session":
|
||
await _handle_session_slash(state, args, controller=controller)
|
||
elif command == "stop":
|
||
await _handle_stop_slash(state, args, controller=controller)
|
||
elif command == "continue":
|
||
await _handle_continue_slash(state, args, controller=controller)
|
||
elif command == "tasks":
|
||
await _handle_tasks_slash(state, args)
|
||
elif command == "task":
|
||
await _handle_task_slash(state, args)
|
||
elif command in {"checkpoints", "checkpoint"}:
|
||
await _handle_checkpoints_slash(state, args)
|
||
elif command == "org":
|
||
await _handle_org_slash(state, args)
|
||
elif command == "talent":
|
||
await _handle_talent_slash(state, args)
|
||
elif command == "market":
|
||
await _handle_market_slash(state, args)
|
||
elif command == "reorg":
|
||
await _handle_reorg_slash(state, args)
|
||
elif command == "runtime":
|
||
await _handle_runtime_slash(state, args)
|
||
elif command == "recover":
|
||
await _handle_recover_slash(state, args)
|
||
elif command in {"work-items", "work-item"}:
|
||
await _handle_work_items_slash(state, args)
|
||
elif command == "logs":
|
||
await _handle_logs_slash(state, args)
|
||
elif command == "comms":
|
||
await _handle_comms_slash(state, args)
|
||
elif command == "attachments":
|
||
await _handle_attachments_slash(state, args)
|
||
elif command == "staffing":
|
||
await _handle_staffing_slash(state, args, controller=controller)
|
||
elif command == "kanban":
|
||
await _handle_kanban_slash(state, args, controller=controller)
|
||
elif command == "board":
|
||
await _handle_board_slash(state, args)
|
||
else:
|
||
console.print(f"[warning]Unknown command: /{escape(command)}. Try /help for available commands.[/warning]")
|
||
return True
|
||
|
||
|
||
async def _sync_runtime_checkpoint_hint(state: _InteractiveChatState) -> None:
|
||
display = state.runtime_display
|
||
if not hasattr(display, "set_checkpoint_hint"):
|
||
return
|
||
getter = getattr(state.engine, "get_latest_pending_checkpoint_for_session", None)
|
||
if not callable(getter) or not state.session_id:
|
||
display.set_checkpoint_hint("")
|
||
return
|
||
try:
|
||
checkpoint = await getter(state.session_id)
|
||
except Exception:
|
||
return
|
||
if not checkpoint:
|
||
display.set_checkpoint_hint("")
|
||
return
|
||
checkpoint_type = str(getattr(checkpoint, "checkpoint_type", "") or "pending")
|
||
checkpoint_id = str(getattr(checkpoint, "checkpoint_id", "") or "")
|
||
display.set_checkpoint_hint(checkpoint_type or checkpoint_id)
|
||
|
||
|
||
async def _process_interactive_chat_message(
|
||
state: _InteractiveChatState,
|
||
user_input: str,
|
||
*,
|
||
message_metadata: dict[str, Any] | None = None,
|
||
interactive_followups: bool = True,
|
||
) -> None:
|
||
console.print()
|
||
try:
|
||
state.runtime_display.begin_turn()
|
||
effective_metadata = message_metadata
|
||
if effective_metadata is None:
|
||
suspend_checkpoint = await _latest_company_suspend_checkpoint(state)
|
||
if suspend_checkpoint is not None:
|
||
effective_metadata = {
|
||
"response_to_checkpoint_id": str(getattr(suspend_checkpoint, "checkpoint_id", "") or ""),
|
||
"response_to_checkpoint_type": str(getattr(suspend_checkpoint, "checkpoint_type", "") or "company_runtime_suspended"),
|
||
}
|
||
elif state.mode == "company":
|
||
effective_metadata = {"company_preflight": "manual"}
|
||
response = await state.engine.process_message(
|
||
user_input,
|
||
project_id=getattr(state.engine, "project_id", None),
|
||
session_id=state.session_id,
|
||
mode=state.mode,
|
||
org_id=state.org_id or None,
|
||
company_profile=state.company_profile if state.mode == "company" else None,
|
||
preferred_agent=state.preferred_agent,
|
||
domains=list(state.domains),
|
||
message_metadata=effective_metadata,
|
||
)
|
||
await state.runtime_display.flush()
|
||
await _sync_runtime_checkpoint_hint(state)
|
||
handled_staffing_preflight = False
|
||
if state.mode == "company":
|
||
if interactive_followups:
|
||
handled_staffing_preflight = await _maybe_run_company_staffing_preflight(state)
|
||
else:
|
||
checkpoint = await _latest_company_staffing_checkpoint(state)
|
||
if checkpoint is not None:
|
||
key = _staffing_checkpoint_key(checkpoint)
|
||
draft = state.company_staffing_drafts.setdefault(
|
||
key,
|
||
_company_staffing_default_draft(_company_staffing_payload(checkpoint)),
|
||
)
|
||
_render_company_staffing_summary(state, checkpoint, draft)
|
||
console.print("[dim]Type a/e/c/r/d at opc>; /staffing is only a fallback reopen command.[/dim]")
|
||
handled_staffing_preflight = True
|
||
if hasattr(state.runtime_display, "render_status") and not handled_staffing_preflight:
|
||
await state.runtime_display.render_status(force=True)
|
||
if not state.runtime_display.has_streamed_content and not handled_staffing_preflight:
|
||
_print_response(response, state.no_markdown)
|
||
if effective_metadata and (
|
||
effective_metadata.get("ui_force_resume")
|
||
or str(effective_metadata.get("response_to_checkpoint_type", "") or "") in {
|
||
"company_runtime_suspended",
|
||
"company_runtime_interrupted",
|
||
}
|
||
):
|
||
state.runtime_control_state = "running"
|
||
except KeyboardInterrupt:
|
||
console.print("\n[warning]Interrupted. Type /quit to exit.[/warning]")
|
||
except Exception as e:
|
||
console.print(f"[error]Error: {escape(str(e))}[/error]")
|
||
|
||
|
||
async def _interactive_mode(
|
||
config,
|
||
project: str | None,
|
||
no_markdown: bool,
|
||
*,
|
||
mode: str = "task",
|
||
preferred_agent: str | None = None,
|
||
company_profile: str | None = None,
|
||
explicit_mode: bool = False,
|
||
explicit_agent: bool = False,
|
||
explicit_company_profile: bool = False,
|
||
) -> None:
|
||
engine, runtime_display = _create_cli_engine(config, project)
|
||
|
||
try:
|
||
await engine.initialize()
|
||
except Exception as e:
|
||
console.print(f"[error]Failed to initialize: {escape(str(e))}[/error]")
|
||
return
|
||
|
||
state = _InteractiveChatState(
|
||
config=config,
|
||
engine=engine,
|
||
runtime_display=runtime_display,
|
||
session_id="",
|
||
no_markdown=no_markdown,
|
||
mode="company" if str(mode or "").strip().lower() == "company" else "task",
|
||
company_profile=company_profile or _initial_company_profile(config),
|
||
preferred_agent=_normalize_interactive_preferred_agent(preferred_agent),
|
||
)
|
||
_attach_cli_runtime_callbacks(state)
|
||
await _restore_chat_context(
|
||
state,
|
||
restore_mode=not explicit_mode,
|
||
restore_company_profile=not explicit_company_profile,
|
||
restore_agent=not explicit_agent,
|
||
)
|
||
|
||
console.print(Panel(
|
||
f"[bold]OPC v{__version__}[/bold] — One-Person Company\n"
|
||
f"Model: {config.llm.default_model}\n"
|
||
f"Current mode: {state.mode}"
|
||
f"{f' ({state.company_profile})' if state.mode == 'company' else ''}\n"
|
||
f"Current agent: {state.preferred_agent or 'system'}\n"
|
||
f"Choose a project and session to begin.",
|
||
border_style="blue",
|
||
))
|
||
try:
|
||
await _run_interactive_startup_selector(state, explicit_project=bool(project))
|
||
except KeyboardInterrupt:
|
||
console.print("\n[warning]Interrupted.[/warning]")
|
||
await state.engine.shutdown()
|
||
return
|
||
|
||
controller = ChatTurnController(state)
|
||
try:
|
||
from prompt_toolkit import PromptSession
|
||
from prompt_toolkit.history import FileHistory
|
||
from prompt_toolkit.patch_stdout import patch_stdout
|
||
from prompt_toolkit.styles import Style
|
||
|
||
history_path = state.engine.opc_home / "cli_history"
|
||
session = PromptSession(
|
||
history=FileHistory(str(history_path)),
|
||
completer=_OPCSlashCompleter(),
|
||
complete_while_typing=True,
|
||
bottom_toolbar=lambda: _chat_bottom_toolbar_text(state, controller),
|
||
style=Style.from_dict({"bottom-toolbar": "fg:#888888"}),
|
||
)
|
||
|
||
while True:
|
||
await _print_pending_checkpoint_hint(state.engine, state.session_id)
|
||
try:
|
||
with patch_stdout(raw=True):
|
||
user_input = await session.prompt_async("opc> ")
|
||
except (EOFError, KeyboardInterrupt):
|
||
break
|
||
|
||
user_input = user_input.strip()
|
||
if not user_input:
|
||
continue
|
||
|
||
if user_input in ("/quit", "/exit", "exit", "quit"):
|
||
break
|
||
if user_input.startswith("/"):
|
||
await _handle_chat_slash_command(state, user_input, controller=controller)
|
||
continue
|
||
if await _handle_company_staffing_shortcut(state, user_input, controller):
|
||
continue
|
||
|
||
await controller.submit_user_message(user_input)
|
||
continue
|
||
|
||
except ImportError:
|
||
console.print("[warning]prompt-toolkit not available, using basic input[/warning]")
|
||
while True:
|
||
await _print_pending_checkpoint_hint(state.engine, state.session_id)
|
||
try:
|
||
user_input = await asyncio.get_event_loop().run_in_executor(
|
||
None, lambda: input("opc> ")
|
||
)
|
||
user_input = user_input.strip()
|
||
except (EOFError, KeyboardInterrupt):
|
||
break
|
||
if user_input in ("/quit", "/exit", "exit", "quit"):
|
||
break
|
||
if not user_input:
|
||
continue
|
||
if user_input.startswith("/"):
|
||
await _handle_chat_slash_command(state, user_input, controller=controller)
|
||
continue
|
||
if await _handle_company_staffing_shortcut(state, user_input, controller):
|
||
continue
|
||
await controller.submit_user_message(user_input)
|
||
continue
|
||
|
||
finally:
|
||
await controller.shutdown()
|
||
await state.engine.shutdown()
|
||
console.print("[info]Goodbye![/info]")
|
||
|
||
|
||
async def _interactive_secretary_mode(config, project: str | None, no_markdown: bool) -> None:
|
||
engine, runtime_display = _create_cli_engine(config, project)
|
||
|
||
try:
|
||
await engine.initialize()
|
||
except Exception as e:
|
||
console.print(f"[error]Failed to initialize: {escape(str(e))}[/error]")
|
||
return
|
||
|
||
console.print(Panel(
|
||
f"[bold]OPC Secretary[/bold]\n"
|
||
f"Model: {config.llm.default_model}\n"
|
||
f"Project: {project or '(none)'}\n"
|
||
f"Commands: /quit, /project <id>, /new-session, /sessions, /resume <session_id>, /policies, /help",
|
||
border_style="green",
|
||
))
|
||
|
||
try:
|
||
from prompt_toolkit import PromptSession
|
||
from prompt_toolkit.history import FileHistory
|
||
|
||
history_path = engine.opc_home / "secretary_cli_history"
|
||
prompt = PromptSession(history=FileHistory(str(history_path)))
|
||
current_session_id, restored_latest = await _resolve_secretary_session_id(engine)
|
||
if restored_latest:
|
||
console.print(f"[info]Restored recent secretary session: {current_session_id}[/info]")
|
||
else:
|
||
console.print(f"[info]Started a new secretary session: {current_session_id}[/info]")
|
||
|
||
while True:
|
||
try:
|
||
user_input = await asyncio.get_event_loop().run_in_executor(
|
||
None, lambda: prompt.prompt("secretary> ")
|
||
)
|
||
except (EOFError, KeyboardInterrupt):
|
||
break
|
||
user_input = user_input.strip()
|
||
if not user_input:
|
||
continue
|
||
if user_input in ("/quit", "/exit", "exit", "quit"):
|
||
break
|
||
if user_input.startswith("/project "):
|
||
new_project = user_input.split(" ", 1)[1].strip()
|
||
await engine.shutdown()
|
||
engine, runtime_display = _create_cli_engine(config, new_project)
|
||
await engine.initialize()
|
||
current_session_id, restored_latest = await _resolve_secretary_session_id(engine)
|
||
console.print(f"[info]Switched to project: {new_project}[/info]")
|
||
if restored_latest:
|
||
console.print(f"[info]Restored recent secretary session: {current_session_id}[/info]")
|
||
else:
|
||
console.print(f"[info]Started a new secretary session: {current_session_id}[/info]")
|
||
continue
|
||
if user_input == "/new-session":
|
||
current_session_id = str(uuid.uuid4())
|
||
console.print(f"[info]Started a new secretary session: {current_session_id}[/info]")
|
||
continue
|
||
if user_input == "/sessions":
|
||
sessions = await engine.secretary.list_sessions(engine.project_id, limit=20) if engine.secretary else []
|
||
if not sessions:
|
||
console.print("[info]No secretary sessions found for this project.[/info]")
|
||
else:
|
||
console.print("[bold]Recent secretary sessions:[/bold]")
|
||
for item in sessions:
|
||
title = item.title or "(untitled)"
|
||
console.print(f" - {item.session_id} :: {title}")
|
||
continue
|
||
if user_input.startswith("/resume "):
|
||
requested_session_id = user_input.split(" ", 1)[1].strip()
|
||
target = await engine.store.get_session(requested_session_id) if engine.store else None
|
||
if not target or target.metadata.get("interface") != "secretary":
|
||
console.print(f"[warning]Secretary session not found: {requested_session_id}[/warning]")
|
||
continue
|
||
current_project = engine.project_id or "default"
|
||
target_project = target.project_id or "default"
|
||
if target_project != current_project:
|
||
console.print(
|
||
"[warning]"
|
||
f"Secretary session {requested_session_id} belongs to project '{target_project}'. "
|
||
f"Switch project first with /project {target_project}."
|
||
"[/warning]"
|
||
)
|
||
continue
|
||
current_session_id = requested_session_id
|
||
console.print(f"[info]Resumed secretary session: {current_session_id}[/info]")
|
||
continue
|
||
if user_input == "/policies":
|
||
summary = engine.secretary.describe_policies(engine.project_id) if engine.secretary else ""
|
||
_print_response(summary, no_markdown)
|
||
continue
|
||
if user_input == "/help":
|
||
console.print(
|
||
"/quit — Exit\n"
|
||
"/project <id> — Switch project\n"
|
||
"/new-session — Start a fresh secretary session\n"
|
||
"/sessions — List secretary sessions for this project\n"
|
||
"/resume <session_id> — Resume an existing secretary session\n"
|
||
"/policies — Show active secretary policies\n"
|
||
"/help — This help"
|
||
)
|
||
continue
|
||
|
||
runtime_display.begin_turn()
|
||
payload = await engine.process_secretary_message(
|
||
user_input,
|
||
project_id=engine.project_id,
|
||
session_id=current_session_id,
|
||
)
|
||
await runtime_display.flush()
|
||
if not runtime_display.has_streamed_content:
|
||
_print_response(payload.get("response", ""), no_markdown)
|
||
|
||
except ImportError:
|
||
console.print("[warning]prompt-toolkit not available, using basic input[/warning]")
|
||
finally:
|
||
await engine.shutdown()
|
||
console.print("[info]Goodbye![/info]")
|
||
|
||
|
||
async def _show_cost_summary(config) -> None:
|
||
from opc.core.config import get_opc_home
|
||
db_path = get_opc_home() / "global.db"
|
||
if not db_path.exists():
|
||
return
|
||
store = OPCStore(db_path)
|
||
await store.initialize()
|
||
try:
|
||
costs = await store.get_total_cost()
|
||
if costs["total_calls"] > 0:
|
||
console.print(f"\n[bold]Cost Summary:[/bold]")
|
||
console.print(f" Total calls: {costs['total_calls']}")
|
||
console.print(f" Total tokens: {costs['total_tokens_in'] + costs['total_tokens_out']}")
|
||
console.print(f" Total cost: ${costs['total_cost']:.4f}")
|
||
finally:
|
||
await store.close()
|
||
|
||
|
||
async def _show_autonomy_summary(config, project: str | None = None) -> None:
|
||
from opc.core.config import get_opc_home
|
||
from opc.layer5_memory.approval_allowlist import ApprovalAllowlistManager
|
||
from opc.layer5_memory.preference import PreferenceManager
|
||
|
||
db_path = get_opc_home() / "global.db"
|
||
if not db_path.exists():
|
||
return
|
||
store = OPCStore(db_path)
|
||
await store.initialize()
|
||
try:
|
||
stats = await store.get_autonomy_stats(project_id=project)
|
||
console.print(f"\n[bold]Autonomy Summary:[/bold]")
|
||
console.print(f" Decisions: {stats['total']}")
|
||
console.print(f" Auto-approved: {stats['auto_approved']}")
|
||
console.print(f" Escalated: {stats['escalated']}")
|
||
console.print(f" Rejected: {stats['rejected']}")
|
||
console.print(f" Auto-approval rate: {stats['auto_approval_rate']:.0%}")
|
||
|
||
prefs = PreferenceManager(get_opc_home())
|
||
learned = prefs.get_autonomy_preferences(project_id=project).get("learned_actions", {})
|
||
if learned:
|
||
console.print(" Learned actions:")
|
||
for name, data in list(learned.items())[:10]:
|
||
console.print(
|
||
f" - {name}: approvals={data.get('approvals', 0)}, "
|
||
f"rejections={data.get('rejections', 0)}, "
|
||
f"explicit_allow={data.get('explicit_allow', False)}, "
|
||
f"explicit_deny={data.get('explicit_deny', False)}"
|
||
)
|
||
allowlist_lines = ApprovalAllowlistManager(get_opc_home()).summarize(project_id=project, limit=10)
|
||
if allowlist_lines:
|
||
console.print(" Persisted allowlist:")
|
||
for line in allowlist_lines:
|
||
console.print(f" {line}")
|
||
finally:
|
||
await store.close()
|
||
|
||
|
||
def _print_response(text: str, no_markdown: bool = False) -> None:
|
||
if no_markdown:
|
||
console.print(text)
|
||
else:
|
||
try:
|
||
md = Markdown(text)
|
||
console.print(md)
|
||
except Exception:
|
||
console.print(text)
|
||
console.print()
|
||
|
||
|
||
async def _print_pending_checkpoint_hint(engine, session_id: str) -> None:
|
||
checkpoint = await engine.get_latest_pending_checkpoint_for_session(session_id)
|
||
if not checkpoint:
|
||
return
|
||
if checkpoint.checkpoint_type == "company_recruitment_confirmation":
|
||
console.print(
|
||
"[info]Pending staffing confirmation for this session. "
|
||
"Reply `1` or `approve` to continue, `2` or `deny` to cancel. "
|
||
"Any other input will be treated as feedback/suggestions for revising the proposal.[/info]"
|
||
)
|
||
return
|
||
if checkpoint.checkpoint_type == "company_staffing_selection":
|
||
payload = dict(getattr(checkpoint, "payload", {}) or {})
|
||
recommended = str(payload.get("recommended_action", "") or "").strip()
|
||
if recommended == "auto_recruit":
|
||
console.print(
|
||
"[info]Pending company staffing preflight for this session. "
|
||
"`auto recruit` is recommended; use `/staffing` to review/edit employee, template, and agent choices.[/info]"
|
||
)
|
||
else:
|
||
console.print(
|
||
"[info]Pending manual staffing selection for this session. "
|
||
"Use `/staffing` to review/edit employee and agent choices, reply `approve` to use defaults, "
|
||
"or `auto` / `auto recruit` to run automatic recruitment.[/info]"
|
||
)
|
||
return
|
||
if checkpoint.checkpoint_type == "company_work_item_gate":
|
||
console.print(
|
||
"[info]Pending work-item confirmation for this session. "
|
||
"Reply with `approve` / `continue` or `deny` / `stop`.[/info]"
|
||
)
|
||
return
|
||
if checkpoint.checkpoint_type == "company_delivery_feedback":
|
||
console.print(
|
||
"[info]Pending delivery self-evolution review for this session. "
|
||
"Reply with `approve` to record agreement, or explicit feedback to update employee experience. "
|
||
"Start normal follow-up work from the regular conversation after this review is handled.[/info]"
|
||
)
|
||
return
|
||
if checkpoint.checkpoint_type in {"task_user_input", "task_peer_wait", "company_peer_wait"}:
|
||
console.print("[info]Pending checkpoint for this session. Your next reply will resume that confirmation flow.[/info]")
|
||
|
||
|
||
def _print_interactive_status(engine, state: _InteractiveChatState | None = None) -> None:
|
||
if state is not None:
|
||
_print_context_status(state)
|
||
if getattr(engine, "llm", None):
|
||
stats = getattr(engine.llm, "stats", {}) or {}
|
||
model = getattr(getattr(getattr(engine, "config", None), "llm", None), "default_model", "unknown")
|
||
console.print(f" Model: {model}")
|
||
console.print(f" Project: {_current_project_id(engine)}")
|
||
console.print(f" Tokens: in={stats.get('tokens_in', 0)}, out={stats.get('tokens_out', 0)}")
|
||
console.print(f" Cost: ${float(stats.get('estimated_cost', 0.0) or 0.0):.4f}")
|
||
if getattr(engine, "adapter_registry", None):
|
||
available = engine.adapter_registry.list_available()
|
||
console.print(f" External agents: {', '.join(available) if available else 'none'}")
|
||
|
||
|
||
async def _load_recent_primary_session(store: OPCStore | None, project_id: str | None) -> tuple[str | None, str]:
|
||
sessions = await _load_recent_primary_sessions(store, project_id, limit=1)
|
||
if not sessions:
|
||
return None, ""
|
||
latest = sessions[0]
|
||
return latest.session_id, (latest.title or "").strip()
|
||
|
||
|
||
async def _resolve_starting_session_id(engine) -> tuple[str, bool]:
|
||
session_id, _ = await _load_recent_primary_session(engine.store, engine.project_id)
|
||
if session_id:
|
||
return session_id, True
|
||
return str(uuid.uuid4()), False
|
||
|
||
|
||
async def _load_recent_secretary_session(engine) -> tuple[str | None, str]:
|
||
if not engine.secretary:
|
||
return None, ""
|
||
sessions = await engine.secretary.list_sessions(engine.project_id, limit=1)
|
||
if not sessions:
|
||
return None, ""
|
||
latest = sessions[0]
|
||
return latest.session_id, (latest.title or "").strip()
|
||
|
||
|
||
async def _resolve_secretary_session_id(engine) -> tuple[str, bool]:
|
||
session_id, _ = await _load_recent_secretary_session(engine)
|
||
if session_id:
|
||
return session_id, True
|
||
return str(uuid.uuid4()), False
|
||
|
||
|
||
async def _run_channel_runtime(config, project: str | None) -> None:
|
||
engine, _runtime_display = _create_cli_engine(config, project)
|
||
try:
|
||
await engine.initialize()
|
||
if not engine.channel_manager:
|
||
console.print("[warning]Channel manager is not available.[/warning]")
|
||
return
|
||
await engine.channel_manager.start_all()
|
||
if not engine.channel_manager.enabled_channels:
|
||
console.print("[warning]No channels enabled. Update .opc/config/channel_config.yaml first.[/warning]")
|
||
return
|
||
_write_channel_runtime_state(engine.channel_manager.enabled_channels)
|
||
console.print(f"[success]Channel runtime started:[/success] {', '.join(engine.channel_manager.enabled_channels)}")
|
||
await engine.message_bus.start()
|
||
except KeyboardInterrupt:
|
||
console.print("\n[warning]Stopping channel runtime...[/warning]")
|
||
finally:
|
||
_clear_channel_runtime_state()
|
||
await engine.shutdown()
|
||
|
||
|
||
# ── Plugins ────────────────────────────────────────────────────────────────
|
||
try:
|
||
from opc.plugins.office_ui import register_cli
|
||
register_cli(app)
|
||
except ImportError:
|
||
pass
|
||
|
||
try:
|
||
from opc.plugins.cli_board import register_cli as register_cli_board
|
||
register_cli_board(app)
|
||
except ImportError:
|
||
pass
|
||
|
||
|
||
def main():
|
||
keylog_path = pop_windows_sslkeylogfile()
|
||
if keylog_path:
|
||
console.print(
|
||
f"[warning]{escape(format_windows_sslkeylog_warning(_current_command_label(), keylog_path))}[/warning]"
|
||
)
|
||
app()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|