Files
turnstone/turnstone/cli.py
T
Patrick Buckley eb2a119da9 refactor(mcp): remove periodic refresh, add manual refresh/reconnect controls
Deletes the _periodic_refresh task and its supporting state
(_refresh_task, _refresh_failures, _refresh_backoff_until,
_REFRESH_BACKOFF_BASE/MAX, _DEFAULT_REFRESH_INTERVAL, refresh_interval
kwarg) from MCPClientManager. Push notifications and operator-driven
manual refresh now cover all catalog-update needs; the long-running
4-hour timer was dead complexity that obscured the per-user pool
work to come.

Catalog freshness on auto-reconnect is preserved by scheduling an
unblocking _refresh_server task on the mcp-loop after _connect_one
succeeds; the calling thread returns immediately so half-open
recovery latency does not double. Adds MCPClientManager.reconnect_sync
(clears the circuit, closes any existing session, calls _connect_one,
clears stale catalog on failure).

Wires a new pair of operator endpoints —
POST /v1/api/admin/mcp-servers/{name}/refresh and
/v1/api/admin/mcp-servers/{name}/reconnect — that fan out to all
nodes through the existing _internal route family, with per-row
"Refresh" and "Reconnect" buttons in the MCP Servers admin tab.
The new node-internal paths /api/_internal/mcp-{refresh,reconnect}/
are gated to the approve scope to prevent direct unprivileged
reconnects bypassing the console's admin.mcp gate. Internal
endpoints return generic error messages and a filtered status
payload (no command/url) to keep transport details admin-gated.

Drops the [mcp] refresh_interval setting, the
--mcp-refresh-interval CLI flag, and the matching config-mapping
entry; updates docs/architecture.md, docs/tools.md,
docs/settings.md, and the three PlantUML diagrams that referenced
the periodic loop.

Tradeoffs (intentional):
- Idle nodes will not auto-rejoin a recovered MCP server until
  traffic arrives or an operator clicks Reconnect. The previous
  background reconnection loop is gone by design — push
  notifications + operator controls replace it.
- Console fan-out blocks on the slowest node (existing pattern);
  not changed here.

This is Phase 1 of the OAuth-MCP series — feature subtraction
ahead of per-user state.
2026-05-04 22:00:23 -07:00

1324 lines
48 KiB
Python

