add mcp and online search

This commit is contained in:
Abdelrahman Abdallah
2026-04-05 02:35:49 +02:00
parent 3f31cee395
commit 783145fe6a
38 changed files with 8114 additions and 261 deletions
+22 -1
View File
@@ -1,5 +1,6 @@
"""Python porting workspace for the Claude Code rewrite effort."""
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
from .agent_context import (
AgentContextSnapshot,
build_context_snapshot,
@@ -15,17 +16,20 @@ from .agent_tools import build_tool_context, default_tool_registry, execute_tool
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
from .background_runtime import BackgroundSessionRuntime
from .commands import PORTED_COMMANDS, build_command_backlog
from .mcp_runtime import MCPRuntime
from .config_runtime import ConfigMutation, ConfigRuntime
from .mcp_runtime import MCPRuntime, MCPResource, MCPServerProfile, MCPTool
from .parity_audit import ParityAuditResult, run_parity_audit
from .plan_runtime import PlanRuntime, PlanStep
from .plugin_runtime import PluginRuntime
from .port_manifest import PortManifest, build_port_manifest
from .query_engine import QueryEnginePort, TurnResult
from .runtime import PortRuntime, RuntimeSession
from .search_runtime import SearchProviderProfile, SearchResult, SearchRuntime, SearchStatusReport
from .session_store import StoredSession, load_session, save_session
from .system_init import build_system_init_message
from .task import PortingTask
from .task_runtime import TaskRuntime
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
from .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
@@ -34,11 +38,20 @@ __all__ = [
'AgentPermissions',
'AgentRunResult',
'AgentRuntimeConfig',
'AccountProfile',
'AccountRuntime',
'AccountSessionState',
'AccountStatusReport',
'AgentMessage',
'AgentSessionState',
'BackgroundSessionRuntime',
'ConfigMutation',
'ConfigRuntime',
'LocalCodingAgent',
'MCPResource',
'MCPRuntime',
'MCPServerProfile',
'MCPTool',
'ModelConfig',
'ParityAuditResult',
'PlanRuntime',
@@ -49,8 +62,13 @@ __all__ = [
'PortingTask',
'QueryEnginePort',
'RuntimeSession',
'SearchProviderProfile',
'SearchResult',
'SearchRuntime',
'SearchStatusReport',
'StoredSession',
'TaskRuntime',
'TokenCounterInfo',
'TurnResult',
'PORTED_COMMANDS',
'PORTED_TOOLS',
@@ -61,7 +79,10 @@ __all__ = [
'build_tool_backlog',
'build_tool_context',
'clear_context_caches',
'clear_token_counter_cache',
'count_tokens',
'default_tool_registry',
'describe_token_counter',
'execute_tool',
'get_system_context',
'get_user_context',
+470
View File
@@ -0,0 +1,470 @@
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_ACCOUNT_STATE_DIR = Path('.port_sessions')
DEFAULT_ACCOUNT_STATE_FILE = DEFAULT_ACCOUNT_STATE_DIR / 'account_runtime.json'
ACCOUNT_MANIFEST_PATHS = (
Path('.claw-account.json'),
Path('.claude/account.json'),
Path('.claude/auth.json'),
)
CREDENTIAL_ENV_VARS = (
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'OPENROUTER_API_KEY',
'LITELLM_MASTER_KEY',
)
@dataclass(frozen=True)
class AccountProfile:
name: str
provider: str
identity: str
source_manifest: str
description: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class AccountSessionState:
provider: str
identity: str
logged_in: bool
logged_in_at: str
profile_name: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
source_manifest: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class AccountStatusReport:
logged_in: bool
detail: str
provider: str | None = None
identity: str | None = None
profile_name: str | None = None
org: str | None = None
auth_mode: str | None = None
api_base: str | None = None
source_manifest: str | None = None
manifest_count: int = 0
profile_count: int = 0
credential_env_vars: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
def as_text(self) -> str:
lines = [
f'logged_in={self.logged_in}',
f'detail={self.detail}',
f'manifest_count={self.manifest_count}',
f'profile_count={self.profile_count}',
]
if self.provider:
lines.append(f'provider={self.provider}')
if self.identity:
lines.append(f'identity={self.identity}')
if self.profile_name:
lines.append(f'profile={self.profile_name}')
if self.org:
lines.append(f'org={self.org}')
if self.auth_mode:
lines.append(f'auth_mode={self.auth_mode}')
if self.api_base:
lines.append(f'api_base={self.api_base}')
if self.source_manifest:
lines.append(f'source_manifest={self.source_manifest}')
if self.credential_env_vars:
lines.append('credential_env=' + ','.join(self.credential_env_vars))
if self.metadata:
for key, value in sorted(self.metadata.items()):
lines.append(f'metadata.{key}={value}')
return '\n'.join(lines)
@dataclass
class AccountRuntime:
cwd: Path
profiles: tuple[AccountProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_ACCOUNT_STATE_FILE.resolve())
active_session: AccountSessionState | None = None
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
credential_env_vars: tuple[str, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'AccountRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
profiles: list[AccountProfile] = []
for manifest_path in manifest_paths:
profiles.extend(_load_profiles_from_manifest(manifest_path))
state_path = (cwd.resolve() / DEFAULT_ACCOUNT_STATE_FILE).resolve()
payload = _load_state_payload(state_path)
active_session = _session_from_payload(payload.get('active_session'))
history_payload = payload.get('history')
history = tuple(
item for item in history_payload if isinstance(item, dict)
) if isinstance(history_payload, list) else ()
return cls(
cwd=cwd.resolve(),
profiles=tuple(profiles),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_session=active_session,
history=history,
credential_env_vars=_detect_credential_env_vars(),
)
def has_account_state(self) -> bool:
return bool(self.profiles or self.active_session is not None or self.credential_env_vars)
def list_profiles(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[AccountProfile, ...]:
profiles = self.profiles
if query:
needle = query.lower()
profiles = tuple(
profile
for profile in profiles
if needle in profile.name.lower()
or needle in profile.provider.lower()
or needle in profile.identity.lower()
or needle in (profile.org or '').lower()
)
if limit is not None and limit >= 0:
profiles = profiles[:limit]
return profiles
def get_profile(self, name_or_identity: str) -> AccountProfile | None:
needle = name_or_identity.strip().lower()
if not needle:
return None
for profile in self.profiles:
if profile.name.lower() == needle or profile.identity.lower() == needle:
return profile
return None
def login(
self,
target: str,
*,
provider: str | None = None,
auth_mode: str | None = None,
) -> AccountStatusReport:
profile = self.get_profile(target)
if profile is not None:
session = AccountSessionState(
provider=profile.provider,
identity=profile.identity,
logged_in=True,
logged_in_at=_utc_now(),
profile_name=profile.name,
org=profile.org,
auth_mode=profile.auth_mode,
api_base=profile.api_base,
source_manifest=profile.source_manifest,
metadata=dict(profile.metadata),
)
detail = f'Activated account profile {profile.name}'
else:
session = AccountSessionState(
provider=(provider or 'custom').strip() or 'custom',
identity=target.strip(),
logged_in=True,
logged_in_at=_utc_now(),
auth_mode=(auth_mode or 'token').strip() or 'token',
metadata={'ephemeral': True},
)
detail = f'Activated ephemeral account identity {target.strip()}'
self.active_session = session
self._append_history(
{
'action': 'login',
'provider': session.provider,
'identity': session.identity,
'profile_name': session.profile_name,
'logged_in_at': session.logged_in_at,
}
)
self._persist_state()
return self.current_report(detail=detail)
def logout(self, *, reason: str = 'manual_logout') -> AccountStatusReport:
previous = self.active_session
detail = (
f'Logged out {previous.identity}'
if previous is not None
else 'No active account session was present.'
)
if previous is not None:
self._append_history(
{
'action': 'logout',
'provider': previous.provider,
'identity': previous.identity,
'profile_name': previous.profile_name,
'reason': reason,
'logged_out_at': _utc_now(),
}
)
self.active_session = None
self._persist_state()
return AccountStatusReport(
logged_in=False,
detail=detail,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
)
def current_report(self, *, detail: str | None = None) -> AccountStatusReport:
if self.active_session is None:
return AccountStatusReport(
logged_in=False,
detail=detail or 'No active account session.',
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
)
session = self.active_session
return AccountStatusReport(
logged_in=session.logged_in,
detail=detail or f'Active account session for {session.identity}',
provider=session.provider,
identity=session.identity,
profile_name=session.profile_name,
org=session.org,
auth_mode=session.auth_mode,
api_base=session.api_base,
source_manifest=session.source_manifest,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
credential_env_vars=self.credential_env_vars,
metadata=dict(session.metadata),
)
def render_summary(self) -> str:
lines = [
f'Local account manifests: {len(self.manifests)}',
f'Configured account profiles: {len(self.profiles)}',
]
if self.credential_env_vars:
lines.append('- Credential env vars: ' + ', '.join(self.credential_env_vars))
for profile in self.profiles[:5]:
details = [profile.name, profile.provider, profile.identity]
if profile.org:
details.append(f'org={profile.org}')
if profile.auth_mode:
details.append(f'auth_mode={profile.auth_mode}')
lines.append('- Profile: ' + ' ; '.join(details))
if self.active_session is None:
lines.append('- Active account session: none')
else:
session = self.active_session
lines.append(
f'- Active account session: {session.provider} / {session.identity}'
)
if session.profile_name:
lines.append(f' - profile: {session.profile_name}')
if session.auth_mode:
lines.append(f' - auth_mode: {session.auth_mode}')
if session.org:
lines.append(f' - org: {session.org}')
if session.api_base:
lines.append(f' - api_base: {session.api_base}')
return '\n'.join(lines)
def render_profile(self, name_or_identity: str) -> str:
profile = self.get_profile(name_or_identity)
if profile is None:
return f'# Account Profile\n\nUnknown account profile: {name_or_identity}'
lines = [
'# Account Profile',
'',
f'- Name: {profile.name}',
f'- Provider: {profile.provider}',
f'- Identity: {profile.identity}',
f'- Source manifest: {profile.source_manifest}',
]
if profile.org:
lines.append(f'- Org: {profile.org}')
if profile.auth_mode:
lines.append(f'- Auth mode: {profile.auth_mode}')
if profile.api_base:
lines.append(f'- API base: {profile.api_base}')
if profile.description:
lines.extend(['', profile.description])
return '\n'.join(lines)
def render_profiles_index(self, *, query: str | None = None) -> str:
profiles = self.list_profiles(query=query, limit=100)
lines = ['# Account Profiles', '']
if not profiles:
lines.append('No local account profiles discovered.')
return '\n'.join(lines)
for profile in profiles:
details = [profile.name, profile.provider, profile.identity]
if profile.org:
details.append(f'org={profile.org}')
if profile.auth_mode:
details.append(f'auth_mode={profile.auth_mode}')
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def _append_history(self, entry: dict[str, Any]) -> None:
self.history = (*self.history, entry)
def _persist_state(self) -> None:
payload = {
'active_session': (
asdict(self.active_session)
if self.active_session is not None
else None
),
'history': list(self.history[-100:]),
}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
encoding='utf-8',
)
def _discover_manifest_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidate_roots = [cwd.resolve()]
for raw_path in additional_working_directories:
path = Path(raw_path).resolve()
if path not in candidate_roots:
candidate_roots.append(path)
discovered: list[Path] = []
seen: set[Path] = set()
for root in candidate_roots:
for relative_path in ACCOUNT_MANIFEST_PATHS:
path = (root / relative_path).resolve()
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append(path)
return tuple(discovered)
def _load_profiles_from_manifest(path: Path) -> list[AccountProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if isinstance(payload, dict):
profiles_payload = payload.get('profiles')
if isinstance(profiles_payload, list):
return [
profile
for item in profiles_payload
for profile in [_profile_from_payload(item, path)]
if profile is not None
]
single = _profile_from_payload(payload, path)
return [single] if single is not None else []
return []
def _profile_from_payload(payload: Any, path: Path) -> AccountProfile | None:
if not isinstance(payload, dict):
return None
raw_name = payload.get('name') or payload.get('profile')
provider = payload.get('provider')
identity = payload.get('identity') or payload.get('email') or payload.get('user')
if not isinstance(provider, str) or not provider.strip():
return None
if not isinstance(identity, str) or not identity.strip():
return None
if not isinstance(raw_name, str) or not raw_name.strip():
raw_name = identity
metadata = payload.get('metadata')
return AccountProfile(
name=str(raw_name).strip(),
provider=provider.strip(),
identity=identity.strip(),
source_manifest=str(path),
description=_optional_str(payload.get('description')),
org=_optional_str(payload.get('org')),
auth_mode=_optional_str(payload.get('authMode') or payload.get('auth_mode')),
api_base=_optional_str(payload.get('apiBase') or payload.get('api_base')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _load_state_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _session_from_payload(payload: Any) -> AccountSessionState | None:
if not isinstance(payload, dict):
return None
provider = payload.get('provider')
identity = payload.get('identity')
if not isinstance(provider, str) or not provider.strip():
return None
if not isinstance(identity, str) or not identity.strip():
return None
metadata = payload.get('metadata')
return AccountSessionState(
provider=provider.strip(),
identity=identity.strip(),
logged_in=bool(payload.get('logged_in', True)),
logged_in_at=str(payload.get('logged_in_at', _utc_now())),
profile_name=_optional_str(payload.get('profile_name')),
org=_optional_str(payload.get('org')),
auth_mode=_optional_str(payload.get('auth_mode')),
api_base=_optional_str(payload.get('api_base')),
source_manifest=_optional_str(payload.get('source_manifest')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _detect_credential_env_vars() -> tuple[str, ...]:
return tuple(
key
for key in CREDENTIAL_ENV_VARS
if isinstance(os.environ.get(key), str) and os.environ.get(key, '').strip()
)
def _optional_str(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+16
View File
@@ -9,10 +9,14 @@ from functools import lru_cache
from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary
from .account_runtime import AccountRuntime
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .agent_types import AgentRuntimeConfig
@@ -224,6 +228,18 @@ def _get_user_context_cached(
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
if mcp_runtime.resources:
context['mcpRuntime'] = mcp_runtime.render_summary()
remote_runtime = RemoteRuntime.from_workspace(Path(cwd), additional_working_directories)
if remote_runtime.has_remote_config():
context['remoteRuntime'] = remote_runtime.render_summary()
search_runtime = SearchRuntime.from_workspace(Path(cwd), additional_working_directories)
if search_runtime.has_search_runtime():
context['searchRuntime'] = search_runtime.render_summary()
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
if account_runtime.has_account_state():
context['accountRuntime'] = account_runtime.render_summary()
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
if config_runtime.has_config():
context['configRuntime'] = config_runtime.render_summary()
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
if plan_runtime.steps:
context['planRuntime'] = plan_runtime.render_summary()
+22 -15
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from .agent_prompting import SYSTEM_PROMPT_DYNAMIC_BOUNDARY
from .agent_session import AgentMessage, AgentSessionState
from .tokenizer_runtime import describe_token_counter, count_tokens
_PATH_HEADER_RE = re.compile(r'^## ((?:/|[A-Za-z]:[\\/]).+)$', re.MULTILINE)
@@ -48,12 +48,13 @@ class ContextUsageReport:
system_context_entries: tuple[UsageEntry, ...]
memory_files: tuple[UsageEntry, ...]
message_breakdown: MessageBreakdown
token_counter_backend: str
token_counter_source: str
token_counter_accurate: bool
def estimate_tokens(text: str) -> int:
if not text:
return 0
return max(1, math.ceil(len(text) / 4))
def estimate_tokens(text: str, model: str | None = None) -> int:
return count_tokens(text, model)
def infer_context_window(model: str) -> int:
@@ -78,21 +79,23 @@ def collect_context_usage(
strategy: str,
) -> ContextUsageReport:
raw_max_tokens = infer_context_window(model)
token_counter = describe_token_counter(model)
count = lambda text: estimate_tokens(text, model) # noqa: E731
system_prompt_sections = tuple(
UsageEntry(name=_section_name(part, idx), tokens=estimate_tokens(part))
UsageEntry(name=_section_name(part, idx), tokens=count(part))
for idx, part in enumerate(session.system_prompt_parts, start=1)
)
system_context_entries = tuple(
UsageEntry(name=key, tokens=estimate_tokens(f'{key}: {value}'))
UsageEntry(name=key, tokens=count(f'{key}: {value}'))
for key, value in session.system_context.items()
if value
)
user_context_entries = tuple(
UsageEntry(name=key, tokens=estimate_tokens(_render_user_context_chunk(key, value)))
UsageEntry(name=key, tokens=count(_render_user_context_chunk(key, value)))
for key, value in session.user_context.items()
if value
)
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd')))
memory_files = tuple(_parse_memory_usage(session.user_context.get('claudeMd'), model=model))
user_context_tokens = sum(entry.tokens for entry in user_context_entries)
system_prompt_tokens = (
@@ -112,20 +115,20 @@ def collect_context_usage(
if _is_user_context_message(session, index, message):
continue
if message.role == 'user':
conversation_user_tokens += estimate_tokens(message.content)
conversation_user_tokens += count(message.content)
continue
if message.role == 'assistant':
assistant_tokens += estimate_tokens(message.content)
assistant_tokens += count(message.content)
for tool_call in message.tool_calls:
serialized = json.dumps(tool_call, ensure_ascii=True)
tokens = estimate_tokens(serialized)
tokens = count(serialized)
tool_call_tokens += tokens
tool_name = _extract_tool_call_name(tool_call)
call_totals = tool_usage.setdefault(tool_name, [0, 0])
call_totals[0] += tokens
continue
if message.role == 'tool':
tokens = estimate_tokens(message.content)
tokens = count(message.content)
tool_result_tokens += tokens
result_totals = tool_usage.setdefault(message.name or 'tool', [0, 0])
result_totals[1] += tokens
@@ -176,6 +179,9 @@ def collect_context_usage(
user_context_tokens=user_context_tokens,
tool_calls_by_type=tool_calls_by_type,
),
token_counter_backend=token_counter.backend,
token_counter_source=token_counter.source,
token_counter_accurate=token_counter.accurate,
)
@@ -185,6 +191,7 @@ def format_context_usage(report: ContextUsageReport) -> str:
'',
f'**Model:** {report.model} ',
f'**Estimated tokens:** {_format_tokens(report.total_tokens)} / {_format_tokens(report.raw_max_tokens)} ({report.percentage:.1f}%) ',
f'**Token counter:** {report.token_counter_backend} ({report.token_counter_source}){" [accurate]" if report.token_counter_accurate else " [fallback]"} ',
f'**Context strategy:** {report.strategy} ',
f'**Messages in session:** {report.message_count}',
'',
@@ -330,7 +337,7 @@ def _is_user_context_message(
)
def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
def _parse_memory_usage(claude_md: str | None, *, model: str | None = None) -> list[UsageEntry]:
if not claude_md:
return []
matches = list(_PATH_HEADER_RE.finditer(claude_md))
@@ -341,7 +348,7 @@ def _parse_memory_usage(claude_md: str | None) -> list[UsageEntry]:
start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(claude_md)
content = claude_md[start:end].strip()
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content)))
entries.append(UsageEntry(name=match.group(1), tokens=estimate_tokens(content, model)))
return entries
+56 -2
View File
@@ -92,6 +92,10 @@ def build_system_prompt_parts(
get_using_your_tools_section(enabled_tool_names),
get_plugin_guidance_section(prompt_context),
get_mcp_guidance_section(prompt_context),
get_remote_guidance_section(prompt_context),
get_search_guidance_section(prompt_context),
get_account_guidance_section(prompt_context),
get_config_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context),
@@ -229,13 +233,62 @@ def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
if not mcp_runtime:
return ''
items = [
'Local MCP manifests may expose additional resources through the runtime.',
'Local MCP manifests may expose additional resources and transport-backed tools through the runtime.',
'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.',
'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.',
'Use MCP transport tools when a configured MCP server exposes real callable tools that should stay outside the local Python tool registry.',
'Treat MCP resource and tool summaries as discoverability hints and prefer reading a specific resource URI or calling a specific MCP tool before relying on its contents.',
]
return '\n'.join(['# MCP', *prepend_bullets(items)])
def get_remote_guidance_section(prompt_context: PromptContext) -> str:
remote_runtime = prompt_context.user_context.get('remoteRuntime')
if not remote_runtime:
return ''
items = [
'Local remote manifests or an active remote connection may be available in the workspace context.',
'Use remote status or remote-connect flows before assuming a specific remote target is active.',
'Treat remote summaries as runtime state for the current workspace, including active target, session URL, and remote workspace path when present.',
]
return '\n'.join(['# Remote', *prepend_bullets(items)])
def get_search_guidance_section(prompt_context: PromptContext) -> str:
search_runtime = prompt_context.user_context.get('searchRuntime')
if not search_runtime:
return ''
items = [
'Local workspace web-search providers may be available through the runtime.',
'Use the web_search tool when the task requires discovering external pages rather than fetching a known URL directly.',
'Use web_fetch after web_search when you need to inspect the contents of a selected result page.',
]
return '\n'.join(['# Search', *prepend_bullets(items)])
def get_account_guidance_section(prompt_context: PromptContext) -> str:
account_runtime = prompt_context.user_context.get('accountRuntime')
if not account_runtime:
return ''
items = [
'Local workspace account or auth state may be available through the runtime.',
'Use account tools and account slash commands when the task depends on local login state, configured profiles, or auth metadata.',
'Treat local account summaries as workspace runtime state, including active identity, configured profiles, and visible credential env vars.',
]
return '\n'.join(['# Account', *prepend_bullets(items)])
def get_config_guidance_section(prompt_context: PromptContext) -> str:
config_runtime = prompt_context.user_context.get('configRuntime')
if not config_runtime:
return ''
items = [
'Local workspace config and settings files may be available through the runtime.',
'Use config tools instead of ad-hoc file edits when the task is specifically about settings or config state.',
'Treat the effective config as merged workspace state, and inspect the specific source when override order matters.',
]
return '\n'.join(['# Config', *prepend_bullets(items)])
def get_task_guidance_section(prompt_context: PromptContext) -> str:
task_runtime = prompt_context.user_context.get('taskRuntime')
if not task_runtime:
@@ -244,6 +297,7 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
'A local runtime task list may be available to track ongoing work.',
'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.',
'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.',
'Use task_next and the richer task state tools when dependencies or blocked work matter.',
]
return '\n'.join(['# Tasks', *prepend_bullets(items)])
+299 -7
View File
@@ -7,9 +7,12 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from .account_runtime import AccountRuntime
from .agent_manager import AgentManager
from .agent_context import clear_context_caches
from .agent_context import render_context_report as render_agent_context_report
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .agent_prompting import (
@@ -42,7 +45,10 @@ from .agent_types import (
from .openai_compat import OpenAICompatClient, OpenAICompatError
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .tokenizer_runtime import describe_token_counter
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -75,6 +81,10 @@ class LocalCodingAgent:
plugin_runtime: PluginRuntime | None = None
hook_policy_runtime: HookPolicyRuntime | None = None
mcp_runtime: MCPRuntime | None = None
remote_runtime: RemoteRuntime | None = None
search_runtime: SearchRuntime | None = None
account_runtime: AccountRuntime | None = None
config_runtime: ConfigRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
@@ -104,6 +114,23 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.remote_runtime is None:
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.search_runtime is None:
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.account_runtime is None:
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.config_runtime is None:
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
@@ -120,12 +147,17 @@ class LocalCodingAgent:
self.client = OpenAICompatClient(self.model_config)
self.tool_context = build_tool_context(
self.runtime_config,
tool_registry=self.tool_registry,
extra_env=(
self.hook_policy_runtime.safe_env()
if self.hook_policy_runtime is not None
else None
),
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
mcp_runtime=self.mcp_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
@@ -919,6 +951,7 @@ class LocalCodingAgent:
'preflight_count': len(plugin_preflight_messages),
}
)
self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result)
history_entry = self._build_file_history_entry(
tool_call=tool_call,
tool_result=tool_result,
@@ -1270,9 +1303,9 @@ class LocalCodingAgent:
if current_total <= target_tokens and not reactive:
break
message = session.messages[index]
original_tokens = estimate_tokens(message.content)
original_tokens = estimate_tokens(message.content, self.model_config.model)
replacement = self._build_snipped_message_content(message)
replacement_tokens = estimate_tokens(replacement)
replacement_tokens = estimate_tokens(replacement, self.model_config.model)
if replacement_tokens >= original_tokens:
continue
session.tombstone_message(
@@ -2832,28 +2865,194 @@ class LocalCodingAgent:
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
return '\n'.join(['# Memory', '', claude_md])
def render_account_report(self, profile: str | None = None) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
if profile:
return self.account_runtime.render_profile(profile)
return '\n'.join(['# Account', '', self.account_runtime.render_summary()])
def render_search_report(
self,
query: str | None = None,
*,
provider: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return (
'# Search\n\nNo local search provider is available. '
'Add a .claw-search.json or .claude/search.json manifest, '
'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.'
)
if query:
try:
return self.search_runtime.render_search_results(
query,
provider_name=provider,
max_results=max_results,
domains=domains,
timeout_seconds=self.runtime_config.command_timeout_seconds,
)
except (KeyError, LookupError, OSError, ValueError) as exc:
return f'# Search\n\nSearch failed: {exc}'
if provider:
return self.search_runtime.render_provider(provider)
return '\n'.join(['# Search', '', self.search_runtime.render_summary()])
def render_search_providers_report(self, query: str | None = None) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search Providers\n\nNo local search providers discovered.'
return self.search_runtime.render_providers_index(query=query)
def render_search_activate_report(self, provider: str) -> str:
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
return '# Search\n\nNo local search provider is available.'
try:
report = self.search_runtime.activate_provider(provider)
except KeyError:
return f'# Search\n\nUnknown search provider: {provider}'
clear_context_caches()
self.tool_context = replace(
self.tool_context,
search_runtime=self.search_runtime,
)
return '\n'.join(['# Search', '', report.as_text()])
def render_account_profiles_report(self, query: str | None = None) -> str:
if self.account_runtime is None:
return '# Account Profiles\n\nNo local account runtime is available.'
return self.account_runtime.render_profiles_index(query=query)
def render_account_login_report(
self,
target: str,
*,
provider: str | None = None,
auth_mode: str | None = None,
) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode)
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_account_logout_report(self) -> str:
if self.account_runtime is None:
return '# Account\n\nNo local account runtime is available.'
report = self.account_runtime.logout(reason='slash_or_cli_logout')
clear_context_caches()
return '\n'.join(['# Account', '', report.as_text()])
def render_config_report(self) -> str:
if self.config_runtime is None:
return '# Config\n\nNo local config runtime is available.'
return '\n'.join(['# Config', '', self.config_runtime.render_summary()])
def render_config_effective_report(self) -> str:
if self.config_runtime is None:
return '# Config Effective\n\nNo local config runtime is available.'
return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()])
def render_config_source_report(self, source: str) -> str:
if self.config_runtime is None:
return '# Config Source\n\nNo local config runtime is available.'
return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)])
def render_config_value_report(self, key_path: str, source: str | None = None) -> str:
if self.config_runtime is None:
return '# Config Value\n\nNo local config runtime is available.'
try:
rendered = self.config_runtime.render_value(key_path, source=source)
except KeyError as exc:
label = source if source is not None else key_path
return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}'
return '\n'.join(['# Config Value', '', rendered])
def render_mcp_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP\n\nNo local MCP manifests or resources discovered.'
return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.'
if query:
return self.mcp_runtime.render_resource_index(query=query)
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
def render_remote_report(self, target: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
if target:
report = self.remote_runtime.connect(target)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()])
def render_remote_mode_report(self, target: str, *, mode: str) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.connect(target, mode=mode)
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_remote_profiles_report(self, query: str | None = None) -> str:
if self.remote_runtime is None:
return '# Remote Profiles\n\nNo local remote runtime is available.'
return self.remote_runtime.render_profiles_index(query=query)
def render_remote_disconnect_report(self) -> str:
if self.remote_runtime is None:
return '# Remote\n\nNo local remote runtime is available.'
report = self.remote_runtime.disconnect()
clear_context_caches()
return '\n'.join(['# Remote', '', report.as_text()])
def render_mcp_resources_report(self, query: str | None = None) -> str:
if self.mcp_runtime is None:
return '# MCP Resources\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource_index(query=query)
def render_mcp_resource_report(self, uri: str) -> str:
if self.mcp_runtime is None:
return '# MCP Resource\n\nNo local MCP manifests or resources discovered.'
return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_resource(uri)
def render_mcp_tools_report(
self,
query: str | None = None,
*,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.'
return self.mcp_runtime.render_tool_index(query=query, server_name=server)
def render_mcp_call_tool_report(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server: str | None = None,
) -> str:
if self.mcp_runtime is None:
return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.'
try:
return self.mcp_runtime.render_tool_call(
tool_name,
arguments=arguments,
server_name=server,
)
except FileNotFoundError as exc:
return f'# MCP Tool Result\n\n{exc}'
def render_tasks_report(self, status: str | None = None) -> str:
if self.task_runtime is None:
return '# Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_tasks(status=status)
def render_next_tasks_report(self) -> str:
if self.task_runtime is None:
return '# Next Tasks\n\nNo local task runtime is available.'
return self.task_runtime.render_next_tasks()
def render_plan_report(self) -> str:
if self.plan_runtime is None:
return '# Plan\n\nNo local plan runtime is available.'
@@ -2891,10 +3090,12 @@ class LocalCodingAgent:
return '\n'.join(lines)
def render_status_report(self) -> str:
token_counter = describe_token_counter(self.model_config.model)
lines = [
'# Status',
'',
f'- Model: {self.model_config.model}',
f'- Token counter: {token_counter.backend} ({token_counter.source})',
f'- Registered tools: {len(self.tool_registry)}',
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
f'- Session ID: {self.active_session_id or "none"}',
@@ -2904,8 +3105,37 @@ class LocalCodingAgent:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
if self.mcp_runtime is not None and self.mcp_runtime.resources:
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime is not None:
if self.mcp_runtime.resources:
lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}')
if self.mcp_runtime.servers:
lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}')
if self.remote_runtime is not None and self.remote_runtime.has_remote_config():
lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}')
if self.remote_runtime.active_connection is not None:
connection = self.remote_runtime.active_connection
lines.append(
f'- Active remote: {connection.mode} -> {connection.target}'
)
if self.search_runtime is not None and self.search_runtime.has_search_runtime():
lines.append(f'- Search providers: {len(self.search_runtime.providers)}')
active_provider = self.search_runtime.current_provider()
if active_provider is not None:
lines.append(
f'- Active search provider: {active_provider.name} ({active_provider.provider})'
)
if self.account_runtime is not None and self.account_runtime.has_account_state():
lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}')
if self.account_runtime.active_session is not None:
session = self.account_runtime.active_session
lines.append(
f'- Active account: {session.provider} -> {session.identity}'
)
if self.config_runtime is not None and self.config_runtime.has_config():
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
lines.append(
f'- Effective config keys: {len(self.config_runtime.list_keys())}'
)
if self.plan_runtime is not None and self.plan_runtime.steps:
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
if self.task_runtime is not None and self.task_runtime.tasks:
@@ -2945,6 +3175,68 @@ class LocalCodingAgent:
)
self.resume_source_session_id = None
def _refresh_runtime_views_for_tool_result(
self,
tool_name: str,
tool_result: ToolExecutionResult,
) -> None:
if not tool_result.ok:
return
refresh_tool_names = {
'update_plan',
'plan_clear',
'task_create',
'task_update',
'task_start',
'task_complete',
'task_block',
'task_cancel',
'todo_write',
'search_activate_provider',
'remote_connect',
'remote_disconnect',
'account_login',
'account_logout',
'config_set',
}
if tool_name not in refresh_tool_names:
return
clear_context_caches()
additional_dirs = tuple(
str(path) for path in self.runtime_config.additional_working_directories
)
if tool_name.startswith('remote_'):
self.remote_runtime = RemoteRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('search_'):
self.search_runtime = SearchRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name.startswith('account_'):
self.account_runtime = AccountRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name == 'config_set':
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('task_') or tool_name == 'todo_write':
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('plan_') or tool_name == 'update_plan':
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
self.tool_context = replace(
self.tool_context,
tool_registry=self.tool_registry,
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
config_runtime=self.config_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
if self.plugin_runtime is None:
return prompt
+193 -1
View File
@@ -114,6 +114,66 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show discovered local MCP manifests and resource counts.',
handler=_handle_mcp,
),
SlashCommandSpec(
names=('search',),
description='Show search runtime status, list or activate providers, or run a real web search query.',
handler=_handle_search,
),
SlashCommandSpec(
names=('remote',),
description='Show local remote runtime status or activate a remote target/profile.',
handler=_handle_remote,
),
SlashCommandSpec(
names=('account',),
description='Show local account runtime status or configured account profiles.',
handler=_handle_account,
),
SlashCommandSpec(
names=('login',),
description='Activate a local account profile or ephemeral identity.',
handler=_handle_login,
),
SlashCommandSpec(
names=('logout',),
description='Clear the active local account session.',
handler=_handle_logout,
),
SlashCommandSpec(
names=('config', 'settings'),
description='Show local config runtime state, effective config, config sources, or a config value.',
handler=_handle_config,
),
SlashCommandSpec(
names=('remotes',),
description='List configured local remote profiles.',
handler=_handle_remotes,
),
SlashCommandSpec(
names=('ssh',),
description='Activate a local SSH remote target/profile.',
handler=_handle_ssh,
),
SlashCommandSpec(
names=('teleport',),
description='Activate a local teleport remote target/profile.',
handler=_handle_teleport,
),
SlashCommandSpec(
names=('direct-connect',),
description='Activate a local direct-connect remote target/profile.',
handler=_handle_direct_connect,
),
SlashCommandSpec(
names=('deep-link',),
description='Activate a local deep-link remote target/profile.',
handler=_handle_deep_link,
),
SlashCommandSpec(
names=('disconnect', 'remote-disconnect'),
description='Disconnect the active local remote runtime target.',
handler=_handle_remote_disconnect,
),
SlashCommandSpec(
names=('resources',),
description='List local MCP resources, optionally filtered by a query string.',
@@ -129,6 +189,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks,
),
SlashCommandSpec(
names=('task-next', 'next-task'),
description='Show the next actionable tasks from the local runtime task list.',
handler=_handle_task_next,
),
SlashCommandSpec(
names=('plan', 'planner'),
description='Show the current local runtime plan.',
@@ -221,8 +286,131 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_mcp_report())
if command == 'tools':
return _local_result(input_text, agent.render_mcp_tools_report())
if command.startswith('tools '):
query = command.split(' ', 1)[1].strip()
return _local_result(input_text, agent.render_mcp_tools_report(query or None))
if command.startswith('tool '):
tool_name = command.split(' ', 1)[1].strip()
if not tool_name:
return _local_result(input_text, 'Usage: /mcp tool <tool-name>')
return _local_result(input_text, agent.render_mcp_call_tool_report(tool_name))
return _local_result(input_text, agent.render_mcp_report(command))
def _handle_search(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_search_report())
if command == 'providers':
return _local_result(input_text, agent.render_search_providers_report())
if command.startswith('providers '):
query = command.split(' ', 1)[1].strip()
return _local_result(input_text, agent.render_search_providers_report(query or None))
if command.startswith('provider '):
provider = command.split(' ', 1)[1].strip()
if not provider:
return _local_result(input_text, 'Usage: /search provider <name>')
return _local_result(input_text, agent.render_search_report(provider=provider))
if command.startswith('use '):
provider = command.split(' ', 1)[1].strip()
if not provider:
return _local_result(input_text, 'Usage: /search use <name>')
return _local_result(input_text, agent.render_search_activate_report(provider))
return _local_result(input_text, agent.render_search_report(command))
def _handle_remote(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args or None
return _local_result(input_text, agent.render_remote_report(target))
def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_account_report())
if command == 'profiles':
return _local_result(input_text, agent.render_account_profiles_report())
if command.startswith('profile '):
profile = command.split(' ', 1)[1].strip()
if not profile:
return _local_result(input_text, 'Usage: /account profile <name>')
return _local_result(input_text, agent.render_account_report(profile))
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args.strip()
if not target:
return _local_result(input_text, 'Usage: /login <profile-or-identity>')
return _local_result(input_text, agent.render_account_login_report(target))
def _handle_logout(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_account_logout_report())
def _handle_config(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_config_report())
if command == 'effective':
return _local_result(input_text, agent.render_config_effective_report())
if command.startswith('source '):
source = command.split(' ', 1)[1].strip()
if not source:
return _local_result(input_text, 'Usage: /config source <source-name>')
return _local_result(input_text, agent.render_config_source_report(source))
if command.startswith('get '):
key_path = command.split(' ', 1)[1].strip()
if not key_path:
return _local_result(input_text, 'Usage: /config get <key-path>')
return _local_result(input_text, agent.render_config_value_report(key_path))
return _local_result(
input_text,
'Usage: /config [effective|source <name>|get <key-path>]',
)
def _handle_remotes(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_report(query))
return _local_result(input_text, agent.render_remote_profiles_report(query))
def _handle_ssh(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /ssh <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='ssh'))
def _handle_teleport(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /teleport <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='teleport'))
def _handle_direct_connect(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /direct-connect <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='direct-connect'))
def _handle_deep_link(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /deep-link <target-or-profile>')
return _local_result(input_text, agent.render_remote_mode_report(args, mode='deep-link'))
def _handle_remote_disconnect(
agent: 'LocalCodingAgent',
_args: str,
input_text: str,
) -> SlashCommandResult:
return _local_result(input_text, agent.render_remote_disconnect_report())
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
@@ -241,6 +429,10 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas
return _local_result(input_text, agent.render_tasks_report(status))
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_next_tasks_report())
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_plan_report())
+1015 -5
View File
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DEFAULT_CONFIG_DIR = Path('.claude')
PROJECT_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.json'
LOCAL_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.local.json'
LEGACY_CONFIG_PATHS = (
Path('.claw-config.json'),
Path('.codex-config.json'),
)
@dataclass(frozen=True)
class ConfigSource:
name: str
path: str
settings: dict[str, Any]
@dataclass(frozen=True)
class ConfigMutation:
source_name: str
key_path: str
store_path: str
before_sha256: str | None
after_sha256: str
before_preview: str | None
after_preview: str
effective_key_count: int
@dataclass
class ConfigRuntime:
cwd: Path
sources: tuple[ConfigSource, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(cls, cwd: Path) -> 'ConfigRuntime':
root = cwd.resolve()
sources: list[ConfigSource] = []
for source_name, path in _discover_source_paths(root):
payload = _load_json_object(path)
if payload is None:
continue
sources.append(
ConfigSource(
name=source_name,
path=str(path),
settings=payload,
)
)
return cls(cwd=root, sources=tuple(sources))
def has_config(self) -> bool:
return bool(self.sources)
def effective_settings(self) -> dict[str, Any]:
merged: dict[str, Any] = {}
for source in self.sources:
merged = _deep_merge(merged, source.settings)
return merged
def list_keys(
self,
*,
source: str | None = None,
prefix: str | None = None,
limit: int | None = None,
) -> tuple[str, ...]:
payload = self._payload_for_source(source)
flattened = sorted(_flatten_keys(payload))
if prefix:
flattened = [key for key in flattened if key.startswith(prefix)]
if limit is not None and limit >= 0:
flattened = flattened[:limit]
return tuple(flattened)
def get_value(
self,
key_path: str,
*,
source: str | None = None,
) -> Any:
payload = self._payload_for_source(source)
return _get_nested_value(payload, key_path)
def set_value(
self,
key_path: str,
value: Any,
*,
source: str = 'local',
) -> ConfigMutation:
resolved_source, path = self._resolve_writable_source(source)
before_payload = _load_json_object(path) or {}
before_text = path.read_text(encoding='utf-8') if path.exists() else None
updated_payload = json.loads(json.dumps(before_payload))
_set_nested_value(updated_payload, key_path, value)
after_text = json.dumps(updated_payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(after_text, encoding='utf-8')
refreshed = ConfigRuntime.from_workspace(self.cwd)
self.sources = refreshed.sources
return ConfigMutation(
source_name=resolved_source,
key_path=key_path,
store_path=str(path),
before_sha256=_sha256_or_none(before_text),
after_sha256=_sha256(after_text),
before_preview=_preview(before_text),
after_preview=_preview(after_text),
effective_key_count=len(_flatten_keys(self.effective_settings())),
)
def render_summary(self) -> str:
lines = [
f'Config sources: {len(self.sources)}',
f'Effective keys: {len(_flatten_keys(self.effective_settings()))}',
]
if not self.sources:
lines.append('- No local config files discovered.')
lines.append(f'- Project settings path: {(self.cwd / PROJECT_SETTINGS_PATH).resolve()}')
lines.append(f'- Local settings path: {(self.cwd / LOCAL_SETTINGS_PATH).resolve()}')
return '\n'.join(lines)
for source in self.sources:
lines.append(
f'- {source.name}: {source.path} ({len(_flatten_keys(source.settings))} key(s))'
)
return '\n'.join(lines)
def render_keys(
self,
*,
source: str | None = None,
prefix: str | None = None,
limit: int | None = None,
) -> str:
keys = self.list_keys(source=source, prefix=prefix, limit=limit)
if not keys:
return '(no config keys)'
return '\n'.join(keys)
def render_value(
self,
key_path: str,
*,
source: str | None = None,
) -> str:
return json.dumps(
self.get_value(key_path, source=source),
ensure_ascii=True,
indent=2,
sort_keys=True,
)
def render_effective_config(self) -> str:
return json.dumps(self.effective_settings(), ensure_ascii=True, indent=2, sort_keys=True)
def render_source(self, source: str) -> str:
resolved = self._find_source(source)
if resolved is None:
return f'# Config\n\nUnknown config source: {source}'
return json.dumps(resolved.settings, ensure_ascii=True, indent=2, sort_keys=True)
def _payload_for_source(self, source: str | None) -> dict[str, Any]:
if source is None:
return self.effective_settings()
resolved = self._find_source(source)
if resolved is None:
raise KeyError(source)
return resolved.settings
def _find_source(self, source: str) -> ConfigSource | None:
alias = _normalize_source_name(source)
for config_source in self.sources:
if _normalize_source_name(config_source.name) == alias:
return config_source
return None
def _resolve_writable_source(self, source: str) -> tuple[str, Path]:
alias = _normalize_source_name(source)
if alias in {'project', 'project-settings', 'settings'}:
return 'project', (self.cwd / PROJECT_SETTINGS_PATH).resolve()
if alias in {'local', 'local-settings'}:
return 'local', (self.cwd / LOCAL_SETTINGS_PATH).resolve()
if alias in {'legacy', 'legacy-project'}:
return 'legacy', (self.cwd / LEGACY_CONFIG_PATHS[0]).resolve()
raise KeyError(source)
def _discover_source_paths(cwd: Path) -> tuple[tuple[str, Path], ...]:
candidates = [
('legacy-claw', (cwd / LEGACY_CONFIG_PATHS[0]).resolve()),
('legacy-codex', (cwd / LEGACY_CONFIG_PATHS[1]).resolve()),
('project', (cwd / PROJECT_SETTINGS_PATH).resolve()),
('local', (cwd / LOCAL_SETTINGS_PATH).resolve()),
]
discovered: list[tuple[str, Path]] = []
seen: set[Path] = set()
for source_name, path in candidates:
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append((source_name, path))
return tuple(discovered)
def _load_json_object(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
return dict(payload) if isinstance(payload, dict) else None
def _flatten_keys(payload: dict[str, Any], *, prefix: str = '') -> tuple[str, ...]:
keys: list[str] = []
for key, value in payload.items():
if not isinstance(key, str):
continue
combined = f'{prefix}.{key}' if prefix else key
keys.append(combined)
if isinstance(value, dict):
keys.extend(_flatten_keys(value, prefix=combined))
return tuple(keys)
def _get_nested_value(payload: dict[str, Any], key_path: str) -> Any:
current: Any = payload
for segment in _split_key_path(key_path):
if not isinstance(current, dict) or segment not in current:
raise KeyError(key_path)
current = current[segment]
return current
def _set_nested_value(payload: dict[str, Any], key_path: str, value: Any) -> None:
current: dict[str, Any] = payload
segments = _split_key_path(key_path)
for segment in segments[:-1]:
child = current.get(segment)
if not isinstance(child, dict):
child = {}
current[segment] = child
current = child
current[segments[-1]] = value
def _split_key_path(key_path: str) -> tuple[str, ...]:
segments = tuple(
segment.strip()
for segment in key_path.split('.')
if segment.strip()
)
if not segments:
raise KeyError(key_path)
return segments
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _normalize_source_name(source: str) -> str:
return source.strip().lower().replace('_', '-')
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def _sha256_or_none(text: str | None) -> str | None:
if text is None:
return None
return _sha256(text)
def _preview(text: str | None, limit: int = 220) -> str | None:
if text is None:
return None
stripped = text.strip()
if len(stripped) <= limit:
return stripped
return stripped[:limit] + '...'
+344 -53
View File
@@ -9,6 +9,7 @@ import json
from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .account_runtime import AccountRuntime
from .agent_runtime import LocalCodingAgent
from .agent_types import (
AgentPermissions,
@@ -21,12 +22,21 @@ from .agent_types import (
from .bootstrap_graph import build_bootstrap_graph
from .command_graph import build_command_graph
from .commands import execute_command, get_command, get_commands, render_command_index
from .direct_modes import run_deep_link, run_direct_connect
from .config_runtime import ConfigRuntime
from .mcp_runtime import MCPRuntime
from .parity_audit import run_parity_audit
from .permissions import ToolPermissionContext
from .port_manifest import build_port_manifest
from .query_engine import QueryEnginePort
from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode
from .remote_runtime import (
RemoteRuntime,
run_deep_link_mode,
run_direct_connect_mode,
run_remote_mode,
run_ssh_mode,
run_teleport_mode,
)
from .search_runtime import SearchRuntime
from .runtime import PortRuntime
from .session_store import (
StoredAgentSession,
@@ -248,6 +258,58 @@ def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('--scratchpad-root')
def _launch_background_agent(args: argparse.Namespace) -> int:
background_runtime = BackgroundSessionRuntime()
background_id = background_runtime.create_id()
forwarded_args: list[str] = []
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
forwarded_args.extend(['--background-root', str(background_runtime.root)])
command = build_background_worker_command(
background_id=background_id,
prompt=args.prompt,
forwarded_args=forwarded_args,
)
record = background_runtime.launch(
command,
prompt=args.prompt,
workspace_cwd=Path(args.cwd).resolve(),
model=args.model,
background_id=background_id,
process_cwd=Path(__file__).resolve().parent.parent,
)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'pid={record.pid}')
print(f'log_path={record.log_path}')
print(f'record_path={record.record_path}')
return 0
def _run_background_worker(args: argparse.Namespace) -> int:
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
exit_code = 1
stop_reason = 'worker_failed'
session_id = None
session_path = None
try:
agent = _build_agent(args)
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
exit_code = 0
stop_reason = result.stop_reason or 'completed'
session_id = result.session_id
session_path = result.session_path
return 0
finally:
background_runtime.mark_finished(
args.background_id,
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
def _build_resumed_agent(args: argparse.Namespace) -> tuple[LocalCodingAgent, StoredAgentSession]:
stored_session = load_agent_session(args.session_id)
model_config = deserialize_model_config(stored_session.model_config)
@@ -519,14 +581,86 @@ def build_parser() -> argparse.ArgumentParser:
remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching')
remote_parser.add_argument('target')
remote_parser.add_argument('--cwd', default='.')
ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching')
ssh_parser.add_argument('target')
ssh_parser.add_argument('--cwd', default='.')
teleport_parser = subparsers.add_parser('teleport-mode', help='simulate teleport runtime branching')
teleport_parser.add_argument('target')
teleport_parser.add_argument('--cwd', default='.')
direct_parser = subparsers.add_parser('direct-connect-mode', help='simulate direct-connect runtime branching')
direct_parser.add_argument('target')
direct_parser.add_argument('--cwd', default='.')
deep_link_parser = subparsers.add_parser('deep-link-mode', help='simulate deep-link runtime branching')
deep_link_parser.add_argument('target')
deep_link_parser.add_argument('--cwd', default='.')
remote_status_parser = subparsers.add_parser('remote-status', help='show local remote runtime status')
remote_status_parser.add_argument('--cwd', default='.')
remote_profiles_parser = subparsers.add_parser('remote-profiles', help='list configured local remote profiles')
remote_profiles_parser.add_argument('--cwd', default='.')
remote_profiles_parser.add_argument('--query')
remote_disconnect_parser = subparsers.add_parser('remote-disconnect', help='disconnect the active local remote target')
remote_disconnect_parser.add_argument('--cwd', default='.')
account_status_parser = subparsers.add_parser('account-status', help='show local account runtime status')
account_status_parser.add_argument('--cwd', default='.')
account_profiles_parser = subparsers.add_parser('account-profiles', help='list configured local account profiles')
account_profiles_parser.add_argument('--cwd', default='.')
account_profiles_parser.add_argument('--query')
account_login_parser = subparsers.add_parser('account-login', help='activate a local account profile or ephemeral identity')
account_login_parser.add_argument('target')
account_login_parser.add_argument('--provider')
account_login_parser.add_argument('--auth-mode')
account_login_parser.add_argument('--cwd', default='.')
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
account_logout_parser.add_argument('--cwd', default='.')
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
search_status_parser.add_argument('--cwd', default='.')
search_status_parser.add_argument('--provider')
search_providers_parser = subparsers.add_parser('search-providers', help='list configured local search providers')
search_providers_parser.add_argument('--cwd', default='.')
search_providers_parser.add_argument('--query')
search_activate_parser = subparsers.add_parser('search-activate', help='set the active local search provider')
search_activate_parser.add_argument('provider')
search_activate_parser.add_argument('--cwd', default='.')
search_parser = subparsers.add_parser('search', help='run a real web search against the configured local search runtime')
search_parser.add_argument('query')
search_parser.add_argument('--cwd', default='.')
search_parser.add_argument('--provider')
search_parser.add_argument('--max-results', type=int, default=5)
search_parser.add_argument('--domain', action='append', default=[])
mcp_status_parser = subparsers.add_parser('mcp-status', help='show local MCP runtime status')
mcp_status_parser.add_argument('--cwd', default='.')
mcp_resources_parser = subparsers.add_parser('mcp-resources', help='list MCP resources discovered through local manifests and transport-backed servers')
mcp_resources_parser.add_argument('--cwd', default='.')
mcp_resources_parser.add_argument('--query')
mcp_resource_parser = subparsers.add_parser('mcp-resource', help='read an MCP resource by URI')
mcp_resource_parser.add_argument('uri')
mcp_resource_parser.add_argument('--cwd', default='.')
mcp_tools_parser = subparsers.add_parser('mcp-tools', help='list MCP tools exposed by configured MCP servers')
mcp_tools_parser.add_argument('--cwd', default='.')
mcp_tools_parser.add_argument('--query')
mcp_tools_parser.add_argument('--server')
mcp_call_tool_parser = subparsers.add_parser('mcp-call-tool', help='call an MCP tool exposed by a configured MCP server')
mcp_call_tool_parser.add_argument('tool_name')
mcp_call_tool_parser.add_argument('--arguments-json', default='{}')
mcp_call_tool_parser.add_argument('--server')
mcp_call_tool_parser.add_argument('--cwd', default='.')
config_status_parser = subparsers.add_parser('config-status', help='show local workspace config runtime summary')
config_status_parser.add_argument('--cwd', default='.')
config_effective_parser = subparsers.add_parser('config-effective', help='render the merged effective local workspace config')
config_effective_parser.add_argument('--cwd', default='.')
config_source_parser = subparsers.add_parser('config-source', help='render a specific local config source')
config_source_parser.add_argument('source')
config_source_parser.add_argument('--cwd', default='.')
config_get_parser = subparsers.add_parser('config-get', help='read a local config value by dotted key path')
config_get_parser.add_argument('key_path')
config_get_parser.add_argument('--source')
config_get_parser.add_argument('--cwd', default='.')
config_set_parser = subparsers.add_parser('config-set', help='write a local config value by dotted key path')
config_set_parser.add_argument('key_path')
config_set_parser.add_argument('value_json')
config_set_parser.add_argument('--source', default='local')
config_set_parser.add_argument('--cwd', default='.')
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
show_command.add_argument('name')
@@ -575,6 +709,38 @@ def build_parser() -> argparse.ArgumentParser:
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
kill_parser.add_argument('background_id')
daemon_parser = subparsers.add_parser('daemon', help='manage local daemon-style background agent sessions')
daemon_subparsers = daemon_parser.add_subparsers(dest='daemon_command')
daemon_subparsers.required = True
daemon_start_parser = daemon_subparsers.add_parser('start', help='launch a local daemon-style background agent session')
daemon_start_parser.add_argument('prompt')
daemon_start_parser.add_argument('--max-turns', type=int, default=12)
daemon_start_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(daemon_start_parser, include_backend=True)
daemon_worker_parser = daemon_subparsers.add_parser('worker', help=argparse.SUPPRESS)
daemon_worker_parser.add_argument('background_id')
daemon_worker_parser.add_argument('prompt')
daemon_worker_parser.add_argument('--background-root', required=True)
daemon_worker_parser.add_argument('--max-turns', type=int, default=12)
daemon_worker_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(daemon_worker_parser, include_backend=True)
daemon_ps_parser = daemon_subparsers.add_parser('ps', help='list local daemon-style background sessions')
daemon_ps_parser.add_argument('--tail', type=int, default=None)
daemon_logs_parser = daemon_subparsers.add_parser('logs', help='show logs for a local daemon-style background session')
daemon_logs_parser.add_argument('background_id')
daemon_logs_parser.add_argument('--tail', type=int, default=None)
daemon_attach_parser = daemon_subparsers.add_parser('attach', help='show the current output snapshot for a local daemon-style background session')
daemon_attach_parser.add_argument('background_id')
daemon_attach_parser.add_argument('--tail', type=int, default=None)
daemon_kill_parser = daemon_subparsers.add_parser('kill', help='stop a local daemon-style background session')
daemon_kill_parser.add_argument('background_id')
chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
chat_parser.add_argument('prompt', nargs='?')
chat_parser.add_argument('--resume-session-id')
@@ -687,19 +853,155 @@ def main(argv: list[str] | None = None) -> int:
print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}')
return 0
if args.command == 'remote-mode':
print(run_remote_mode(args.target).as_text())
print(run_remote_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'ssh-mode':
print(run_ssh_mode(args.target).as_text())
print(run_ssh_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'teleport-mode':
print(run_teleport_mode(args.target).as_text())
print(run_teleport_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'direct-connect-mode':
print(run_direct_connect(args.target).as_text())
print(run_direct_connect_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'deep-link-mode':
print(run_deep_link(args.target).as_text())
print(run_deep_link_mode(args.target, cwd=Path(args.cwd).resolve()).as_text())
return 0
if args.command == 'remote-status':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print('# Remote')
print()
print(runtime.render_summary())
return 0
if args.command == 'remote-profiles':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_profiles_index(query=args.query))
return 0
if args.command == 'remote-disconnect':
runtime = RemoteRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.disconnect().as_text())
return 0
if args.command == 'account-status':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print('# Account')
print()
print(runtime.render_summary())
return 0
if args.command == 'account-profiles':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_profiles_index(query=args.query))
return 0
if args.command == 'account-login':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(
runtime.login(
args.target,
provider=args.provider,
auth_mode=args.auth_mode,
).as_text()
)
return 0
if args.command == 'account-logout':
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.logout().as_text())
return 0
if args.command == 'search-status':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
if args.provider:
print(runtime.render_provider(args.provider))
else:
print('# Search')
print()
print(runtime.render_summary())
return 0
if args.command == 'search-providers':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_providers_index(query=args.query))
return 0
if args.command == 'search-activate':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
try:
report = runtime.activate_provider(args.provider)
except KeyError:
print(f'Unknown search provider: {args.provider}')
return 1
print(report.as_text())
return 0
if args.command == 'search':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
try:
output = runtime.render_search_results(
args.query,
provider_name=args.provider,
max_results=args.max_results,
domains=tuple(args.domain),
)
except (KeyError, LookupError, OSError, ValueError) as exc:
print(f'Search failed: {exc}')
return 1
print(output)
return 0
if args.command == 'mcp-status':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print('# MCP')
print()
print(runtime.render_summary())
return 0
if args.command == 'mcp-resources':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_resource_index(query=args.query))
return 0
if args.command == 'mcp-resource':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_resource(args.uri))
return 0
if args.command == 'mcp-tools':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_tool_index(query=args.query, server_name=args.server))
return 0
if args.command == 'mcp-call-tool':
runtime = MCPRuntime.from_workspace(Path(args.cwd).resolve())
arguments = json.loads(args.arguments_json)
if not isinstance(arguments, dict):
print('arguments-json must decode to a JSON object')
return 1
print(
runtime.render_tool_call(
args.tool_name,
arguments=arguments,
server_name=args.server,
)
)
return 0
if args.command == 'config-status':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print('# Config')
print()
print(runtime.render_summary())
return 0
if args.command == 'config-effective':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_effective_config())
return 0
if args.command == 'config-source':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_source(args.source))
return 0
if args.command == 'config-get':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_value(args.key_path, source=args.source))
return 0
if args.command == 'config-set':
runtime = ConfigRuntime.from_workspace(Path(args.cwd).resolve())
value = json.loads(args.value_json)
mutation = runtime.set_value(args.key_path, value, source=args.source)
print('# Config')
print()
print(f'source={mutation.source_name}')
print(f'key_path={mutation.key_path}')
print(f'store_path={mutation.store_path}')
print(f'effective_key_count={mutation.effective_key_count}')
print(runtime.render_value(args.key_path))
return 0
if args.command == 'show-command':
module = get_command(args.name)
@@ -729,53 +1031,9 @@ def main(argv: list[str] | None = None) -> int:
_print_agent_result(result, show_transcript=args.show_transcript)
return 0
if args.command == 'agent-bg':
background_runtime = BackgroundSessionRuntime()
background_id = background_runtime.create_id()
forwarded_args: list[str] = []
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
forwarded_args.extend(['--background-root', str(background_runtime.root)])
command = build_background_worker_command(
background_id=background_id,
prompt=args.prompt,
forwarded_args=forwarded_args,
)
record = background_runtime.launch(
command,
prompt=args.prompt,
workspace_cwd=Path(args.cwd).resolve(),
model=args.model,
background_id=background_id,
process_cwd=Path(__file__).resolve().parent.parent,
)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'pid={record.pid}')
print(f'log_path={record.log_path}')
print(f'record_path={record.record_path}')
return 0
return _launch_background_agent(args)
if args.command == 'agent-bg-worker':
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
exit_code = 1
stop_reason = 'worker_failed'
session_id = None
session_path = None
try:
agent = _build_agent(args)
result = agent.run(args.prompt)
_print_agent_result(result, show_transcript=args.show_transcript)
exit_code = 0
stop_reason = result.stop_reason or 'completed'
session_id = result.session_id
session_path = result.session_path
return 0
finally:
background_runtime.mark_finished(
args.background_id,
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
return _run_background_worker(args)
if args.command == 'agent-ps':
print(BackgroundSessionRuntime().render_ps())
return 0
@@ -804,6 +1062,39 @@ def main(argv: list[str] | None = None) -> int:
if record.exit_code is not None:
print(f'exit_code={record.exit_code}')
return 0
if args.command == 'daemon':
if args.daemon_command == 'start':
return _launch_background_agent(args)
if args.daemon_command == 'worker':
return _run_background_worker(args)
if args.daemon_command == 'ps':
print(BackgroundSessionRuntime().render_ps())
return 0
if args.daemon_command == 'logs':
print(
BackgroundSessionRuntime().render_logs(
args.background_id,
tail=args.tail,
)
)
return 0
if args.daemon_command == 'attach':
print(
BackgroundSessionRuntime().render_attach(
args.background_id,
tail=args.tail,
)
)
return 0
if args.daemon_command == 'kill':
record = BackgroundSessionRuntime().kill(args.background_id)
print('# Background Session')
print(f'background_id={record.background_id}')
print(f'status={record.status}')
print(f'pid={record.pid}')
if record.exit_code is not None:
print(f'exit_code={record.exit_code}')
return 0
if args.command == 'agent-chat':
agent = _build_agent(args)
return _run_agent_chat_loop(
+655 -50
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import json
import os
import selectors
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
MCP_PROTOCOL_VERSION = '2025-11-25'
@dataclass(frozen=True)
class MCPResource:
uri: str
@@ -19,9 +26,33 @@ class MCPResource:
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class MCPTool:
name: str
server_name: str
source_manifest: str
description: str | None = None
input_schema: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class MCPServerProfile:
name: str
source_manifest: str
transport: str
command: str | None = None
args: tuple[str, ...] = ()
env: dict[str, str] = field(default_factory=dict)
cwd: str | None = None
description: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class MCPRuntime:
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
servers: tuple[MCPServerProfile, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
@@ -30,77 +61,165 @@ class MCPRuntime:
additional_working_directories: tuple[str, ...] = (),
) -> 'MCPRuntime':
resources: list[MCPResource] = []
servers: list[MCPServerProfile] = []
for path in _discover_manifest_paths(cwd, additional_working_directories):
resources.extend(_load_resources_from_manifest(path))
return cls(resources=tuple(resources))
manifest_resources, manifest_servers = _load_manifest(path)
resources.extend(manifest_resources)
servers.extend(manifest_servers)
return cls(
resources=tuple(resources),
servers=tuple(_dedupe_servers(servers)),
)
@property
def manifests(self) -> tuple[str, ...]:
seen: list[str] = []
for resource in self.resources:
if resource.source_manifest not in seen:
seen.append(resource.source_manifest)
for entry in [*self.resources, *self.servers]:
source_manifest = entry.source_manifest
if source_manifest not in seen:
seen.append(source_manifest)
return tuple(seen)
def has_transport_servers(self) -> bool:
return any(server.transport == 'stdio' for server in self.servers)
def list_resources(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[MCPResource, ...]:
resources = self.resources
if query:
needle = query.lower()
resources = tuple(
resource
for resource in resources
if needle in resource.uri.lower()
or needle in resource.server_name.lower()
or needle in (resource.name or '').lower()
or needle in (resource.description or '').lower()
)
resources = list(self.resources)
resources.extend(self._list_remote_resources())
filtered = _filter_resources(tuple(resources), query=query)
if limit is not None and limit >= 0:
resources = resources[:limit]
return resources
filtered = filtered[:limit]
return filtered
def get_resource(self, uri: str) -> MCPResource | None:
for resource in self.resources:
if resource.uri == uri:
return resource
for resource in self._list_remote_resources():
if resource.uri == uri:
return resource
return None
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
resource = self.get_resource(uri)
if resource is None:
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
if resource.inline_text is not None:
return _truncate(resource.inline_text, max_chars)
if resource.resolved_path is None:
raise FileNotFoundError(f'MCP resource has no readable content: {uri}')
path = Path(resource.resolved_path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f'MCP resource file not found: {path}')
text = path.read_text(encoding='utf-8', errors='replace')
return _truncate(text, max_chars)
for resource in self.resources:
if resource.uri != uri:
continue
if resource.inline_text is not None:
return _truncate(resource.inline_text, max_chars)
if resource.resolved_path is not None:
path = Path(resource.resolved_path)
if not path.exists() or not path.is_file():
raise FileNotFoundError(f'MCP resource file not found: {path}')
text = path.read_text(encoding='utf-8', errors='replace')
return _truncate(text, max_chars)
last_error: Exception | None = None
candidate_servers: list[MCPServerProfile] = []
discovered = self.get_resource(uri)
if discovered is not None:
server = self.get_server(discovered.server_name)
if server is not None:
candidate_servers.append(server)
for server in self.servers:
if server.transport != 'stdio':
continue
if all(existing.name != server.name for existing in candidate_servers):
candidate_servers.append(server)
for server in candidate_servers:
try:
result = _request_stdio(server, 'resources/read', {'uri': uri})
except Exception as exc:
last_error = exc
continue
rendered = _render_resource_contents(result.get('contents'))
if rendered:
return _truncate(rendered, max_chars)
if last_error is not None:
raise FileNotFoundError(f'Unable to read MCP resource {uri}: {last_error}') from last_error
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
def list_tools(
self,
*,
query: str | None = None,
server_name: str | None = None,
limit: int | None = None,
) -> tuple[MCPTool, ...]:
tools = self._list_remote_tools(server_name=server_name)
if query:
needle = query.lower()
tools = tuple(
tool
for tool in tools
if needle in tool.name.lower()
or needle in (tool.description or '').lower()
or needle in tool.server_name.lower()
)
if limit is not None and limit >= 0:
tools = tools[:limit]
return tools
def call_tool(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server_name: str | None = None,
max_chars: int = 12000,
) -> tuple[str, dict[str, Any]]:
tool = self._resolve_tool(tool_name, server_name=server_name)
server = self.get_server(tool.server_name)
if server is None:
raise FileNotFoundError(f'Unknown MCP server: {tool.server_name}')
payload = {
'name': tool.name,
'arguments': dict(arguments or {}),
}
result = _request_stdio(server, 'tools/call', payload)
rendered = _truncate(_render_tool_call_result(result), max_chars)
metadata = {
'server_name': tool.server_name,
'tool_name': tool.name,
'is_error': bool(result.get('isError')),
}
return rendered, metadata
def get_server(self, name: str) -> MCPServerProfile | None:
needle = name.strip().lower()
if not needle:
return None
for server in self.servers:
if server.name.lower() == needle:
return server
return None
def render_summary(self) -> str:
if not self.resources:
return 'No local MCP manifests or resources discovered.'
if not self.resources and not self.servers:
return 'No local MCP manifests, servers, or resources discovered.'
lines = [
f'Local MCP manifests: {len(self.manifests)}',
f'Local MCP resources: {len(self.resources)}',
f'Configured MCP servers: {len(self.servers)}',
]
transport_counts: dict[str, int] = {}
for server in self.servers:
transport_counts[server.transport] = transport_counts.get(server.transport, 0) + 1
for transport, count in sorted(transport_counts.items()):
lines.append(f'- {transport}: {count} server(s)')
by_server: dict[str, int] = {}
for resource in self.resources:
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
for server_name, count in sorted(by_server.items()):
lines.append(f'- {server_name}: {count} resource(s)')
for manifest in self.manifests[:10]:
manifest_name = Path(manifest).name
manifest_count = sum(
1 for resource in self.resources if resource.source_manifest == manifest
)
lines.append(f'- {manifest_name}: {manifest_count} resource(s)')
lines.append(f'- local resources for {server_name}: {count}')
for server in self.servers[:10]:
details = [server.name, server.transport]
if server.command:
details.append(server.command)
lines.append('- Server: ' + ' ; '.join(details))
return '\n'.join(lines)
def render_resource_index(
@@ -122,6 +241,10 @@ class MCPRuntime:
details.append(f'mime={resource.mime_type}')
if resource.resolved_path:
details.append(f'path={resource.resolved_path}')
elif resource.inline_text is not None:
details.append('source=inline')
else:
details.append('source=transport')
lines.append('- ' + '; '.join(details))
return '\n'.join(lines)
@@ -144,6 +267,93 @@ class MCPRuntime:
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
return '\n'.join(lines)
def render_tool_index(
self,
*,
query: str | None = None,
server_name: str | None = None,
limit: int = 50,
) -> str:
tools = self.list_tools(query=query, server_name=server_name, limit=limit)
if not tools:
return '# MCP Tools\n\nNo matching MCP tools discovered.'
lines = ['# MCP Tools', '']
for tool in tools:
details = [tool.name, f'server={tool.server_name}']
if tool.description:
details.append(tool.description)
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def render_tool_call(
self,
tool_name: str,
*,
arguments: dict[str, Any] | None = None,
server_name: str | None = None,
max_chars: int = 12000,
) -> str:
content, metadata = self.call_tool(
tool_name,
arguments=arguments,
server_name=server_name,
max_chars=max_chars,
)
lines = [
'# MCP Tool Result',
'',
f'- Tool: {tool_name}',
f'- Server: {metadata["server_name"]}',
f'- is_error: {metadata["is_error"]}',
'',
content,
]
return '\n'.join(lines)
def _list_remote_resources(self) -> tuple[MCPResource, ...]:
discovered: list[MCPResource] = []
for server in self.servers:
if server.transport != 'stdio':
continue
try:
result = _request_stdio(server, 'resources/list', {})
except OSError:
continue
for item in _extract_remote_resources(server, result):
discovered.append(item)
return tuple(discovered)
def _list_remote_tools(self, *, server_name: str | None = None) -> tuple[MCPTool, ...]:
discovered: list[MCPTool] = []
candidate_servers = (
[self.get_server(server_name)] if server_name else list(self.servers)
)
for server in candidate_servers:
if server is None or server.transport != 'stdio':
continue
try:
result = _request_stdio(server, 'tools/list', {})
except OSError:
continue
for item in _extract_remote_tools(server, result):
discovered.append(item)
return tuple(discovered)
def _resolve_tool(self, tool_name: str, server_name: str | None = None) -> MCPTool:
tools = self.list_tools(server_name=server_name)
matches = [tool for tool in tools if tool.name == tool_name]
if server_name:
if not matches:
raise FileNotFoundError(f'Unknown MCP tool: {tool_name} on server {server_name}')
return matches[0]
if not matches:
raise FileNotFoundError(f'Unknown MCP tool: {tool_name}')
if len(matches) > 1:
raise FileNotFoundError(
f'MCP tool {tool_name} exists on multiple servers. Pass server_name to disambiguate.'
)
return matches[0]
def _discover_manifest_paths(
cwd: Path,
@@ -176,15 +386,17 @@ def _discover_manifest_paths(
return tuple(candidates)
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
def _load_manifest(path: Path) -> tuple[list[MCPResource], list[MCPServerProfile]]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
return [], []
if not isinstance(payload, dict):
return []
return [], []
resources: list[MCPResource] = []
servers: list[MCPServerProfile] = []
if isinstance(payload.get('resources'), list):
resources.extend(
_extract_resources(
@@ -193,21 +405,86 @@ def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
manifest_path=path,
)
)
servers = payload.get('servers')
if isinstance(servers, list):
for item in servers:
raw_servers = payload.get('servers')
if isinstance(raw_servers, list):
for item in raw_servers:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
server_name = name.strip()
raw_resources = item.get('resources')
if not isinstance(raw_resources, list):
if isinstance(raw_resources, list):
resources.extend(
_extract_resources(server_name, raw_resources, manifest_path=path)
)
server = _extract_server_profile(server_name, item, manifest_path=path)
if server is not None:
servers.append(server)
raw_mcp_servers = payload.get('mcpServers')
if isinstance(raw_mcp_servers, dict):
for server_name, item in raw_mcp_servers.items():
if not isinstance(server_name, str) or not server_name.strip():
continue
resources.extend(
_extract_resources(name.strip(), raw_resources, manifest_path=path)
)
return resources
if not isinstance(item, dict):
continue
server = _extract_server_profile(server_name.strip(), item, manifest_path=path)
if server is not None:
servers.append(server)
return resources, servers
def _extract_server_profile(
server_name: str,
payload: dict[str, Any],
*,
manifest_path: Path,
) -> MCPServerProfile | None:
command = payload.get('command')
if not isinstance(command, str) or not command.strip():
return None
args = payload.get('args', ())
if not isinstance(args, list):
args = ()
normalized_args = tuple(
item for item in args if isinstance(item, str)
)
env = payload.get('env')
normalized_env = {
key: value
for key, value in (env.items() if isinstance(env, dict) else [])
if isinstance(key, str) and isinstance(value, str)
}
cwd = payload.get('cwd')
resolved_cwd: str | None = None
if isinstance(cwd, str) and cwd.strip():
candidate = Path(cwd).expanduser()
if not candidate.is_absolute():
candidate = manifest_path.parent / candidate
resolved_cwd = str(candidate.resolve())
description = payload.get('description') if isinstance(payload.get('description'), str) else None
transport = payload.get('transport')
if not isinstance(transport, str) or not transport.strip():
transport = 'stdio'
transport = transport.strip().lower()
if transport != 'stdio':
return None
metadata = payload.get('metadata')
return MCPServerProfile(
name=server_name,
source_manifest=str(manifest_path),
transport=transport,
command=command.strip(),
args=normalized_args,
env=normalized_env,
cwd=resolved_cwd,
description=description,
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _extract_resources(
@@ -267,6 +544,334 @@ def _extract_resources(
return resources
def _extract_remote_resources(
server: MCPServerProfile,
payload: dict[str, Any],
) -> tuple[MCPResource, ...]:
raw_resources = payload.get('resources')
if not isinstance(raw_resources, list):
return ()
resources: list[MCPResource] = []
for item in raw_resources:
if not isinstance(item, dict):
continue
uri = item.get('uri')
if not isinstance(uri, str) or not uri.strip():
continue
resources.append(
MCPResource(
uri=uri.strip(),
server_name=server.name,
source_manifest=server.source_manifest,
name=item.get('name') if isinstance(item.get('name'), str) else None,
description=(
item.get('description')
if isinstance(item.get('description'), str)
else None
),
mime_type=(
item.get('mimeType')
if isinstance(item.get('mimeType'), str)
else item.get('mime_type')
if isinstance(item.get('mime_type'), str)
else None
),
metadata={
'transport': server.transport,
'server_command': server.command,
},
)
)
return tuple(resources)
def _extract_remote_tools(
server: MCPServerProfile,
payload: dict[str, Any],
) -> tuple[MCPTool, ...]:
raw_tools = payload.get('tools')
if not isinstance(raw_tools, list):
return ()
tools: list[MCPTool] = []
for item in raw_tools:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
input_schema = item.get('inputSchema')
if not isinstance(input_schema, dict):
input_schema = item.get('input_schema')
tools.append(
MCPTool(
name=name.strip(),
server_name=server.name,
source_manifest=server.source_manifest,
description=(
item.get('description')
if isinstance(item.get('description'), str)
else None
),
input_schema=dict(input_schema) if isinstance(input_schema, dict) else {},
metadata={
'transport': server.transport,
'server_command': server.command,
},
)
)
return tuple(tools)
def _filter_resources(
resources: tuple[MCPResource, ...],
*,
query: str | None = None,
) -> tuple[MCPResource, ...]:
if not query:
return resources
needle = query.lower()
return tuple(
resource
for resource in resources
if needle in resource.uri.lower()
or needle in resource.server_name.lower()
or needle in (resource.name or '').lower()
or needle in (resource.description or '').lower()
)
def _dedupe_servers(servers: list[MCPServerProfile]) -> list[MCPServerProfile]:
seen: set[tuple[str, str, str | None, tuple[str, ...]]] = set()
deduped: list[MCPServerProfile] = []
for server in servers:
key = (server.name.lower(), server.transport, server.command, server.args)
if key in seen:
continue
seen.add(key)
deduped.append(server)
return deduped
def _request_stdio(
server: MCPServerProfile,
method: str,
params: dict[str, Any],
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
with _StdioMCPConnection(server, timeout_seconds=timeout_seconds) as connection:
return connection.request(method, params)
class _StdioMCPConnection:
def __init__(self, server: MCPServerProfile, *, timeout_seconds: float = 10.0) -> None:
self.server = server
self.timeout_seconds = timeout_seconds
self.process: subprocess.Popen[str] | None = None
self.selector: selectors.BaseSelector | None = None
self.stderr_lines: list[str] = []
self._request_id = 0
def __enter__(self) -> '_StdioMCPConnection':
try:
command = [self.server.command or '', *self.server.args]
if not command[0]:
raise OSError(f'MCP server {self.server.name} has no executable command')
env = os.environ.copy()
env.update(self.server.env)
self.process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
cwd=self.server.cwd or None,
env=env,
)
self.selector = selectors.DefaultSelector()
assert self.process.stdout is not None
assert self.process.stderr is not None
self.selector.register(self.process.stdout, selectors.EVENT_READ, data='stdout')
self.selector.register(self.process.stderr, selectors.EVENT_READ, data='stderr')
self._initialize()
return self
except Exception:
self.close()
raise
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def close(self) -> None:
process = self.process
if self.selector is not None:
try:
self.selector.close()
except Exception:
pass
self.selector = None
if process is None:
return
try:
if process.stdin is not None:
process.stdin.close()
except Exception:
pass
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
for stream_name in ('stdout', 'stderr'):
stream = getattr(process, stream_name, None)
if stream is not None:
try:
stream.close()
except Exception:
pass
self.process = None
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
self._request_id += 1
request_id = self._request_id
self._send(
{
'jsonrpc': '2.0',
'id': request_id,
'method': method,
'params': params,
}
)
response = self._await_response(request_id)
error = response.get('error')
if isinstance(error, dict):
message = error.get('message')
raise OSError(
f'MCP {method} failed for server {self.server.name}: {message or error}'
)
result = response.get('result')
if not isinstance(result, dict):
return {}
return result
def _initialize(self) -> None:
self._request_id += 1
request_id = self._request_id
self._send(
{
'jsonrpc': '2.0',
'id': request_id,
'method': 'initialize',
'params': {
'protocolVersion': MCP_PROTOCOL_VERSION,
'capabilities': {},
'clientInfo': {
'name': 'claw-code-agent',
'version': '0.1.0',
},
},
}
)
response = self._await_response(request_id)
error = response.get('error')
if isinstance(error, dict):
raise OSError(
f'MCP initialize failed for server {self.server.name}: {error.get("message") or error}'
)
self._send(
{
'jsonrpc': '2.0',
'method': 'notifications/initialized',
'params': {},
}
)
def _send(self, payload: dict[str, Any]) -> None:
if self.process is None or self.process.stdin is None:
raise OSError(f'MCP server {self.server.name} is not running')
self.process.stdin.write(json.dumps(payload, ensure_ascii=True) + '\n')
self.process.stdin.flush()
def _await_response(self, request_id: int) -> dict[str, Any]:
deadline = time.monotonic() + self.timeout_seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
stderr = '\n'.join(self.stderr_lines[-5:])
raise TimeoutError(
f'Timed out waiting for MCP response from {self.server.name}'
+ (f' stderr={stderr}' if stderr else '')
)
if self.selector is None:
raise OSError(f'MCP selector is not available for {self.server.name}')
events = self.selector.select(timeout=remaining)
if not events:
continue
for key, _mask in events:
stream_name = key.data
line = key.fileobj.readline()
if not line:
continue
if stream_name == 'stderr':
self.stderr_lines.append(line.rstrip())
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(payload, dict):
continue
if payload.get('id') == request_id:
return payload
def _render_resource_contents(contents: Any) -> str:
if not isinstance(contents, list):
return ''
parts: list[str] = []
for item in contents:
if not isinstance(item, dict):
continue
text = item.get('text')
if isinstance(text, str):
parts.append(text)
continue
blob = item.get('blob')
if isinstance(blob, str):
mime_type = item.get('mimeType') if isinstance(item.get('mimeType'), str) else 'application/octet-stream'
parts.append(f'[blob:{mime_type}] {blob}')
continue
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
return '\n\n'.join(parts).strip()
def _render_tool_call_result(result: dict[str, Any]) -> str:
parts: list[str] = []
content = result.get('content')
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
text = item.get('text')
if isinstance(text, str):
parts.append(text)
continue
structured = item.get('structuredContent')
if structured is not None:
parts.append(json.dumps(structured, ensure_ascii=True, indent=2))
continue
parts.append(json.dumps(item, ensure_ascii=True, indent=2))
structured_content = result.get('structuredContent')
if structured_content is not None:
parts.append(json.dumps(structured_content, ensure_ascii=True, indent=2))
if not parts:
parts.append(json.dumps(result, ensure_ascii=True, indent=2))
return '\n\n'.join(part for part in parts if part).strip()
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
+80 -3
View File
@@ -11,7 +11,13 @@ from .task_runtime import TaskRuntime
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed')
VALID_PLAN_STATUSES = (
'pending',
'in_progress',
'completed',
'blocked',
'cancelled',
)
@dataclass(frozen=True)
@@ -21,6 +27,9 @@ class PlanStep:
task_id: str | None = None
description: str | None = None
priority: str | None = None
active_form: str | None = None
owner: str | None = None
depends_on: tuple[str, ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
@@ -29,6 +38,9 @@ class PlanStep:
'task_id': self.task_id,
'description': self.description,
'priority': self.priority,
'active_form': self.active_form,
'owner': self.owner,
'depends_on': list(self.depends_on),
}
@classmethod
@@ -53,6 +65,19 @@ class PlanStep:
and payload.get('priority').strip()
else None
),
active_form=(
str(payload.get('active_form'))
if isinstance(payload.get('active_form'), str)
and payload.get('active_form').strip()
else None
),
owner=(
str(payload.get('owner'))
if isinstance(payload.get('owner'), str)
and payload.get('owner').strip()
else None
),
depends_on=_normalize_id_list(payload.get('depends_on')),
)
@@ -147,6 +172,19 @@ class PlanRuntime:
and item.get('priority').strip()
else None
),
active_form=(
item.get('active_form').strip()
if isinstance(item.get('active_form'), str)
and item.get('active_form').strip()
else None
),
owner=(
item.get('owner').strip()
if isinstance(item.get('owner'), str)
and item.get('owner').strip()
else None
),
depends_on=_normalize_id_list(item.get('depends_on')),
)
)
mutation = self._persist(
@@ -158,6 +196,10 @@ class PlanRuntime:
),
)
if sync_tasks and task_runtime is not None:
dependents: dict[str, list[str]] = {}
for step in self.steps:
for dependency in step.depends_on:
dependents.setdefault(dependency, []).append(step.task_id or '')
task_items = [
{
'task_id': step.task_id or f'plan_{index}',
@@ -165,6 +207,14 @@ class PlanRuntime:
'description': step.description,
'status': _plan_status_to_task_status(step.status),
'priority': step.priority,
'active_form': step.active_form,
'owner': step.owner,
'blocked_by': list(step.depends_on),
'blocks': sorted(
dependency
for dependency in dependents.get(step.task_id or f'plan_{index}', [])
if dependency
),
}
for index, step in enumerate(self.steps, start=1)
]
@@ -238,6 +288,12 @@ class PlanRuntime:
lines.append('- ' + '; '.join(details))
if step.description:
lines.append(f' description: {step.description}')
if step.active_form:
lines.append(f' active_form: {step.active_form}')
if step.owner:
lines.append(f' owner: {step.owner}')
if step.depends_on:
lines.append(f" depends_on: {', '.join(step.depends_on)}")
return '\n'.join(lines)
def _persist(
@@ -297,6 +353,7 @@ def _normalize_plan_status(value: Any) -> str:
'complete': 'completed',
'in-progress': 'in_progress',
'in progress': 'in_progress',
'blocked_on': 'blocked',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_PLAN_STATUSES:
@@ -306,10 +363,30 @@ def _normalize_plan_status(value: Any) -> str:
def _plan_status_to_task_status(status: str) -> str:
if status == 'completed':
return 'done'
return 'completed'
if status == 'in_progress':
return 'in_progress'
return 'todo'
if status == 'blocked':
return 'blocked'
if status == 'cancelled':
return 'cancelled'
return 'pending'
def _normalize_id_list(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
def _snapshot_text(text: str, limit: int = 240) -> str:
+571 -25
View File
@@ -1,25 +1,571 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RuntimeModeReport:
mode: str
connected: bool
detail: str
def as_text(self) -> str:
return f'mode={self.mode}\nconnected={self.connected}\ndetail={self.detail}'
def run_remote_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('remote', True, f'Remote control placeholder prepared for {target}')
def run_ssh_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('ssh', True, f'SSH proxy placeholder prepared for {target}')
def run_teleport_mode(target: str) -> RuntimeModeReport:
return RuntimeModeReport('teleport', True, f'Teleport resume/create placeholder prepared for {target}')
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_REMOTE_STATE_DIR = Path('.port_sessions')
DEFAULT_REMOTE_STATE_FILE = DEFAULT_REMOTE_STATE_DIR / 'remote_runtime.json'
SUPPORTED_REMOTE_MODES = (
'remote',
'ssh',
'teleport',
'direct-connect',
'deep-link',
)
@dataclass(frozen=True)
class RemoteProfile:
name: str
mode: str
target: str
source_manifest: str
description: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
env: dict[str, str] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RemoteConnectionState:
mode: str
target: str
connected: bool
connected_at: str
profile_name: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
source_manifest: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RuntimeModeReport:
mode: str
connected: bool
detail: str
target: str | None = None
profile_name: str | None = None
workspace_cwd: str | None = None
session_url: str | None = None
source_manifest: str | None = None
manifest_count: int = 0
profile_count: int = 0
metadata: dict[str, Any] = field(default_factory=dict)
def as_text(self) -> str:
lines = [
f'mode={self.mode}',
f'connected={self.connected}',
f'detail={self.detail}',
]
if self.target:
lines.append(f'target={self.target}')
if self.profile_name:
lines.append(f'profile={self.profile_name}')
if self.workspace_cwd:
lines.append(f'workspace_cwd={self.workspace_cwd}')
if self.session_url:
lines.append(f'session_url={self.session_url}')
if self.source_manifest:
lines.append(f'source_manifest={self.source_manifest}')
lines.append(f'manifest_count={self.manifest_count}')
lines.append(f'profile_count={self.profile_count}')
if self.metadata:
for key, value in sorted(self.metadata.items()):
lines.append(f'metadata.{key}={value}')
return '\n'.join(lines)
@dataclass
class RemoteRuntime:
cwd: Path
profiles: tuple[RemoteProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_REMOTE_STATE_FILE.resolve())
active_connection: RemoteConnectionState | None = None
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'RemoteRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
profiles: list[RemoteProfile] = []
for manifest_path in manifest_paths:
profiles.extend(_load_profiles_from_manifest(manifest_path))
state_path = cwd.resolve() / DEFAULT_REMOTE_STATE_FILE
payload = _load_state_payload(state_path)
active_connection = _connection_from_payload(payload.get('active_connection'))
history_payload = payload.get('history')
history = tuple(
item for item in history_payload if isinstance(item, dict)
) if isinstance(history_payload, list) else ()
return cls(
cwd=cwd.resolve(),
profiles=tuple(profiles),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_connection=active_connection,
history=history,
)
def has_remote_config(self) -> bool:
return bool(self.profiles or self.active_connection is not None)
def list_profiles(
self,
*,
query: str | None = None,
mode: str | None = None,
limit: int | None = None,
) -> tuple[RemoteProfile, ...]:
profiles = self.profiles
if query:
needle = query.lower()
profiles = tuple(
profile
for profile in profiles
if needle in profile.name.lower()
or needle in profile.mode.lower()
or needle in profile.target.lower()
or needle in (profile.description or '').lower()
)
if mode:
profiles = tuple(profile for profile in profiles if profile.mode == _normalize_mode(mode))
if limit is not None and limit >= 0:
profiles = profiles[:limit]
return profiles
def get_profile(self, name_or_target: str) -> RemoteProfile | None:
needle = name_or_target.strip().lower()
if not needle:
return None
for profile in self.profiles:
if profile.name.lower() == needle or profile.target.lower() == needle:
return profile
return None
def connect(
self,
target: str,
*,
mode: str | None = None,
) -> RuntimeModeReport:
normalized_mode = _normalize_mode(mode or 'remote')
profile = self.get_profile(target)
if profile is not None:
normalized_mode = _normalize_mode(profile.mode or normalized_mode)
connection = RemoteConnectionState(
mode=normalized_mode,
target=profile.target,
connected=True,
connected_at=_utc_now(),
profile_name=profile.name,
workspace_cwd=profile.workspace_cwd,
session_url=profile.session_url,
source_manifest=profile.source_manifest,
metadata=dict(profile.metadata),
)
detail = f'Activated remote profile {profile.name}'
else:
connection = RemoteConnectionState(
mode=normalized_mode,
target=target.strip(),
connected=True,
connected_at=_utc_now(),
metadata={'ephemeral': True},
)
detail = f'Activated {normalized_mode} target {target.strip()}'
self.active_connection = connection
self._append_history(
{
'action': 'connect',
'mode': connection.mode,
'target': connection.target,
'profile_name': connection.profile_name,
'connected_at': connection.connected_at,
}
)
self._persist_state()
return self.current_report(detail=detail)
def disconnect(self, *, reason: str = 'manual_disconnect') -> RuntimeModeReport:
previous = self.active_connection
detail = (
f'Disconnected {previous.mode} target {previous.target}'
if previous is not None
else 'No active remote connection was present.'
)
if previous is not None:
self._append_history(
{
'action': 'disconnect',
'mode': previous.mode,
'target': previous.target,
'profile_name': previous.profile_name,
'reason': reason,
'disconnected_at': _utc_now(),
}
)
self.active_connection = None
self._persist_state()
return RuntimeModeReport(
mode=previous.mode if previous is not None else 'remote',
connected=False,
detail=detail,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
)
def current_report(self, *, detail: str | None = None) -> RuntimeModeReport:
if self.active_connection is None:
return RuntimeModeReport(
mode='remote',
connected=False,
detail=detail or 'No active remote connection.',
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
)
connection = self.active_connection
return RuntimeModeReport(
mode=connection.mode,
connected=connection.connected,
detail=detail or f'Active {connection.mode} connection for {connection.target}',
target=connection.target,
profile_name=connection.profile_name,
workspace_cwd=connection.workspace_cwd,
session_url=connection.session_url,
source_manifest=connection.source_manifest,
manifest_count=len(self.manifests),
profile_count=len(self.profiles),
metadata=dict(connection.metadata),
)
def render_summary(self) -> str:
lines = [
f'Local remote manifests: {len(self.manifests)}',
f'Configured remote profiles: {len(self.profiles)}',
]
if self.active_connection is None:
lines.append('- Active remote connection: none')
else:
connection = self.active_connection
active = f'- Active remote connection: {connection.mode} -> {connection.target}'
if connection.profile_name:
active += f' (profile={connection.profile_name})'
lines.append(active)
if connection.workspace_cwd:
lines.append(f'- Active remote workspace: {connection.workspace_cwd}')
if connection.session_url:
lines.append(f'- Active remote session URL: {connection.session_url}')
for profile in self.profiles[:10]:
parts = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
if profile.workspace_cwd:
parts.append(f'workspace={profile.workspace_cwd}')
if profile.session_url:
parts.append(f'session_url={profile.session_url}')
lines.append('- ' + '; '.join(parts))
if self.history:
lines.append(f'- Runtime history entries: {len(self.history)}')
return '\n'.join(lines)
def render_profiles_index(
self,
*,
query: str | None = None,
mode: str | None = None,
limit: int = 20,
) -> str:
profiles = self.list_profiles(query=query, mode=mode, limit=limit)
if not profiles:
return '# Remote Profiles\n\nNo matching remote profiles discovered.'
lines = ['# Remote Profiles', '']
for profile in profiles:
details = [profile.name, f'mode={profile.mode}', f'target={profile.target}']
if profile.workspace_cwd:
details.append(f'workspace={profile.workspace_cwd}')
if profile.session_url:
details.append(f'session_url={profile.session_url}')
if profile.description:
details.append(f'description={profile.description}')
lines.append('- ' + '; '.join(details))
return '\n'.join(lines)
def render_profile(self, name_or_target: str) -> str:
profile = self.get_profile(name_or_target)
if profile is None:
return f'# Remote Profile\n\nUnknown remote profile: {name_or_target}'
lines = [
'# Remote Profile',
'',
f'- Name: {profile.name}',
f'- Mode: {profile.mode}',
f'- Target: {profile.target}',
f'- Source manifest: {profile.source_manifest}',
]
if profile.description:
lines.append(f'- Description: {profile.description}')
if profile.workspace_cwd:
lines.append(f'- Workspace: {profile.workspace_cwd}')
if profile.session_url:
lines.append(f'- Session URL: {profile.session_url}')
if profile.env:
lines.append('- Environment values:')
lines.extend(f' - {key}={value}' for key, value in sorted(profile.env.items()))
if profile.metadata:
lines.append('- Metadata:')
lines.extend(f' - {key}={value}' for key, value in sorted(profile.metadata.items()))
return '\n'.join(lines)
def _persist_state(self) -> None:
payload = {
'active_connection': (
asdict(self.active_connection) if self.active_connection is not None else None
),
'history': list(self.history),
}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2),
encoding='utf-8',
)
def _append_history(self, entry: dict[str, Any]) -> None:
merged = [*self.history, dict(entry)]
self.history = tuple(merged[-40:])
def run_remote_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='remote')
def run_ssh_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='ssh')
def run_teleport_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='teleport')
def run_direct_connect_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='direct-connect')
def run_deep_link_mode(
target: str,
*,
cwd: Path | None = None,
additional_working_directories: tuple[str, ...] = (),
) -> RuntimeModeReport:
runtime = RemoteRuntime.from_workspace(
cwd or Path.cwd(),
additional_working_directories=additional_working_directories,
)
return runtime.connect(target, mode='deep-link')
def _discover_manifest_paths(
cwd: Path,
additional_working_directories: tuple[str, ...],
) -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[Path] = set()
def remember(path: Path) -> None:
resolved = path.resolve()
if resolved in seen or not resolved.exists() or not resolved.is_file():
return
seen.add(resolved)
candidates.append(resolved)
roots: list[Path] = []
current = cwd.resolve()
while True:
roots.append(current)
if current.parent == current:
break
current = current.parent
roots.extend(Path(path).resolve() for path in additional_working_directories)
for root in roots:
remember(root / '.claw-remote.json')
remember(root / '.remote.json')
remember(root / '.codex-remote.json')
remember(root / 'remote.json')
return tuple(candidates)
def _load_profiles_from_manifest(path: Path) -> list[RemoteProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(payload, dict):
return []
profiles: list[RemoteProfile] = []
raw_profiles = payload.get('profiles')
if isinstance(raw_profiles, list):
profiles.extend(_extract_profiles(raw_profiles, manifest_path=path))
elif _looks_like_profile(payload):
profile = _profile_from_item(payload, manifest_path=path)
if profile is not None:
profiles.append(profile)
remotes = payload.get('remotes')
if isinstance(remotes, list):
profiles.extend(_extract_profiles(remotes, manifest_path=path))
return profiles
def _extract_profiles(raw_profiles: list[Any], *, manifest_path: Path) -> list[RemoteProfile]:
profiles: list[RemoteProfile] = []
seen_names: set[str] = set()
for item in raw_profiles:
profile = _profile_from_item(item, manifest_path=manifest_path)
if profile is None or profile.name.lower() in seen_names:
continue
seen_names.add(profile.name.lower())
profiles.append(profile)
return profiles
def _profile_from_item(item: Any, *, manifest_path: Path) -> RemoteProfile | None:
if not isinstance(item, dict):
return None
name = item.get('name')
target = item.get('target')
if not isinstance(name, str) or not name.strip():
return None
if not isinstance(target, str) or not target.strip():
return None
mode = _normalize_mode(str(item.get('mode', 'remote')))
workspace_cwd = _optional_string(
item.get('workspaceCwd')
if item.get('workspaceCwd') is not None
else item.get('workspace_cwd')
)
session_url = _optional_string(
item.get('sessionUrl')
if item.get('sessionUrl') is not None
else item.get('session_url')
)
description = _optional_string(item.get('description'))
env = item.get('env')
metadata = item.get('metadata')
return RemoteProfile(
name=name.strip(),
mode=mode,
target=target.strip(),
source_manifest=str(manifest_path),
description=description,
workspace_cwd=workspace_cwd,
session_url=session_url,
env=(
{
str(key): str(value)
for key, value in env.items()
if isinstance(key, str) and isinstance(value, (str, int, float, bool))
}
if isinstance(env, dict)
else {}
),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _looks_like_profile(payload: dict[str, Any]) -> bool:
return isinstance(payload.get('name'), str) and isinstance(payload.get('target'), str)
def _load_state_payload(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _connection_from_payload(payload: Any) -> RemoteConnectionState | None:
if not isinstance(payload, dict):
return None
mode = _optional_string(payload.get('mode'))
target = _optional_string(payload.get('target'))
connected_at = _optional_string(payload.get('connected_at'))
if mode is None or target is None or connected_at is None:
return None
metadata = payload.get('metadata')
return RemoteConnectionState(
mode=_normalize_mode(mode),
target=target,
connected=bool(payload.get('connected', True)),
connected_at=connected_at,
profile_name=_optional_string(payload.get('profile_name')),
workspace_cwd=_optional_string(payload.get('workspace_cwd')),
session_url=_optional_string(payload.get('session_url')),
source_manifest=_optional_string(payload.get('source_manifest')),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _normalize_mode(mode: str) -> str:
normalized = mode.strip().lower().replace('_', '-')
if normalized == 'direct':
normalized = 'direct-connect'
if normalized == 'deeplink':
normalized = 'deep-link'
if normalized not in SUPPORTED_REMOTE_MODES:
return 'remote'
return normalized
def _optional_string(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
+606
View File
@@ -0,0 +1,606 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib import parse, request
DEFAULT_SEARCH_STATE_DIR = Path('.port_sessions')
DEFAULT_SEARCH_STATE_FILE = DEFAULT_SEARCH_STATE_DIR / 'search_runtime.json'
SEARCH_MANIFEST_PATHS = (
Path('.claw-search.json'),
Path('.claude/search.json'),
)
DEFAULT_SEARXNG_BASE_URL = 'http://127.0.0.1:8080'
DEFAULT_BRAVE_BASE_URL = 'https://api.search.brave.com/res/v1/web/search'
DEFAULT_TAVILY_BASE_URL = 'https://api.tavily.com/search'
@dataclass(frozen=True)
class SearchProviderProfile:
name: str
provider: str
source_manifest: str
base_url: str
api_key_env: str | None = None
description: str | None = None
default_max_results: int = 5
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SearchResult:
title: str
url: str
snippet: str
provider_name: str
rank: int
@dataclass(frozen=True)
class SearchStatusReport:
configured: bool
detail: str
provider_name: str | None = None
provider_kind: str | None = None
base_url: str | None = None
manifest_count: int = 0
provider_count: int = 0
api_key_env: str | None = None
def as_text(self) -> str:
lines = [
f'configured={self.configured}',
f'detail={self.detail}',
f'manifest_count={self.manifest_count}',
f'provider_count={self.provider_count}',
]
if self.provider_name:
lines.append(f'provider={self.provider_name}')
if self.provider_kind:
lines.append(f'provider_kind={self.provider_kind}')
if self.base_url:
lines.append(f'base_url={self.base_url}')
if self.api_key_env:
lines.append(f'api_key_env={self.api_key_env}')
return '\n'.join(lines)
@dataclass
class SearchRuntime:
cwd: Path
providers: tuple[SearchProviderProfile, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_SEARCH_STATE_FILE.resolve())
active_provider_name: str | None = None
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'SearchRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
providers: list[SearchProviderProfile] = []
for manifest_path in manifest_paths:
providers.extend(_load_profiles_from_manifest(manifest_path))
providers.extend(_load_profiles_from_env())
providers = _dedupe_profiles(providers)
state_path = (cwd.resolve() / DEFAULT_SEARCH_STATE_FILE).resolve()
payload = _load_state_payload(state_path)
active_provider_name = payload.get('active_provider_name')
if not isinstance(active_provider_name, str):
active_provider_name = None
return cls(
cwd=cwd.resolve(),
providers=tuple(providers),
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
active_provider_name=active_provider_name,
)
def has_search_runtime(self) -> bool:
return bool(self.providers)
def list_providers(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[SearchProviderProfile, ...]:
providers = self.providers
if query:
needle = query.lower()
providers = tuple(
provider
for provider in providers
if needle in provider.name.lower()
or needle in provider.provider.lower()
or needle in provider.base_url.lower()
or needle in (provider.description or '').lower()
)
if limit is not None and limit >= 0:
providers = providers[:limit]
return providers
def get_provider(self, name: str) -> SearchProviderProfile | None:
needle = name.strip().lower()
if not needle:
return None
for provider in self.providers:
if provider.name.lower() == needle:
return provider
return None
def current_provider(self) -> SearchProviderProfile | None:
if self.active_provider_name:
active = self.get_provider(self.active_provider_name)
if active is not None:
return active
env_default = os.environ.get('CLAW_SEARCH_PROVIDER')
if isinstance(env_default, str) and env_default.strip():
active = self.get_provider(env_default.strip())
if active is not None:
return active
return self.providers[0] if self.providers else None
def activate_provider(self, name: str) -> SearchStatusReport:
provider = self.get_provider(name)
if provider is None:
raise KeyError(name)
self.active_provider_name = provider.name
self._persist_state()
return SearchStatusReport(
configured=True,
detail=f'Activated search provider {provider.name}',
provider_name=provider.name,
provider_kind=provider.provider,
base_url=provider.base_url,
manifest_count=len(self.manifests),
provider_count=len(self.providers),
api_key_env=provider.api_key_env,
)
def render_summary(self) -> str:
lines = [
f'Local search manifests: {len(self.manifests)}',
f'Configured search providers: {len(self.providers)}',
]
current = self.current_provider()
if current is None:
lines.append('- Active search provider: none')
return '\n'.join(lines)
lines.append(f'- Active search provider: {current.name} ({current.provider})')
for provider in self.providers[:5]:
details = [provider.name, provider.provider, provider.base_url]
if provider.api_key_env:
details.append(f'api_key_env={provider.api_key_env}')
lines.append('- Provider: ' + ' ; '.join(details))
return '\n'.join(lines)
def render_providers_index(self, *, query: str | None = None) -> str:
providers = self.list_providers(query=query, limit=100)
lines = ['# Search Providers', '']
if not providers:
lines.append('No local search providers discovered.')
return '\n'.join(lines)
for provider in providers:
details = [provider.name, provider.provider, provider.base_url]
if provider.api_key_env:
details.append(f'api_key_env={provider.api_key_env}')
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def render_provider(self, name: str) -> str:
provider = self.get_provider(name)
if provider is None:
return f'# Search Provider\n\nUnknown search provider: {name}'
lines = [
'# Search Provider',
'',
f'- Name: {provider.name}',
f'- Provider: {provider.provider}',
f'- Base URL: {provider.base_url}',
f'- Source manifest: {provider.source_manifest}',
]
if provider.api_key_env:
lines.append(f'- API key env: {provider.api_key_env}')
if provider.description:
lines.extend(['', provider.description])
return '\n'.join(lines)
def render_search_results(
self,
query: str,
*,
provider_name: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
timeout_seconds: float = 20.0,
) -> str:
provider, results = self.search(
query,
provider_name=provider_name,
max_results=max_results,
domains=domains,
timeout_seconds=timeout_seconds,
)
lines = ['# Web Search', '']
lines.append(f'- Provider: {provider.name} ({provider.provider})')
lines.append(f'- Query: {query}')
lines.append(f'- Results: {len(results)}')
lines.append('')
if not results:
lines.append('No search results.')
return '\n'.join(lines)
for result in results:
lines.append(f'{result.rank}. {result.title}')
lines.append(f' {result.url}')
if result.snippet:
lines.append(f' {result.snippet}')
return '\n'.join(lines)
def search(
self,
query: str,
*,
provider_name: str | None = None,
max_results: int = 5,
domains: tuple[str, ...] = (),
timeout_seconds: float = 20.0,
) -> tuple[SearchProviderProfile, tuple[SearchResult, ...]]:
provider = self._resolve_provider(provider_name)
backend = provider.provider.lower()
if backend == 'searxng':
results = _search_searxng(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
elif backend == 'brave':
results = _search_brave(provider, query, max_results=max_results, timeout_seconds=timeout_seconds)
elif backend == 'tavily':
results = _search_tavily(provider, query, max_results=max_results, domains=domains, timeout_seconds=timeout_seconds)
else:
raise ValueError(f'Unsupported search provider: {provider.provider}')
if domains:
results = tuple(result for result in results if _matches_domains(result.url, domains))
return provider, tuple(results[:max_results])
def _resolve_provider(self, provider_name: str | None) -> SearchProviderProfile:
if provider_name:
provider = self.get_provider(provider_name)
if provider is None:
raise KeyError(provider_name)
return provider
provider = self.current_provider()
if provider is None:
raise LookupError('No local search provider is configured.')
return provider
def _persist_state(self) -> None:
payload = {'active_provider_name': self.active_provider_name}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n',
encoding='utf-8',
)
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
candidate_roots = [cwd.resolve()]
for raw_path in additional_working_directories:
path = Path(raw_path).resolve()
if path not in candidate_roots:
candidate_roots.append(path)
discovered: list[Path] = []
seen: set[Path] = set()
for root in candidate_roots:
for relative_path in SEARCH_MANIFEST_PATHS:
path = (root / relative_path).resolve()
if path in seen or not path.exists() or not path.is_file():
continue
seen.add(path)
discovered.append(path)
return tuple(discovered)
def _load_profiles_from_manifest(path: Path) -> list[SearchProviderProfile]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if isinstance(payload, dict):
providers_payload = payload.get('providers')
if isinstance(providers_payload, list):
return [
provider
for item in providers_payload
for provider in [_provider_from_payload(item, path)]
if provider is not None
]
single = _provider_from_payload(payload, path)
return [single] if single is not None else []
return []
def _provider_from_payload(payload: Any, path: Path) -> SearchProviderProfile | None:
if not isinstance(payload, dict):
return None
name = payload.get('name')
provider = payload.get('provider')
if not isinstance(name, str) or not name.strip():
return None
if not isinstance(provider, str) or not provider.strip():
return None
normalized_provider = provider.strip().lower()
base_url = _optional_str(payload.get('baseUrl') or payload.get('base_url')) or _default_base_url(normalized_provider)
if base_url is None:
return None
api_key_env = _optional_str(payload.get('apiKeyEnv') or payload.get('api_key_env')) or _default_api_env(normalized_provider)
description = _optional_str(payload.get('description'))
default_max_results = payload.get('defaultMaxResults') or payload.get('default_max_results') or 5
if isinstance(default_max_results, bool) or not isinstance(default_max_results, int):
default_max_results = 5
metadata = payload.get('metadata')
return SearchProviderProfile(
name=name.strip(),
provider=normalized_provider,
source_manifest=str(path),
base_url=base_url,
api_key_env=api_key_env,
description=description,
default_max_results=max(default_max_results, 1),
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
def _load_profiles_from_env() -> list[SearchProviderProfile]:
providers: list[SearchProviderProfile] = []
searxng_base = os.environ.get('SEARXNG_BASE_URL')
if isinstance(searxng_base, str) and searxng_base.strip():
providers.append(
SearchProviderProfile(
name='searxng',
provider='searxng',
source_manifest='env:SEARXNG_BASE_URL',
base_url=searxng_base.strip(),
)
)
brave_key = os.environ.get('BRAVE_SEARCH_API_KEY')
if isinstance(brave_key, str) and brave_key.strip():
providers.append(
SearchProviderProfile(
name='brave',
provider='brave',
source_manifest='env:BRAVE_SEARCH_API_KEY',
base_url=DEFAULT_BRAVE_BASE_URL,
api_key_env='BRAVE_SEARCH_API_KEY',
)
)
tavily_key = os.environ.get('TAVILY_API_KEY')
if isinstance(tavily_key, str) and tavily_key.strip():
providers.append(
SearchProviderProfile(
name='tavily',
provider='tavily',
source_manifest='env:TAVILY_API_KEY',
base_url=DEFAULT_TAVILY_BASE_URL,
api_key_env='TAVILY_API_KEY',
)
)
return providers
def _dedupe_profiles(providers: list[SearchProviderProfile]) -> list[SearchProviderProfile]:
seen: set[str] = set()
deduped: list[SearchProviderProfile] = []
for provider in providers:
key = provider.name.lower()
if key in seen:
continue
seen.add(key)
deduped.append(provider)
return deduped
def _load_state_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _default_base_url(provider: str) -> str | None:
if provider == 'searxng':
return DEFAULT_SEARXNG_BASE_URL
if provider == 'brave':
return DEFAULT_BRAVE_BASE_URL
if provider == 'tavily':
return DEFAULT_TAVILY_BASE_URL
return None
def _default_api_env(provider: str) -> str | None:
if provider == 'brave':
return 'BRAVE_SEARCH_API_KEY'
if provider == 'tavily':
return 'TAVILY_API_KEY'
return None
def _optional_str(value: Any) -> str | None:
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _search_searxng(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
endpoint = provider.base_url.rstrip('/')
if not endpoint.endswith('/search'):
endpoint += '/search'
url = endpoint + '?' + parse.urlencode(
{
'q': query,
'format': 'json',
}
)
req = request.Request(url, headers={'User-Agent': 'claw-code-agent/1.0'})
with request.urlopen(req, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode('utf-8', errors='replace'))
results = payload.get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('content') or item.get('snippet') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _search_brave(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
api_key = _require_api_key(provider)
url = provider.base_url + '?' + parse.urlencode({'q': query, 'count': max_results})
req = request.Request(
url,
headers={
'User-Agent': 'claw-code-agent/1.0',
'X-Subscription-Token': api_key,
'Accept': 'application/json',
},
)
with request.urlopen(req, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode('utf-8', errors='replace'))
results = payload.get('web', {}).get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('description') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _search_tavily(
provider: SearchProviderProfile,
query: str,
*,
max_results: int,
domains: tuple[str, ...],
timeout_seconds: float,
) -> tuple[SearchResult, ...]:
api_key = _require_api_key(provider)
payload = {
'api_key': api_key,
'query': query,
'max_results': max_results,
}
if domains:
payload['include_domains'] = list(domains)
data = json.dumps(payload, ensure_ascii=True).encode('utf-8')
req = request.Request(
provider.base_url,
data=data,
headers={
'User-Agent': 'claw-code-agent/1.0',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
method='POST',
)
with request.urlopen(req, timeout=timeout_seconds) as response:
body = json.loads(response.read().decode('utf-8', errors='replace'))
results = body.get('results')
if not isinstance(results, list):
return ()
rendered: list[SearchResult] = []
for index, item in enumerate(results[:max_results], start=1):
if not isinstance(item, dict):
continue
url_value = item.get('url')
title = item.get('title')
snippet = item.get('content') or ''
if not isinstance(url_value, str) or not url_value.strip():
continue
if not isinstance(title, str) or not title.strip():
title = url_value
rendered.append(
SearchResult(
title=title.strip(),
url=url_value.strip(),
snippet=str(snippet).strip(),
provider_name=provider.name,
rank=index,
)
)
return tuple(rendered)
def _require_api_key(provider: SearchProviderProfile) -> str:
if provider.api_key_env is None:
raise LookupError(f'Search provider {provider.name} does not define an API key env var.')
value = os.environ.get(provider.api_key_env)
if not isinstance(value, str) or not value.strip():
raise LookupError(
f'Search provider {provider.name} requires env var {provider.api_key_env}.'
)
return value.strip()
def _matches_domains(url: str, domains: tuple[str, ...]) -> bool:
hostname = parse.urlparse(url).hostname or ''
hostname = hostname.lower()
for domain in domains:
normalized = domain.strip().lower()
if not normalized:
continue
if hostname == normalized or hostname.endswith('.' + normalized):
return True
return False
+58 -6
View File
@@ -5,16 +5,27 @@ from datetime import datetime, timezone
from typing import Any
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
VALID_TASK_STATUSES = (
'pending',
'in_progress',
'completed',
'blocked',
'cancelled',
)
@dataclass(frozen=True)
class PortingTask:
task_id: str
title: str
status: str = 'todo'
status: str = 'pending'
description: str | None = None
priority: str | None = None
active_form: str | None = None
owner: str | None = None
blocks: tuple[str, ...] = ()
blocked_by: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
@@ -29,6 +40,11 @@ class PortingTask:
'status': self.status,
'description': self.description,
'priority': self.priority,
'active_form': self.active_form,
'owner': self.owner,
'blocks': list(self.blocks),
'blocked_by': list(self.blocked_by),
'metadata': dict(self.metadata),
'created_at': self.created_at,
'updated_at': self.updated_at,
}
@@ -49,6 +65,25 @@ class PortingTask:
if isinstance(payload.get('priority'), str)
else None
),
active_form=(
str(payload.get('active_form'))
if isinstance(payload.get('active_form'), str)
and payload.get('active_form')
else None
),
owner=(
str(payload.get('owner'))
if isinstance(payload.get('owner'), str)
and payload.get('owner')
else None
),
blocks=_normalize_string_tuple(payload.get('blocks')),
blocked_by=_normalize_string_tuple(payload.get('blocked_by')),
metadata=(
dict(payload.get('metadata'))
if isinstance(payload.get('metadata'), dict)
else {}
),
created_at=(
str(payload.get('created_at'))
if isinstance(payload.get('created_at'), str)
@@ -68,11 +103,28 @@ def _normalize_task_status(value: Any) -> str:
aliases = {
'in-progress': 'in_progress',
'in progress': 'in_progress',
'complete': 'done',
'completed': 'done',
'open': 'todo',
'complete': 'completed',
'done': 'completed',
'todo': 'pending',
'open': 'pending',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'
return 'pending'
def _normalize_string_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in value:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
+328 -11
View File
@@ -12,6 +12,8 @@ from .task import PortingTask, VALID_TASK_STATUSES
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
ACTIONABLE_TASK_STATUSES = ('pending', 'in_progress')
TERMINAL_TASK_STATUSES = ('completed', 'cancelled')
@dataclass(frozen=True)
@@ -57,16 +59,38 @@ class TaskRuntime:
self,
*,
status: str | None = None,
owner: str | None = None,
actionable_only: bool = False,
limit: int | None = None,
) -> tuple[PortingTask, ...]:
tasks = self.tasks
if status:
normalized = _normalize_status(status)
tasks = tuple(task for task in tasks if task.status == normalized)
if owner:
tasks = tuple(task for task in tasks if task.owner == owner)
if actionable_only:
actionable_ids = {task.task_id for task in self.next_tasks(limit=None)}
tasks = tuple(task for task in tasks if task.task_id in actionable_ids)
tasks = tuple(_sort_tasks(tasks))
if limit is not None and limit >= 0:
tasks = tasks[:limit]
return tasks
def next_tasks(self, *, limit: int | None = 10) -> tuple[PortingTask, ...]:
tasks_by_id = {task.task_id: task for task in self.tasks}
actionable: list[PortingTask] = []
for task in self.tasks:
if task.status not in ACTIONABLE_TASK_STATUSES:
continue
unresolved = _unresolved_dependencies(task, tasks_by_id)
if task.status == 'in_progress' or not unresolved:
actionable.append(task)
actionable = list(_sort_tasks(actionable))
if limit is not None and limit >= 0:
actionable = actionable[:limit]
return tuple(actionable)
def get_task(self, task_id: str) -> PortingTask | None:
for task in self.tasks:
if task.task_id == task_id:
@@ -78,9 +102,14 @@ class TaskRuntime:
*,
title: str,
description: str | None = None,
status: str = 'todo',
status: str = 'pending',
priority: str | None = None,
task_id: str | None = None,
active_form: str | None = None,
owner: str | None = None,
blocks: tuple[str, ...] | list[str] = (),
blocked_by: tuple[str, ...] | list[str] = (),
metadata: dict[str, Any] | None = None,
) -> TaskMutation:
task = PortingTask(
task_id=task_id or f'task_{uuid4().hex[:10]}',
@@ -88,6 +117,15 @@ class TaskRuntime:
description=description.strip() if isinstance(description, str) and description.strip() else None,
status=_normalize_status(status),
priority=priority.strip() if isinstance(priority, str) and priority.strip() else None,
active_form=(
active_form.strip()
if isinstance(active_form, str) and active_form.strip()
else None
),
owner=owner.strip() if isinstance(owner, str) and owner.strip() else None,
blocks=_normalize_id_list(blocks),
blocked_by=_normalize_id_list(blocked_by),
metadata=dict(metadata or {}),
)
return self._persist((*self.tasks, task), task=task)
@@ -99,10 +137,19 @@ class TaskRuntime:
description: str | None = None,
status: str | None = None,
priority: str | None = None,
active_form: str | None = None,
owner: str | None = None,
blocks: tuple[str, ...] | list[str] | None = None,
blocked_by: tuple[str, ...] | list[str] | None = None,
metadata: dict[str, Any] | None = None,
merge_metadata: bool = False,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
updated_metadata = dict(existing.metadata)
if metadata is not None:
updated_metadata = {**updated_metadata, **metadata} if merge_metadata else dict(metadata)
updated = replace(
existing,
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
@@ -119,11 +166,133 @@ class TaskRuntime:
else None if priority == ''
else existing.priority
),
active_form=(
active_form.strip()
if isinstance(active_form, str) and active_form.strip()
else None if active_form == ''
else existing.active_form
),
owner=(
owner.strip()
if isinstance(owner, str) and owner.strip()
else None if owner == ''
else existing.owner
),
blocks=(
_normalize_id_list(blocks)
if blocks is not None
else existing.blocks
),
blocked_by=(
_normalize_id_list(blocked_by)
if blocked_by is not None
else existing.blocked_by
),
metadata=updated_metadata,
updated_at=datetime.now(timezone.utc).isoformat(),
)
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
return self._persist(tasks, task=updated)
def start_task(
self,
task_id: str,
*,
owner: str | None = None,
active_form: str | None = None,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
unresolved = _unresolved_dependencies(existing, {task.task_id: task for task in self.tasks})
metadata = dict(existing.metadata)
if unresolved:
metadata['blocked_reason'] = f'waiting_on:{",".join(unresolved)}'
return self.update_task(
task_id,
status='blocked',
owner=owner if owner is not None else existing.owner,
active_form=active_form if active_form is not None else existing.active_form,
metadata=metadata,
)
if 'blocked_reason' in metadata:
metadata.pop('blocked_reason')
return self.update_task(
task_id,
status='in_progress',
owner=owner if owner is not None else existing.owner,
active_form=active_form if active_form is not None else existing.active_form,
metadata=metadata,
)
def complete_task(self, task_id: str) -> TaskMutation:
mutation = self.update_task(task_id, status='completed')
completed_ids = {task.task_id for task in self.tasks if task.status in TERMINAL_TASK_STATUSES}
updated_tasks: list[PortingTask] = []
changed = False
for task in self.tasks:
if task.status != 'blocked':
updated_tasks.append(task)
continue
unresolved = tuple(
dependency
for dependency in task.blocked_by
if dependency not in completed_ids
)
if unresolved:
updated_tasks.append(task)
continue
metadata = dict(task.metadata)
metadata.pop('blocked_reason', None)
updated_tasks.append(
replace(
task,
status='pending',
metadata=metadata,
updated_at=datetime.now(timezone.utc).isoformat(),
)
)
changed = True
if changed:
return self._persist(tuple(updated_tasks), task=self.get_task(task_id))
return mutation
def block_task(
self,
task_id: str,
*,
blocked_by: tuple[str, ...] | list[str] | None = None,
reason: str | None = None,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
merged_blocked_by = tuple(existing.blocked_by)
if blocked_by is not None:
merged_blocked_by = _merge_ids(existing.blocked_by, _normalize_id_list(blocked_by))
metadata = dict(existing.metadata)
if isinstance(reason, str) and reason.strip():
metadata['blocked_reason'] = reason.strip()
return self.update_task(
task_id,
status='blocked',
blocked_by=merged_blocked_by,
metadata=metadata,
)
def cancel_task(self, task_id: str, *, reason: str | None = None) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
metadata = dict(existing.metadata)
if isinstance(reason, str) and reason.strip():
metadata['cancel_reason'] = reason.strip()
return self.update_task(
task_id,
status='cancelled',
metadata=metadata,
)
def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
tasks: list[PortingTask] = []
now = datetime.now(timezone.utc).isoformat()
@@ -157,6 +326,25 @@ class TaskRuntime:
and item.get('priority').strip()
else None
),
active_form=(
item.get('active_form').strip()
if isinstance(item.get('active_form'), str)
and item.get('active_form').strip()
else None
),
owner=(
item.get('owner').strip()
if isinstance(item.get('owner'), str)
and item.get('owner').strip()
else None
),
blocks=_normalize_id_list(item.get('blocks', [])),
blocked_by=_normalize_id_list(item.get('blocked_by', [])),
metadata=(
dict(item.get('metadata'))
if isinstance(item.get('metadata'), dict)
else {}
),
created_at=(
str(item.get('created_at'))
if isinstance(item.get('created_at'), str)
@@ -181,26 +369,55 @@ class TaskRuntime:
'- Status counts: '
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
)
actionable = self.next_tasks(limit=None)
if actionable:
lines.append(f'- Actionable tasks: {len(actionable)}')
blocked = [task for task in self.tasks if task.status == 'blocked']
if blocked:
lines.append(f'- Blocked tasks: {len(blocked)}')
if self.tasks:
preview = ', '.join(task.title for task in self.tasks[:4])
preview = ', '.join(task.title for task in _sort_tasks(self.tasks)[:4])
if len(self.tasks) > 4:
preview += f', ... (+{len(self.tasks) - 4} more)'
lines.append(f'- Task preview: {preview}')
return '\n'.join(lines)
def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str:
tasks = self.list_tasks(status=status, limit=limit)
def render_tasks(
self,
*,
status: str | None = None,
owner: str | None = None,
actionable_only: bool = False,
limit: int = 50,
) -> str:
tasks = self.list_tasks(
status=status,
owner=owner,
actionable_only=actionable_only,
limit=limit,
)
if not tasks:
return '# Tasks\n\nNo tasks are currently stored.'
lines = ['# Tasks', '']
if actionable_only:
lines.append('Showing actionable tasks only.')
lines.append('')
for task in tasks:
details = [task.task_id, f'status={task.status}']
if task.priority:
details.append(f'priority={task.priority}')
if task.owner:
details.append(f'owner={task.owner}')
details.append(f'title={task.title}')
lines.append('- ' + '; '.join(details))
if task.description:
lines.append(f' description: {task.description}')
if task.active_form:
lines.append(f' active_form: {task.active_form}')
if task.blocked_by:
lines.append(f" blocked_by: {', '.join(task.blocked_by)}")
if task.blocks:
lines.append(f" blocks: {', '.join(task.blocks)}")
return '\n'.join(lines)
def render_task(self, task_id: str) -> str:
@@ -216,22 +433,52 @@ class TaskRuntime:
]
if task.priority:
lines.append(f'- Priority: {task.priority}')
if task.owner:
lines.append(f'- Owner: {task.owner}')
if task.active_form:
lines.append(f'- Active Form: {task.active_form}')
if task.description:
lines.append(f'- Description: {task.description}')
if task.blocked_by:
lines.append(f"- Blocked By: {', '.join(task.blocked_by)}")
if task.blocks:
lines.append(f"- Blocks: {', '.join(task.blocks)}")
if task.metadata:
lines.append('- Metadata:')
for key, value in sorted(task.metadata.items()):
lines.append(f' - {key}={value}')
lines.append(f'- Updated: {task.updated_at}')
return '\n'.join(lines)
def render_next_tasks(self, *, limit: int = 10) -> str:
tasks = self.next_tasks(limit=limit)
if not tasks:
return '# Next Tasks\n\nNo actionable tasks are currently available.'
lines = ['# Next Tasks', '']
for task in tasks:
details = [task.task_id, f'status={task.status}', f'title={task.title}']
if task.owner:
details.append(f'owner={task.owner}')
lines.append('- ' + '; '.join(details))
unresolved = _unresolved_dependencies(task, {item.task_id: item for item in self.tasks})
if unresolved:
lines.append(f" unresolved_dependencies: {', '.join(unresolved)}")
if task.active_form:
lines.append(f' active_form: {task.active_form}')
return '\n'.join(lines)
def _persist(
self,
tasks: tuple[PortingTask, ...],
*,
task: PortingTask | None,
) -> TaskMutation:
before_text = self._serialize_payload(self.tasks)
before_tasks = self.tasks
before_text = self._serialize_payload(before_tasks)
before_preview = _snapshot_text(before_text)
before_sha256 = (
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
if self.storage_path.exists() or self.tasks
if self.storage_path.exists() or before_tasks
else None
)
payload_text = self._serialize_payload(tasks)
@@ -246,7 +493,7 @@ class TaskRuntime:
after_sha256=after_sha256,
before_preview=before_preview if before_text.strip() else None,
after_preview=_snapshot_text(payload_text),
before_count=len(json.loads(before_text).get('tasks', [])) if before_text.strip() else 0,
before_count=len(before_tasks),
after_count=len(tasks),
)
@@ -268,11 +515,81 @@ def _normalize_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
aliases = {
'complete': 'done',
'completed': 'done',
'open': 'todo',
'complete': 'completed',
'done': 'completed',
'todo': 'pending',
'open': 'pending',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'
return 'pending'
def _normalize_id_list(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, tuple):
items = list(value)
elif isinstance(value, list):
items = value
else:
return ()
normalized: list[str] = []
seen: set[str] = set()
for item in items:
if not isinstance(item, str):
continue
text = item.strip()
if not text or text in seen:
continue
normalized.append(text)
seen.add(text)
return tuple(normalized)
def _merge_ids(existing: tuple[str, ...], additions: tuple[str, ...]) -> tuple[str, ...]:
merged = list(existing)
seen = set(existing)
for item in additions:
if item in seen:
continue
merged.append(item)
seen.add(item)
return tuple(merged)
def _unresolved_dependencies(
task: PortingTask,
tasks_by_id: dict[str, PortingTask],
) -> tuple[str, ...]:
unresolved: list[str] = []
for dependency_id in task.blocked_by:
dependency = tasks_by_id.get(dependency_id)
if dependency is None:
unresolved.append(dependency_id)
continue
if dependency.status not in TERMINAL_TASK_STATUSES:
unresolved.append(dependency_id)
return tuple(unresolved)
def _task_sort_key(task: PortingTask) -> tuple[int, int, str, str]:
status_rank = {
'in_progress': 0,
'pending': 1,
'blocked': 2,
'completed': 3,
'cancelled': 4,
}.get(task.status, 9)
priority_rank = {
'critical': 0,
'high': 1,
'medium': 2,
'low': 3,
}.get((task.priority or '').lower(), 9)
return (status_rank, priority_rank, task.title.lower(), task.task_id)
def _sort_tasks(tasks: tuple[PortingTask, ...] | list[PortingTask]) -> tuple[PortingTask, ...]:
return tuple(sorted(tasks, key=_task_sort_key))
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import math
import os
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Callable
@dataclass(frozen=True)
class TokenCounterInfo:
backend: str
source: str
accurate: bool
@dataclass(frozen=True)
class ResolvedTokenCounter:
info: TokenCounterInfo
count_text: Callable[[str], int]
def count_tokens(text: str, model: str | None = None) -> int:
counter = resolve_token_counter(model)
return counter.count_text(text)
def describe_token_counter(model: str | None = None) -> TokenCounterInfo:
return resolve_token_counter(model).info
def resolve_token_counter(model: str | None = None) -> ResolvedTokenCounter:
return _resolve_token_counter(
_normalize_model(model),
_normalize_env('CLAW_CODE_TOKENIZER_PATH'),
_normalize_env('CLAW_CODE_TOKENIZER_MODEL'),
_normalize_env('CLAW_CODE_TOKENIZER_TRUST_REMOTE_CODE'),
)
def clear_token_counter_cache() -> None:
_resolve_token_counter.cache_clear()
@lru_cache(maxsize=64)
def _resolve_token_counter(
normalized_model: str | None,
explicit_path: str | None,
explicit_model: str | None,
trust_remote_code: str | None,
) -> ResolvedTokenCounter:
transformer_ref = explicit_path or explicit_model or normalized_model
if _prefer_tiktoken(normalized_model):
counter = _try_build_tiktoken_counter(normalized_model)
if counter is not None:
return counter
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
if counter is not None:
return counter
else:
counter = _try_build_transformers_counter(transformer_ref, trust_remote_code)
if counter is not None:
return counter
counter = _try_build_tiktoken_counter(normalized_model)
if counter is not None:
return counter
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='heuristic',
source='len(text)/4 fallback',
accurate=False,
),
count_text=_heuristic_count,
)
def _normalize_model(model: str | None) -> str | None:
if not isinstance(model, str):
return None
normalized = model.strip()
return normalized or None
def _normalize_env(name: str) -> str | None:
value = os.environ.get(name)
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _prefer_tiktoken(model: str | None) -> bool:
if model is None:
return False
lowered = model.lower()
return (
lowered.startswith('gpt')
or lowered.startswith('o1')
or lowered.startswith('o3')
or lowered.startswith('o4')
or 'gpt-4' in lowered
or 'gpt-5' in lowered
or 'openai' in lowered
)
def _try_build_tiktoken_counter(model: str | None) -> ResolvedTokenCounter | None:
if model is None:
return None
try:
import tiktoken
except ImportError:
return None
encoding = None
encoding_name = None
try:
encoding = tiktoken.encoding_for_model(model)
encoding_name = getattr(encoding, 'name', model)
except KeyError:
fallback_name = _tiktoken_fallback_encoding(model)
if fallback_name is None:
return None
encoding = tiktoken.get_encoding(fallback_name)
encoding_name = fallback_name
except Exception:
return None
def _count(text: str) -> int:
if not text:
return 0
return len(encoding.encode_ordinary(text))
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='tiktoken',
source=encoding_name or 'unknown',
accurate=True,
),
count_text=_count,
)
def _tiktoken_fallback_encoding(model: str) -> str | None:
lowered = model.lower()
if lowered.startswith('gpt') or lowered.startswith('o1') or lowered.startswith('o3') or lowered.startswith('o4'):
return 'o200k_base'
if 'gpt-3.5' in lowered or 'gpt-4' in lowered:
return 'cl100k_base'
return None
def _try_build_transformers_counter(
model_ref: str | None,
trust_remote_code: str | None,
) -> ResolvedTokenCounter | None:
if model_ref is None:
return None
try:
from transformers import AutoTokenizer
except ImportError:
return None
trust_remote_code_enabled = isinstance(trust_remote_code, str) and trust_remote_code.lower() in {
'1',
'true',
'yes',
'on',
}
tokenizer = None
try:
tokenizer = AutoTokenizer.from_pretrained(
model_ref,
local_files_only=True,
use_fast=True,
trust_remote_code=trust_remote_code_enabled,
)
except Exception:
return None
def _count(text: str) -> int:
if not text:
return 0
encoded: Any = tokenizer.encode(text, add_special_tokens=False)
return len(encoded)
return ResolvedTokenCounter(
info=TokenCounterInfo(
backend='transformers',
source=f'{model_ref} (local_files_only)',
accurate=True,
),
count_text=_count,
)
def _heuristic_count(text: str) -> int:
if not text:
return 0
return max(1, math.ceil(len(text) / 4))