mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
eb2a119da9
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.
1324 lines
48 KiB
Python
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()
|