"""Terminal CLI frontend for turnstone.
Provides TerminalUI (implementing the SessionUI protocol), readline setup,
model auto-detection, workstream management, and the main() REPL entry point.
"""
from __future__ import annotations
import argparse
import logging
import os
import readline
import sys
import textwrap
import threading
from typing import TYPE_CHECKING, Any
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.judge import JudgeConfig
from turnstone.core.session import ChatSession, SessionUI
from turnstone.core.session_manager import SessionManager
from turnstone.core.workstream import (
Workstream,
WorkstreamKind,
WorkstreamState,
)
from turnstone.ui.colors import (
BOLD,
DIM,
GREEN,
RED,
RESET,
YELLOW,
bold,
cyan,
dim,
green,
red,
yellow,
)
from turnstone.ui.markdown import MarkdownRenderer
from turnstone.ui.spinner import Spinner
# ANSI colors for intent verdict risk levels
_VERDICT_COLORS: dict[str, str] = {
"low": GREEN,
"medium": YELLOW,
"high": RED,
"critical": f"{BOLD}{RED}",
}
if TYPE_CHECKING:
from collections.abc import Callable
# ─── Readline ─────────────────────────────────────────────────────────────
SLASH_COMMANDS = [
"/instructions",
"/clear",
"/new",
"/workstreams",
"/resume",
"/name",
"/delete",
"/history",
"/model",
"/raw",
"/reason",
"/compact",
"/creative",
"/debug",
"/mcp",
"/retry",
"/rewind",
"/help",
"/exit",
"/quit",
"/q",
"/ws",
"/cluster",
]
def _completer(text: str, state: int) -> str | None:
"""Tab-complete slash commands."""
matches = [c for c in SLASH_COMMANDS if c.startswith(text)] if text.startswith("/") else []
if state < len(matches):
return matches[state] + " "
return None
def setup_readline() -> None:
"""Set up readline with tab completion."""
readline.set_history_length(1000)
readline.set_completer(_completer)
readline.set_completer_delims("") # treat entire line as completion input
readline.parse_and_bind("tab: complete")
# ─── TerminalUI ───────────────────────────────────────────────────────────
class TerminalUI(SessionUI):
"""Terminal-based UI using ANSI colors, MarkdownRenderer, and Spinner."""
def __init__(self) -> None:
self.md = MarkdownRenderer()
self.spinner: Spinner | None = None
self._print_lock = threading.Lock()
self.auto_approve = False
self.auto_approve_tools: set[str] = set()
def on_thinking_start(self) -> None:
self.spinner = Spinner("Thinking")
self.spinner.start()
def on_thinking_stop(self) -> None:
if self.spinner:
self.spinner.stop()
self.spinner = None
def on_reasoning_token(self, text: str) -> None:
sys.stdout.write(f"{DIM}{text}{RESET}")
sys.stdout.flush()
def on_content_token(self, text: str) -> None:
rendered = self.md.feed(text)
if rendered:
sys.stdout.write(rendered)
sys.stdout.flush()
def on_stream_end(self) -> None:
remainder = self.md.flush()
if remainder:
sys.stdout.write(remainder)
self.md.in_code_block = False
sys.stdout.write("\n")
sys.stdout.flush()
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
"""Display tool previews and prompt for batch approval.
Returns (approved: bool, feedback: str | None).
"""
pending = [it for it in items if it.get("needs_approval") and not it.get("error")]
# Evaluate admin tool policies (deny/allow/ask) before prompting.
if pending:
try:
from turnstone.core.policy import evaluate_tool_policies_batch
from turnstone.core.storage._registry import get_storage
storage = get_storage()
if storage is not None:
_policy_names = [
it.get("approval_label", "") or it.get("func_name", "")
for it in pending
if it.get("func_name")
]
if _policy_names:
verdicts = evaluate_tool_policies_batch(storage, _policy_names)
for it in pending:
policy_name = it.get("approval_label", "") or it.get("func_name", "")
verdict = verdicts.get(policy_name)
if verdict == "deny":
it["denied"] = True
it["error"] = f"Blocked by tool policy ('{policy_name}')"
it["needs_approval"] = False
elif verdict == "allow":
it["needs_approval"] = False
pending = [
it for it in items if it.get("needs_approval") and not it.get("error")
]
except Exception:
logging.getLogger(__name__).debug("Policy evaluation unavailable", exc_info=True)
with self._print_lock:
# Print all headers, previews, and heuristic verdicts
for item in items:
if item.get("error"):
sys.stdout.write(f" {red(item['header'])}\n")
sys.stdout.write(f" {red(item['error'])}\n")
else:
sys.stdout.write(f" {yellow(item['header'])}\n")
if item.get("preview"):
styled = dim(item["preview"]) if not item.get("error") else red(item["preview"])
sys.stdout.write(styled + "\n")
verdict = item.get("_heuristic_verdict")
if verdict:
risk = verdict.get("risk_level", "medium")
rec = verdict.get("recommendation", "review")
conf = int(verdict.get("confidence", 0.5) * 100)
summary = verdict.get("intent_summary", "")
color = _VERDICT_COLORS.get(risk, "")
sys.stdout.write(
f" {color}RISK: {risk} (confidence: {conf}%) \u2014 {rec}{RESET}\n"
)
if summary:
sys.stdout.write(f" Intent: {summary}\n")
sys.stdout.flush()
if not pending or self.auto_approve:
return True, None
# Per-tool auto-approve check
if self.auto_approve_tools:
pending_names = {
it.get("approval_label", "") or it.get("func_name", "")
for it in pending
if it.get("func_name")
}
if pending_names and pending_names.issubset(self.auto_approve_tools):
return True, None
# Prompt
try:
if len(pending) == 1:
label = pending[0].get("approval_label", pending[0]["func_name"])
prompt_text = (
f" \001{BOLD}\002Allow {label}?\001{RESET}\002 "
f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 "
)
else:
labels = ", ".join(it.get("approval_label", it["func_name"]) for it in pending)
prompt_text = (
f" \001{BOLD}\002Allow {len(pending)} tools ({labels})?\001{RESET}\002 "
f"\001{DIM}\002[y/n/a(lways), optional message]\001{RESET}\002 "
)
resp = input(prompt_text).strip()
except (EOFError, KeyboardInterrupt):
sys.stdout.write("\n")
resp = "n"
# Parse decision and optional feedback: "y, use absolute path"
decision = resp.lower()
feedback = None
for sep in (",", " "):
if sep in resp:
decision = resp[: resp.index(sep)].strip().lower()
feedback = resp[resp.index(sep) + 1 :].strip() or None
break
if decision in ("a", "always"):
tool_names = {
it.get("approval_label", "") or it.get("func_name", "")
for it in pending
if it.get("func_name") and not it.get("error")
}
tool_names.discard("")
tool_names.discard("__budget_override__")
self.auto_approve_tools.update(tool_names)
return True, feedback
elif decision in ("y", "yes"):
return True, feedback
else:
denial_msg = "Denied by user"
if feedback:
denial_msg += f": {feedback}"
for item in pending:
item["denied"] = True
item["denial_msg"] = denial_msg
return False, None
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if is_error:
with self._print_lock:
sys.stderr.write(f"{RED}\u2717 {name}: {output}{RESET}\n")
sys.stderr.flush()
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
pass # Terminal shows spinner during tool execution
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
total_tok = usage["prompt_tokens"] + usage["completion_tokens"]
pct = total_tok / context_window * 100 if context_window > 0 else 0
parts = [f"{total_tok:,} / {context_window:,} tokens ({pct:.0f}%)"]
if effort != "medium":
parts.append(f"reasoning: {effort}")
sys.stdout.write(f"\n {DIM}[{' · '.join(parts)}]{RESET}\n")
sys.stdout.flush()
def on_plan_review(self, content: str) -> str:
sys.stdout.write(f"\n{DIM}{'─' * 60}{RESET}\n")
for line in content.splitlines():
sys.stdout.write(f" {line}\n")
sys.stdout.write(f"{DIM}{'─' * 60}{RESET}\n")
sys.stdout.flush()
try:
prompt_text = (
f" \001{BOLD}\002Plan ready.\001{RESET}\002 "
f"\001{DIM}\002[enter to approve, feedback to amend, "
f"ctrl-c to reject]\001{RESET}\002 "
)
resp = input(prompt_text).strip()
except EOFError:
resp = ""
except KeyboardInterrupt:
resp = "reject"
return resp
def on_info(self, message: str) -> None:
print(message)
def on_error(self, message: str) -> None:
sys.stdout.write(f"{RED}{message}{RESET}\n")
sys.stdout.flush()
def _print_reminder(self, reminders: list[dict[str, str]]) -> None:
"""Render a metacognitive reminder list as ``[metacognition · type] text``
lines in the terminal — the CLI's equivalent of the web UI's
yellow themed bubble. Used by both ``on_user_reminder`` and
``on_tool_reminder``; the rendering is identical because
terminal output is anchor-by-flow rather than DOM-by-anchor.
"""
for r in reminders:
nt = str(r.get("type", "") or "")
text = str(r.get("text", "") or "")
label = "metacognition" + (f" · {nt}" if nt else "")
sys.stdout.write(f"{YELLOW}[{label}]{RESET} {text}\n")
sys.stdout.flush()
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
self._print_reminder(reminders)
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
# tool_call_id ignored — the CLI anchors by output sequence
# (the line lands directly after the tool result that
# triggered the batch's reminder).
self._print_reminder(reminders)
def on_state_change(self, state: str) -> None:
pass # base TerminalUI ignores state changes
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
"""Display LLM judge verdict — called from daemon thread while approval is pending."""
risk = verdict.get("risk_level", "medium")
rec = verdict.get("recommendation", "review")
summary = verdict.get("intent_summary", "")
conf = int(verdict.get("confidence", 0.5) * 100)
tier = verdict.get("tier", "llm")
color = _VERDICT_COLORS.get(risk, "")
print(
f"\n {color}\u25b8 {tier.upper()} VERDICT: {risk.upper()} ({conf}%) \u2014 {rec}{RESET}"
)
if summary:
print(f" {summary}")
def on_output_warning(self, call_id: str, assessment: dict[str, Any]) -> None:
"""Display output guard warning when risk signals are detected."""
risk = assessment.get("risk_level", "none")
if risk == "none":
return
flags = assessment.get("flags", [])
color = _VERDICT_COLORS.get(risk, YELLOW)
sys.stdout.write(
f"\n {color}⚠ OUTPUT WARNING: {risk.upper()}{', '.join(flags)}{RESET}\n"
)
for ann in assessment.get("annotations", []):
sys.stdout.write(f" {ann}\n")
if assessment.get("redacted"):
sys.stdout.write(f" {DIM}(credentials redacted from output){RESET}\n")
sys.stdout.flush()
def on_rename(self, name: str) -> None:
pass # base TerminalUI ignores renames
# ─── WorkstreamTerminalUI ─────────────────────────────────────────────────
# State display config: (symbol, color_fn, label)
_STATE_DISPLAY: dict[WorkstreamState, tuple[str, Callable[[str], str], str]] = {
WorkstreamState.IDLE: ("·", dim, "idle"),
WorkstreamState.THINKING: ("◌", cyan, "thinking"),
WorkstreamState.RUNNING: ("▸", green, "running"),
WorkstreamState.ATTENTION: ("◆", yellow, "attention"),
WorkstreamState.ERROR: ("✖", red, "error"),
}
class WorkstreamTerminalUI(TerminalUI):
"""TerminalUI with workstream awareness: buffers output when in background,
blocks on approval until foregrounded."""
def __init__(self, ws_id: str, manager: SessionManager) -> None:
super().__init__()
self.ws_id = ws_id
self.manager = manager
self._output_buffer: list[tuple[str, str]] = [] # (event_type, text)
self._fg_event = threading.Event()
self._fg_event.set() # starts as foreground
@property
def is_foreground(self) -> bool:
return self.manager.active_id == self.ws_id
def set_foreground(self, fg: bool) -> None:
if fg:
self._fg_event.set()
else:
self._fg_event.clear()
def on_state_change(self, state: str) -> None:
try:
ws_state = WorkstreamState(state)
except ValueError:
return
self.manager.set_state(self.ws_id, ws_state)
# -- output buffering when in background --------------------------------
def on_thinking_start(self) -> None:
if self.is_foreground:
super().on_thinking_start()
def on_thinking_stop(self) -> None:
if self.is_foreground:
super().on_thinking_stop()
elif self.spinner:
self.spinner.stop()
self.spinner = None
def _buffer(self, event_type: str, text: str) -> None:
with self._print_lock:
self._output_buffer.append((event_type, text))
def on_reasoning_token(self, text: str) -> None:
if self.is_foreground:
super().on_reasoning_token(text)
else:
self._buffer("reasoning", text)
def on_content_token(self, text: str) -> None:
if self.is_foreground:
super().on_content_token(text)
else:
self._buffer("content", text)
def on_stream_end(self) -> None:
if self.is_foreground:
super().on_stream_end()
else:
self._buffer("stream_end", "")
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
if self.is_foreground:
super().on_status(usage, context_window, effort)
# silently drop status for background streams
def on_info(self, message: str) -> None:
if self.is_foreground:
super().on_info(message)
else:
self._buffer("info", message)
def on_error(self, message: str) -> None:
if self.is_foreground:
super().on_error(message)
else:
self._buffer("error", message)
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if self.is_foreground:
super().on_tool_result(call_id, name, output, is_error=is_error)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
if self.is_foreground:
super().on_tool_output_chunk(call_id, chunk)
def on_plan_review(self, content: str) -> str:
# Must wait until foregrounded to show plan review
if not self.is_foreground:
self._buffer(
"info",
f"{YELLOW}[Plan ready — switch to this workstream to review]{RESET}",
)
self._fg_event.wait()
return super().on_plan_review(content)
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
"""Block until foregrounded if in background, then show approval prompt."""
if not self.is_foreground:
tool_names = ", ".join(
it.get("approval_label", it.get("func_name", "?"))
for it in items
if it.get("needs_approval") and not it.get("error")
)
if tool_names:
self._buffer("info", f"{YELLOW}Waiting for approval: {tool_names}{RESET}")
self._fg_event.wait()
return super().approve_tools(items)
def flush_buffer(self) -> None:
"""Replay buffered output when switching to foreground."""
with self._print_lock:
if not self._output_buffer:
return
buf = list(self._output_buffer)
self._output_buffer.clear()
sys.stdout.write(f"\n {DIM}--- buffered output ({len(buf)} events) ---{RESET}\n")
replay_md = MarkdownRenderer()
for event_type, text in buf:
if event_type == "reasoning":
sys.stdout.write(f"{DIM}{text}{RESET}")
elif event_type == "content":
rendered = replay_md.feed(text)
if rendered:
sys.stdout.write(rendered)
elif event_type == "stream_end":
remainder = replay_md.flush()
if remainder:
sys.stdout.write(remainder)
replay_md.in_code_block = False
sys.stdout.write("\n")
elif event_type == "info":
sys.stdout.write(f"{text}\n")
elif event_type == "error":
sys.stdout.write(f"{RED}{text}{RESET}\n")
sys.stdout.write(f" {DIM}--- end buffered output ---{RESET}\n\n")
sys.stdout.flush()
# ─── Workstream commands ──────────────────────────────────────────────────
def _print_ws_status_line(manager: SessionManager) -> None:
"""Print a one-line status of background workstreams that are active."""
active_id = manager.active_id
parts = []
for ws in manager.list_all():
if ws.id == active_id:
continue
if ws.state in (WorkstreamState.IDLE,):
continue
sym, color_fn, label = _STATE_DISPLAY[ws.state]
idx = manager.index_of(ws.id)
parts.append(color_fn(f"{sym} {idx}:{ws.name} ({label})"))
if parts:
sys.stderr.write(f" {' '.join(parts)}\n")
sys.stderr.flush()
def _handle_ws_command(
manager: SessionManager,
cmd_line: str,
skip_permissions: bool,
) -> bool:
"""Handle /ws subcommands. Returns (switched: bool)."""
parts = cmd_line.strip().split()
sub = parts[1] if len(parts) > 1 else "list"
if sub == "list":
active_id = manager.active_id
all_ws = manager.list_all()
max_name = max((len(ws.name) for ws in all_ws), default=0)
for ws in all_ws:
idx = manager.index_of(ws.id)
sym, color_fn, label = _STATE_DISPLAY[ws.state]
marker = " *" if ws.id == active_id else " "
padded = ws.name.ljust(max_name)
print(f" {marker}{idx}. {color_fn(f'{sym} {padded}')} {dim(label)}")
return False
elif sub == "new":
name = parts[2] if len(parts) > 2 else ""
try:
ws = manager.create(user_id="", name=name)
except RuntimeError as e:
print(red(str(e)))
return False
if skip_permissions and isinstance(ws.ui, TerminalUI):
ws.ui.auto_approve = True
# Mark old active as background
old = manager.get_active()
if old and isinstance(old.ui, WorkstreamTerminalUI):
old.ui.set_foreground(False)
manager.switch(ws.id)
if isinstance(ws.ui, WorkstreamTerminalUI):
ws.ui.set_foreground(True)
print(f"Created workstream {cyan(ws.name)} (#{manager.index_of(ws.id)})")
return True
elif sub.isdigit():
idx = int(sub)
old = manager.get_active()
ws: Workstream | None = manager.switch_by_index(idx) # type: ignore[no-redef]
if ws:
if old and isinstance(old.ui, WorkstreamTerminalUI):
old.ui.set_foreground(False)
if isinstance(ws.ui, WorkstreamTerminalUI):
ws.ui.set_foreground(True)
ws.ui.flush_buffer()
print(f"Switched to {cyan(ws.name)}")
return True
else:
print(red(f"No workstream #{idx}"))
return False
elif sub == "close":
target_idx = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else None
ws_id: str | None = None
if target_idx is not None:
all_ws = manager.list_all()
if 1 <= target_idx <= len(all_ws):
ws_id = all_ws[target_idx - 1].id
else:
print(red(f"No workstream #{target_idx}"))
return False
else:
ws_id = manager.active_id
if ws_id is None:
return False
assert ws_id is not None
ws_obj = manager.get(ws_id)
ws_name = ws_obj.name if ws_obj else "?"
if manager.close(ws_id):
print(f"Closed workstream {ws_name}")
# Ensure new active is foregrounded if any remain.
new_active = manager.get_active()
if new_active and isinstance(new_active.ui, WorkstreamTerminalUI):
new_active.ui.set_foreground(True)
return True
# close() returned False — the ws was already closed or
# unknown. The old "last workstream" guard went away with the
# default-startup workstream.
print(red(f"Workstream {ws_name} not found or already closed"))
return False
elif sub == "rename":
new_name = " ".join(parts[2:]) if len(parts) > 2 else ""
if not new_name:
print(red("Usage: /ws rename <name>"))
return False
ws_active: Workstream | None = manager.get_active()
if ws_active:
old_name = ws_active.name
ws_active.name = new_name
print(f"Renamed {old_name} -> {cyan(new_name)}")
return False
else:
print(f"Unknown /ws subcommand: {sub}")
print("Usage: /ws [list|new [name]|<N>|close [N]|rename <name>]")
return False
# ─── Cluster commands ─────────────────────────────────────────────────────
def _handle_cluster_command(cmd_line: str, console_url: str | None) -> None:
"""Handle /cluster subcommands querying the turnstone-console API."""
import httpx
if not console_url:
print(red("No console URL configured. Use --console-url or set [console] url in config."))
return
headers: dict[str, str] = {}
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
_cluster_token_mgr = ServiceTokenManager(
user_id="cli",
scopes=frozenset({"read", "write", "approve", "service"}),
source="cli",
secret=jwt_secret,
audience=JWT_AUD_CONSOLE,
)
headers["Authorization"] = f"Bearer {_cluster_token_mgr.token}"
parts = cmd_line.strip().split()
sub = parts[1] if len(parts) > 1 else "status"
try:
if sub == "status":
resp = httpx.get(f"{console_url}/v1/api/cluster/overview", timeout=5, headers=headers)
data = resp.json()
states = data.get("states", {})
agg = data.get("aggregate", {})
print(f"\n {bold('Cluster Overview')}")
print(
f" Nodes: {cyan(str(data.get('nodes', 0)))} "
f"Workstreams: {cyan(str(data.get('workstreams', 0)))}"
)
print()
for state_name in ["running", "thinking", "attention", "idle", "error"]:
count = states.get(state_name, 0)
sym, color_fn, label = _STATE_DISPLAY.get(
{
"running": WorkstreamState.RUNNING,
"thinking": WorkstreamState.THINKING,
"attention": WorkstreamState.ATTENTION,
"idle": WorkstreamState.IDLE,
"error": WorkstreamState.ERROR,
}[state_name],
("?", dim, state_name),
)
print(f" {color_fn(f'{sym} {label}')}: {count}")
print()
tokens = agg.get("total_tokens", 0)
tools = agg.get("total_tool_calls", 0)
tok_str = f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens)
print(f" {dim(f'{tok_str} tokens · {tools} tool calls')}")
print()
elif sub == "nodes":
limit = 20
resp = httpx.get(
f"{console_url}/v1/api/cluster/nodes?sort=activity&limit={limit}",
timeout=5,
headers=headers,
)
data = resp.json()
nodes = data.get("nodes", [])
total = data.get("total", len(nodes))
if not nodes:
print(dim(" No nodes discovered."))
return
# Column widths
max_name = max(len(n["node_id"]) for n in nodes)
print(
f"\n {'NODE'.ljust(max_name)} {'WS':>4} {'RUN':>4} {'ATTN':>4} {'TOKENS':>8}"
)
print(f" {'-' * max_name} {'----':>4} {'----':>4} {'----':>4} {'--------':>8}")
for n in nodes:
name = n["node_id"].ljust(max_name)
ws = str(n.get("ws_total", 0))
run = n.get("ws_running", 0)
attn = n.get("ws_attention", 0)
tok = n.get("total_tokens", 0)
tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok)
run_str = green(str(run)) if run else dim("0")
attn_str = yellow(str(attn)) if attn else dim("0")
print(f" {cyan(name)} {ws:>4} {run_str:>4} {attn_str:>4} {dim(tok_str):>8}")
if total > len(nodes):
print(dim(f"\n Showing {len(nodes)} of {total} nodes"))
print()
elif sub == "workstreams" or sub == "ws":
params = "sort=state&per_page=20"
# Parse optional filters: /cluster ws running, /cluster ws node=X
for arg in parts[2:]:
if "=" in arg:
key, val = arg.split("=", 1)
if key in ("state", "node", "search"):
params += f"&{key}={val}"
else:
params += f"&state={arg}"
resp = httpx.get(
f"{console_url}/v1/api/cluster/workstreams?{params}",
timeout=5,
headers=headers,
)
data = resp.json()
ws_list = data.get("workstreams", [])
total = data.get("total", len(ws_list))
if not ws_list:
print(dim(" No matching workstreams."))
return
max_name = max(len(w.get("name", "")[:20]) for w in ws_list)
max_node = max(len(w.get("node", "")[:16]) for w in ws_list)
print(
f"\n {'STATE':<8} {'NAME'.ljust(max_name)} {'NODE'.ljust(max_node)} {'TOKENS':>8} {'CTX':>4}"
)
for w in ws_list:
state = w.get("state", "idle")
ws_state = {
"running": WorkstreamState.RUNNING,
"thinking": WorkstreamState.THINKING,
"attention": WorkstreamState.ATTENTION,
"idle": WorkstreamState.IDLE,
"error": WorkstreamState.ERROR,
}.get(state, WorkstreamState.IDLE)
sym, color_fn, label = _STATE_DISPLAY[ws_state]
name = w.get("name", "")[:20].ljust(max_name)
node = w.get("node", "")[:16].ljust(max_node)
tok = w.get("tokens", 0)
tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok)
ctx = w.get("context_ratio", 0)
ctx_str = f"{int(ctx * 100)}%" if ctx > 0 else ""
print(
f" {color_fn(f'{sym} {label:<5}')} {bold(name)} {dim(node)} {dim(tok_str):>8} {ctx_str:>4}"
)
if total > len(ws_list):
print(dim(f"\n Showing {len(ws_list)} of {total} workstreams"))
print()
elif sub == "node":
if len(parts) < 3:
print(red("Usage: /cluster node <node_id>"))
return
node_id = parts[2]
resp = httpx.get(
f"{console_url}/v1/api/cluster/node/{node_id}", timeout=5, headers=headers
)
data = resp.json()
if "error" in data:
print(red(data["error"]))
return
ws_list = data.get("workstreams", [])
print(f"\n {bold(node_id)} ({data.get('server_url', '')})")
if not ws_list:
print(dim(" No workstreams."))
return
for w in ws_list:
state = w.get("state", "idle")
ws_state = {
"running": WorkstreamState.RUNNING,
"thinking": WorkstreamState.THINKING,
"attention": WorkstreamState.ATTENTION,
"idle": WorkstreamState.IDLE,
"error": WorkstreamState.ERROR,
}.get(state, WorkstreamState.IDLE)
sym, color_fn, _ = _STATE_DISPLAY[ws_state]
name = w.get("name", "")
title = w.get("title", "")
activity = w.get("activity", "")
print(f" {color_fn(sym)} {bold(name)} {dim(title)}")
if activity:
print(f" {dim(activity)}")
print()
else:
print(f"Unknown /cluster subcommand: {sub}")
print("Usage: /cluster [status|nodes|workstreams [state|node=X]|node <id>]")
except httpx.ConnectError:
print(red(f"Cannot connect to console at {console_url}"))
except Exception as e:
print(red(f"Cluster command failed: {e}"))
# ─── Model auto-detection ─────────────────────────────────────────────────
def detect_model(client: Any, provider: str = "openai") -> tuple[str, int | None]:
"""Auto-detect model — delegates to :func:`turnstone.core.model_registry.detect_model`.
CLI always uses fatal=True, so model is never None.
"""
from turnstone.core.model_registry import detect_model as _detect
model, ctx = _detect(client, provider=provider)
assert model is not None # fatal=True guarantees non-None or SystemExit
return model, ctx
# ─── Main ──────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Interactive CLI for OpenAI-compatible models with tool calling.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
python3 chat.py # auto-detect model
python3 chat.py --model kappa_20b_131k # explicit model
python3 chat.py --temperature 0.7 # lower temperature
"""),
)
parser.add_argument(
"--base-url",
default="http://localhost:8000/v1",
help="OpenAI-compatible API base URL (default: http://localhost:8000/v1)",
)
parser.add_argument(
"--model",
default=None,
help="Model name (default: auto-detect from server)",
)
parser.add_argument(
"--instructions",
default=None,
help="Developer instructions injected as developer message",
)
parser.add_argument(
"--skill",
default=None,
help="Skill name (replaces default skills)",
)
parser.add_argument(
"--temperature",
type=float,
default=0.5,
help="Sampling temperature (default: 0.5)",
)
parser.add_argument(
"--max-tokens",
type=int,
default=32768,
help="Max completion tokens (default: 32768)",
)
parser.add_argument(
"--tool-timeout",
type=int,
default=30,
help="Bash command timeout in seconds (default: 30)",
)
parser.add_argument(
"--reasoning-effort",
default="medium",
choices=["none", "minimal", "low", "medium", "high", "xhigh", "max"],
help="Reasoning effort level (default: medium)",
)
parser.add_argument(
"--provider",
default="openai",
choices=["openai", "anthropic"],
help="LLM provider for the default model (default: openai)",
)
parser.add_argument(
"--context-window",
type=int,
default=0,
help="Context window size in tokens (0 = auto-detect from model)",
)
parser.add_argument(
"--compact-max-tokens",
type=int,
default=32768,
help="Max tokens for compaction summary (default: 32768)",
)
parser.add_argument(
"--auto-compact-pct",
type=float,
default=0.8,
help="Auto-compact when prompt exceeds this fraction of context window (default: 0.8)",
)
parser.add_argument(
"--agent-max-turns",
type=int,
default=-1,
help="Max tool turns for agent sub-sessions, -1 for unlimited (default: -1)",
)
parser.add_argument(
"--tool-truncation",
type=int,
default=0,
help="Tool output truncation limit in chars, 0 for auto (50%% of context window) (default: 0)",
)
parser.add_argument(
"--tool-search",
choices=["auto", "on", "off"],
default="auto",
help="Dynamic tool search: auto (enable when tool count exceeds threshold), on, off (default: auto)",
)
parser.add_argument(
"--tool-search-threshold",
type=int,
default=20,
help="Min tools before tool search activates (default: 20)",
)
parser.add_argument(
"--tool-search-max-results",
type=int,
default=5,
help="Max tools returned per tool search query (default: 5)",
)
parser.add_argument(
"--web-search-backend",
default="",
metavar="BACKEND",
help="Web search backend: '' (auto), 'tavily', 'ddg', or 'mcp:server:tool'",
)
parser.add_argument(
"--resume",
default=None,
metavar="WS",
help="Resume a previous workstream by alias or ws_id",
)
parser.add_argument(
"--skip-permissions",
action="store_true",
help="Auto-approve all tool calls (no confirmation prompts)",
)
parser.add_argument(
"--api-key",
default=None,
help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)",
)
parser.add_argument(
"--retention-days",
type=int,
default=90,
metavar="DAYS",
help="Delete unnamed workstreams older than DAYS days on startup, 0 to disable (default: 90)",
)
parser.add_argument(
"--console-url",
default=None,
help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)",
)
parser.add_argument(
"--mcp-config",
default=None,
metavar="PATH",
help="Path to MCP server config file (standard mcpServers JSON format)",
)
judge_group = parser.add_argument_group("Judge options")
judge_group.add_argument(
"--judge",
dest="judge_enabled",
action="store_true",
default=True,
help="Enable intent validation judge for tool approvals (default)",
)
judge_group.add_argument(
"--no-judge",
dest="judge_enabled",
action="store_false",
help="Disable intent validation judge",
)
judge_group.add_argument(
"--judge-model",
dest="judge_model",
default="",
help="Model for judge (default: same as session model)",
)
judge_group.add_argument(
"--judge-timeout",
dest="judge_timeout",
type=float,
default=60.0,
help="LLM judge timeout in seconds (default: 60)",
)
judge_group.add_argument(
"--judge-confidence",
dest="judge_confidence",
type=float,
default=0.7,
help="Confidence threshold for judge (default: 0.7)",
)
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
apply_config(
parser,
["api", "model", "session", "tools", "console", "auth", "mcp", "database", "judge"],
)
args = parser.parse_args()
from turnstone.core.log import configure_logging
configure_logging(level="WARNING", service="cli")
# Initialize storage backend
from turnstone.core.storage import init_storage
db_backend = getattr(args, "db_backend", None) or os.environ.get(
"TURNSTONE_DB_BACKEND", "sqlite"
)
db_url = getattr(args, "db_url", None) or os.environ.get("TURNSTONE_DB_URL", "")
db_path = getattr(args, "db_path", None) or os.environ.get("TURNSTONE_DB_PATH", "")
db_pool_size = int(
getattr(args, "db_pool_size", None) or os.environ.get("TURNSTONE_DB_POOL_SIZE", "2")
)
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
# Prune stale / empty workstreams on startup
from turnstone.core.memory import prune_workstreams
prune_workstreams(retention_days=args.retention_days, log_fn=print)
# Set up readline
setup_readline()
# Create client and detect model
provider_name = args.provider
api_key = (
args.api_key
or os.environ.get("ANTHROPIC_API_KEY" if provider_name == "anthropic" else "OPENAI_API_KEY")
or "dummy"
)
base_url = args.base_url
if provider_name == "anthropic" and base_url == "http://localhost:8000/v1":
# Override local default to Anthropic API
base_url = "https://api.anthropic.com"
from turnstone.core.providers import create_client
client = create_client(provider_name, base_url=base_url, api_key=api_key)
if args.model:
model = args.model
detected_ctx = None
else:
model, detected_ctx = detect_model(client, provider=provider_name)
# Use detected context window, fall back to CLI override or 32768
context_window = args.context_window
if detected_ctx and not context_window: # 0 = auto-detect
context_window = detected_ctx
elif not context_window:
context_window = 32768
# Build model registry (reads [models.*] sections from config.toml)
from turnstone.core.model_registry import load_model_registry
registry = load_model_registry(
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider_name,
)
# Initialize MCP client (connects to configured MCP servers, if any)
from turnstone.core.mcp_client import create_mcp_client
from turnstone.core.storage._registry import get_storage as _get_storage
mcp_client = create_mcp_client(
getattr(args, "mcp_config", None),
storage=_get_storage(),
)
# apply_config() merges [judge] config.toml values into args as
# Output_guard and redact_secrets default to True, enabling the heuristic
# guard even when the LLM judge is disabled via --no-judge.
judge_config = JudgeConfig(
enabled=args.judge_enabled,
model=args.judge_model,
confidence_threshold=args.judge_confidence,
timeout=args.judge_timeout,
)
# ChatSession factory — captures shared config for creating workstreams
def session_factory(
ui: SessionUI | None,
model_alias: str | None = None,
ws_id: str | None = None,
*,
skill: str | None = None,
client_type: str = "",
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
) -> ChatSession:
assert ui is not None, "session_factory requires a non-None UI"
r_client, r_model, r_cfg = registry.resolve(model_alias)
return ChatSession(
client=r_client,
model=r_model,
ui=ui,
instructions=args.instructions,
temperature=args.temperature,
max_tokens=args.max_tokens,
tool_timeout=args.tool_timeout,
reasoning_effort=args.reasoning_effort,
context_window=r_cfg.context_window,
compact_max_tokens=args.compact_max_tokens,
auto_compact_pct=args.auto_compact_pct,
agent_max_turns=args.agent_max_turns,
tool_truncation=args.tool_truncation,
mcp_client=mcp_client,
registry=registry,
model_alias=model_alias or registry.default,
tool_search=args.tool_search,
tool_search_threshold=args.tool_search_threshold,
tool_search_max_results=args.tool_search_max_results,
web_search_backend=args.web_search_backend,
skill=skill or args.skill or None,
judge_config=judge_config,
kind=kind,
parent_ws_id=parent_ws_id,
)
# Create session manager and initial workstream. The InteractiveAdapter
# ui_factory needs the manager to build its terminal UI, but the
# manager's ctor takes the adapter — break the cycle via
# ``InteractiveAdapter.attach`` (mirrors the coord-side pattern).
import queue as _queue_mod
cli_adapter = InteractiveAdapter(
# CLI doesn't consume SSE events; drain into a tiny queue and let
# emit_* drop silently on Full (the adapter already suppresses).
global_queue=_queue_mod.Queue(maxsize=1),
ui_factory=lambda ws: WorkstreamTerminalUI(ws.id, cli_adapter.manager),
session_factory=session_factory,
)
manager = SessionManager(
cli_adapter,
storage=_get_storage(),
max_active=50,
)
cli_adapter.attach(manager)
ws = manager.create(user_id="")
if args.skip_permissions and isinstance(ws.ui, TerminalUI):
ws.ui.auto_approve = True
# Handle --resume
if args.resume:
from turnstone.core.memory import resolve_workstream
target_id = resolve_workstream(args.resume)
if not target_id:
print(red(f"Workstream not found: {args.resume}"))
sys.exit(1)
if ws.session is None:
print(red("No session available."))
sys.exit(1)
if not ws.session.resume(target_id):
print(red(f"Workstream '{args.resume}' has no messages."))
sys.exit(1)
print(f"Resumed workstream {bold(target_id)} ({len(ws.session.messages)} messages)")
# Background attention notification — write to stderr while user types
def _bg_attention_notify(ws_id: str, state: WorkstreamState) -> None:
if state == WorkstreamState.ATTENTION and ws_id != manager.active_id:
bg_ws = manager.get(ws_id)
if bg_ws:
idx = manager.index_of(ws_id)
sys.stderr.write(
f"\a\r\033[s\033[1A\033[K"
f" {YELLOW}\u25c6 {idx}:{bg_ws.name} needs attention{RESET}"
f"\033[u"
)
sys.stderr.flush()
manager.subscribe_to_state(_bg_attention_notify)
# Print banner
print(f"\n{bold('Chat')} with {cyan(model)}")
if registry.count > 1:
others = [a for a in registry.list_aliases() if a != registry.default]
print(f"Models: {registry.default} (default), {', '.join(others)}")
if mcp_client:
mcp_tools = mcp_client.get_tools()
if mcp_tools:
print(f"MCP tools: {len(mcp_tools)} from {mcp_client.server_count} server(s)")
from turnstone.core.storage import get_storage as _cli_get_storage
mcp_client.set_storage(_cli_get_storage())
print("Type /help for commands, /ws for workstreams, /exit or Ctrl+D to quit.\n")
# Prompt string -- use a short display name
display_name = model.split("/")[-1] # strip path prefixes if any
if len(display_name) > 30:
display_name = display_name[:27] + "..."
# Main loop
while True:
try:
# Show background workstream status if any need attention
if manager.count > 1:
_print_ws_status_line(manager)
# Build prompt with workstream info
active = manager.get_active()
if manager.count > 1 and active is not None:
idx = manager.index_of(active.id)
prompt_str = f"\001{BOLD}\002{idx}:{active.name}\001{RESET}\002 > "
else:
prompt_str = f"\001{BOLD}\002[{display_name}]\001{RESET}\002 > "
user_input = input(prompt_str)
except (EOFError, KeyboardInterrupt):
print()
break
user_input = user_input.strip()
if not user_input:
continue
if user_input.startswith("/ws"):
_handle_ws_command(manager, user_input, args.skip_permissions)
continue
if user_input.startswith("/cluster"):
_handle_cluster_command(user_input, args.console_url)
continue
active = manager.get_active()
if active is None or active.session is None:
continue
if user_input.startswith("/"):
should_exit = active.session.handle_command(user_input)
if should_exit:
break
# Dispatch deferred retry (handle_command sets _pending_retry)
retry_msg = active.session._pending_retry
if retry_msg:
active.session._pending_retry = None
try:
active.session.send(retry_msg)
except KeyboardInterrupt:
print(f"\n{yellow('Interrupted.')}")
except Exception as e:
print(f"\n{red(f'Error: {e}')}")
else:
try:
active.session.send(user_input)
except KeyboardInterrupt:
print(f"\n{yellow('Interrupted.')}")
except Exception as e:
print(f"\n{red(f'Error: {e}')}")
# Close active session (removes MCP listener) before shutting down MCP
if active and active.session:
active.session.close()
if mcp_client:
mcp_client.shutdown()
registry.shutdown()
print("Goodbye.")
if __name__ == "__main__":
main()