add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/background_runtime.py. The agent now has a real

persistent plan runtime with update_plan, plan_get, and plan_clear, plus plan-to-task sync wired through claw-code-agent/src/
  agent_tools.py, claw-code-agent/src/agent_runtime.py, claw-code-agent/src/agent_context.py, claw-code-agent/src/agent_prompting.py, and claw-code-agent/src/
  agent_slash_commands.py. New local plan slash commands are /plan and /planner.
This commit is contained in:
Abdelrahman Abdallah
2026-04-03 18:31:25 +02:00
parent 045413e5e1
commit 3f31cee395
23 changed files with 4344 additions and 93 deletions
+11
View File
@@ -13,14 +13,19 @@ from .agent_runtime import LocalCodingAgent
from .agent_session import AgentMessage, AgentSessionState
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 .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 .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 .tools import PORTED_TOOLS, build_tool_backlog
__all__ = [
@@ -31,15 +36,21 @@ __all__ = [
'AgentRuntimeConfig',
'AgentMessage',
'AgentSessionState',
'BackgroundSessionRuntime',
'LocalCodingAgent',
'MCPRuntime',
'ModelConfig',
'ParityAuditResult',
'PlanRuntime',
'PlanStep',
'PortManifest',
'PortRuntime',
'PluginRuntime',
'PortingTask',
'QueryEnginePort',
'RuntimeSession',
'StoredSession',
'TaskRuntime',
'TurnResult',
'PORTED_COMMANDS',
'PORTED_TOOLS',
+33
View File
@@ -9,7 +9,11 @@ from functools import lru_cache
from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .task_runtime import TaskRuntime
from .agent_types import AgentRuntimeConfig
MAX_STATUS_CHARS = 2000
@@ -197,6 +201,35 @@ def _get_user_context_cached(
plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories)
if plugin_runtime.manifests:
context['pluginRuntime'] = plugin_runtime.render_summary()
hook_policy_runtime = HookPolicyRuntime.from_workspace(Path(cwd), additional_working_directories)
if hook_policy_runtime.manifests:
context['hookPolicy'] = hook_policy_runtime.render_summary()
managed_settings = hook_policy_runtime.managed_settings()
if managed_settings:
context['managedSettings'] = '\n'.join(
f'{key}={value}'
for key, value in sorted(managed_settings.items())
)
safe_env = hook_policy_runtime.safe_env()
if safe_env:
context['safeEnv'] = '\n'.join(
f'{key}={value}'
for key, value in sorted(safe_env.items())
)
context['trustMode'] = (
'Workspace trust mode: trusted'
if hook_policy_runtime.is_trusted()
else 'Workspace trust mode: untrusted'
)
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
if mcp_runtime.resources:
context['mcpRuntime'] = mcp_runtime.render_summary()
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
if plan_runtime.steps:
context['planRuntime'] = plan_runtime.render_summary()
task_runtime = TaskRuntime.from_workspace(Path(cwd))
if task_runtime.tasks:
context['taskRuntime'] = task_runtime.render_summary()
return context
+54
View File
@@ -91,6 +91,10 @@ def build_system_prompt_parts(
get_actions_section(),
get_using_your_tools_section(enabled_tool_names),
get_plugin_guidance_section(prompt_context),
get_mcp_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context),
get_tone_and_style_section(),
get_output_efficiency_section(),
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
@@ -207,6 +211,56 @@ def get_plugin_guidance_section(prompt_context: PromptContext) -> str:
return '\n'.join(['# Plugins', *prepend_bullets(items)])
def get_hook_policy_guidance_section(prompt_context: PromptContext) -> str:
hook_policy = prompt_context.user_context.get('hookPolicy')
trust_mode = prompt_context.user_context.get('trustMode')
if not hook_policy and not trust_mode:
return ''
items = [
'Workspace hook and policy manifests may inject trust mode, safe environment values, tool deny rules, and managed settings.',
'Treat workspace trust mode as high-priority local runtime guidance when deciding whether to edit files or run shell commands.',
'If a workspace policy blocks a tool, do not retry it unchanged. Change approach or explain the limitation.',
]
return '\n'.join(['# Hook Policy', *prepend_bullets(items)])
def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
mcp_runtime = prompt_context.user_context.get('mcpRuntime')
if not mcp_runtime:
return ''
items = [
'Local MCP manifests may expose additional resources 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.',
]
return '\n'.join(['# MCP', *prepend_bullets(items)])
def get_task_guidance_section(prompt_context: PromptContext) -> str:
task_runtime = prompt_context.user_context.get('taskRuntime')
if not task_runtime:
return ''
items = [
'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.',
]
return '\n'.join(['# Tasks', *prepend_bullets(items)])
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
plan_runtime = prompt_context.user_context.get('planRuntime')
if not plan_runtime:
return ''
items = [
'A local runtime plan may be available to track the active multi-step workflow.',
'Use the update_plan tool to keep the stored plan current when the task spans multiple milestones.',
'When the plan changes materially, update the stored plan rather than relying only on free-form progress text.',
'Plan updates can sync into the local task runtime, so keep step statuses accurate.',
]
return '\n'.join(['# Planning', *prepend_bullets(items)])
def get_output_efficiency_section() -> str:
return """# Communicating with the user
+386 -16
View File
@@ -10,6 +10,8 @@ from uuid import uuid4
from .agent_manager import AgentManager
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 .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
from .agent_prompting import (
build_prompt_context,
build_system_prompt_parts,
@@ -38,7 +40,9 @@ from .agent_types import (
UsageStats,
)
from .openai_compat import OpenAICompatClient, OpenAICompatError
from .plan_runtime import PlanRuntime
from .plugin_runtime import PluginRuntime
from .task_runtime import TaskRuntime
from .session_store import (
StoredAgentSession,
load_agent_session,
@@ -69,6 +73,10 @@ class LocalCodingAgent:
managed_child_index: int | None = None
managed_label: str | None = None
plugin_runtime: PluginRuntime | None = None
hook_policy_runtime: HookPolicyRuntime | None = None
mcp_runtime: MCPRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
active_session_id: str | None = field(default=None, init=False, repr=False)
@@ -86,6 +94,21 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.hook_policy_runtime is None:
self.hook_policy_runtime = HookPolicyRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.mcp_runtime is None:
self.mcp_runtime = MCPRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
registry = dict(self.tool_registry)
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
if plugin_tools:
@@ -95,7 +118,17 @@ class LocalCodingAgent:
registry = {**registry, **virtual_tools}
self.tool_registry = registry
self.client = OpenAICompatClient(self.model_config)
self.tool_context = build_tool_context(self.runtime_config)
self.tool_context = build_tool_context(
self.runtime_config,
extra_env=(
self.hook_policy_runtime.safe_env()
if self.hook_policy_runtime is not None
else None
),
mcp_runtime=self.mcp_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
)
def set_model(self, model: str) -> None:
self.model_config = replace(self.model_config, model=model)
@@ -144,6 +177,67 @@ class LocalCodingAgent:
system_context=prompt_context.system_context,
)
def _apply_hook_policy_budget_overrides(
self,
runtime_config: AgentRuntimeConfig,
) -> AgentRuntimeConfig:
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
return runtime_config
overrides = self.hook_policy_runtime.budget_overrides()
if not overrides:
return runtime_config
budget = runtime_config.budget_config
return replace(
runtime_config,
budget_config=BudgetConfig(
max_total_tokens=(
budget.max_total_tokens
if budget.max_total_tokens is not None
else _optional_policy_int(overrides.get('max_total_tokens'))
),
max_input_tokens=(
budget.max_input_tokens
if budget.max_input_tokens is not None
else _optional_policy_int(overrides.get('max_input_tokens'))
),
max_output_tokens=(
budget.max_output_tokens
if budget.max_output_tokens is not None
else _optional_policy_int(overrides.get('max_output_tokens'))
),
max_reasoning_tokens=(
budget.max_reasoning_tokens
if budget.max_reasoning_tokens is not None
else _optional_policy_int(overrides.get('max_reasoning_tokens'))
),
max_total_cost_usd=(
budget.max_total_cost_usd
if budget.max_total_cost_usd is not None
else _optional_policy_float(overrides.get('max_total_cost_usd'))
),
max_tool_calls=(
budget.max_tool_calls
if budget.max_tool_calls is not None
else _optional_policy_int(overrides.get('max_tool_calls'))
),
max_delegated_tasks=(
budget.max_delegated_tasks
if budget.max_delegated_tasks is not None
else _optional_policy_int(overrides.get('max_delegated_tasks'))
),
max_model_calls=(
budget.max_model_calls
if budget.max_model_calls is not None
else _optional_policy_int(overrides.get('max_model_calls'))
),
max_session_turns=(
budget.max_session_turns
if budget.max_session_turns is not None
else _optional_policy_int(overrides.get('max_session_turns'))
),
),
)
def run(self, prompt: str) -> AgentRunResult:
self.managed_agent_id = None
self.resume_source_session_id = None
@@ -220,7 +314,10 @@ class LocalCodingAgent:
),
)
effective_prompt = self._apply_plugin_before_prompt_hooks(slash_result.prompt or prompt)
effective_prompt = self._apply_hook_policy_before_prompt_hooks(
slash_result.prompt or prompt
)
effective_prompt = self._apply_plugin_before_prompt_hooks(effective_prompt)
effective_prompt = self._apply_plugin_resume_hooks(
effective_prompt,
resumed=base_session is not None,
@@ -431,7 +528,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=turn_index,
@@ -512,7 +609,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=turn_index,
@@ -583,6 +680,9 @@ class LocalCodingAgent:
if self.plugin_runtime is not None:
self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False)
plugin_preflight_messages = self._plugin_tool_preflight_messages(tool_call.name)
policy_preflight_messages = self._hook_policy_tool_preflight_messages(
tool_call.name
)
if plugin_preflight_messages:
stream_events.append(
{
@@ -593,7 +693,18 @@ class LocalCodingAgent:
'message_count': len(plugin_preflight_messages),
}
)
if policy_preflight_messages:
stream_events.append(
{
'type': 'hook_policy_tool_preflight',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message_count': len(policy_preflight_messages),
}
)
plugin_block_message = self._plugin_block_message(tool_call.name)
policy_block_message = self._hook_policy_block_message(tool_call.name)
if plugin_block_message is not None:
if self.plugin_runtime is not None:
blocked_attempts = int(
@@ -621,6 +732,27 @@ class LocalCodingAgent:
'message': plugin_block_message,
}
)
if policy_block_message is not None:
tool_result = ToolExecutionResult(
name=tool_call.name,
ok=False,
content=policy_block_message,
metadata={
'action': 'hook_policy_block',
'hook_policy_blocked': True,
'hook_policy_block_message': policy_block_message,
'error_kind': 'permission_denied',
},
)
stream_events.append(
{
'type': 'hook_policy_tool_block',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message': policy_block_message,
}
)
if tool_call.name == 'delegate_agent':
if tool_result is None:
tool_result = self._execute_delegate_agent(tool_call.arguments)
@@ -658,6 +790,7 @@ class LocalCodingAgent:
metadata=tool_result.metadata,
)
plugin_messages = self._plugin_tool_result_messages(tool_call.name)
policy_messages = self._hook_policy_tool_result_messages(tool_call.name)
if plugin_messages:
merged_metadata = dict(tool_result.metadata)
merged_metadata['plugin_messages'] = list(plugin_messages)
@@ -677,12 +810,47 @@ class LocalCodingAgent:
'message': message,
}
)
if policy_messages:
merged_metadata = dict(tool_result.metadata)
merged_metadata['hook_policy_messages'] = list(policy_messages)
tool_result = ToolExecutionResult(
name=tool_result.name,
ok=tool_result.ok,
content=tool_result.content,
metadata=merged_metadata,
)
for message in policy_messages:
stream_events.append(
{
'type': 'hook_policy_tool_hook',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'message': message,
}
)
if tool_result.metadata.get('error_kind') == 'permission_denied':
stream_events.append(
{
'type': 'tool_permission_denial',
'tool_name': tool_call.name,
'tool_call_id': tool_call.id,
'message_id': session.messages[tool_message_index].message_id,
'reason': tool_result.content,
'source': (
'hook_policy'
if tool_result.metadata.get('action') == 'hook_policy_block'
else 'tool_runtime'
),
}
)
session.finalize_tool(
tool_message_index,
content=serialize_tool_result(tool_result),
metadata={
'phase': 'completed',
'plugin_preflight_messages': list(plugin_preflight_messages),
'hook_policy_preflight_messages': list(policy_preflight_messages),
**dict(tool_result.metadata),
},
stop_reason='tool_completed',
@@ -707,6 +875,9 @@ class LocalCodingAgent:
preflight_messages=plugin_preflight_messages,
block_message=plugin_block_message,
plugin_messages=plugin_messages,
hook_policy_preflight_messages=policy_preflight_messages,
hook_policy_block_message=policy_block_message,
hook_policy_messages=policy_messages,
delegate_preflight_messages=tuple(
message
for message in tool_result.metadata.get(
@@ -774,7 +945,7 @@ class LocalCodingAgent:
str(scratchpad_directory) if scratchpad_directory is not None else None
),
)
result = self._append_plugin_after_turn_events(
result = self._append_runtime_after_turn_events(
result,
prompt=effective_prompt,
turn_index=self.runtime_config.max_turns,
@@ -2381,6 +2552,39 @@ class LocalCodingAgent:
)
return '\n'.join(lines)
def _apply_hook_policy_before_prompt_hooks(self, prompt: str) -> str:
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
return prompt
injections = self.hook_policy_runtime.before_prompt_messages()
managed_settings = self.hook_policy_runtime.managed_settings()
safe_env = self.hook_policy_runtime.safe_env()
trusted = self.hook_policy_runtime.is_trusted()
if not injections and not managed_settings and not safe_env and trusted:
return prompt
lines = ['<system-reminder>', 'Workspace hook/policy guidance:']
lines.append(
f'- Trust mode: {"trusted" if trusted else "untrusted"}'
)
if not trusted:
lines.append(
'- Untrusted workspaces should favor inspection-first behavior. '
'Avoid unnecessary writes or shell actions unless the task clearly requires them.'
)
for entry in injections:
lines.append(f'- Before prompt: {entry}')
if managed_settings:
lines.append(
'- Managed settings: '
+ ', '.join(f'{key}={value}' for key, value in sorted(managed_settings.items()))
)
if safe_env:
lines.append(
'- Safe environment values loaded for tools: '
+ ', '.join(sorted(safe_env))
)
lines.extend(['</system-reminder>', '', prompt])
return '\n'.join(lines)
def _build_plugin_tool_runtime_message(
self,
*,
@@ -2388,6 +2592,9 @@ class LocalCodingAgent:
preflight_messages: tuple[str, ...],
block_message: str | None,
plugin_messages: tuple[str, ...],
hook_policy_preflight_messages: tuple[str, ...] = (),
hook_policy_block_message: str | None = None,
hook_policy_messages: tuple[str, ...] = (),
delegate_preflight_messages: tuple[str, ...] = (),
delegate_after_messages: tuple[str, ...] = (),
) -> str | None:
@@ -2395,28 +2602,46 @@ class LocalCodingAgent:
block_message is None
and not plugin_messages
and not preflight_messages
and hook_policy_block_message is None
and not hook_policy_preflight_messages
and not hook_policy_messages
and not delegate_preflight_messages
and not delegate_after_messages
):
return None
plugin_only = (
hook_policy_block_message is None
and not hook_policy_preflight_messages
and not hook_policy_messages
)
lines = [
'<system-reminder>',
f'Plugin tool runtime guidance for `{tool_name}`:',
(
f'Plugin tool runtime guidance for `{tool_name}`:'
if plugin_only
else f'Runtime tool guidance for `{tool_name}`:'
),
]
for message in preflight_messages:
lines.append(f'- Before tool: {message}')
for message in hook_policy_preflight_messages:
lines.append(f'- Hook/policy before tool: {message}')
for message in delegate_preflight_messages:
lines.append(f'- Before delegate: {message}')
if block_message is not None:
lines.append(f'- Blocked: {block_message}')
if hook_policy_block_message is not None:
lines.append(f'- Hook/policy blocked: {hook_policy_block_message}')
for message in plugin_messages:
lines.append(f'- After result: {message}')
for message in hook_policy_messages:
lines.append(f'- Hook/policy after result: {message}')
for message in delegate_after_messages:
lines.append(f'- After delegate: {message}')
lines.extend(
[
'',
'Use this plugin guidance when deciding the next tool call or assistant response.',
'Use this runtime guidance when deciding the next tool call or assistant response.',
'</system-reminder>',
]
)
@@ -2437,6 +2662,21 @@ class LocalCodingAgent:
return ()
return self.plugin_runtime.tool_result_injections(tool_name)
def _hook_policy_tool_preflight_messages(self, tool_name: str) -> tuple[str, ...]:
if self.hook_policy_runtime is None:
return ()
return self.hook_policy_runtime.before_tool_messages(tool_name)
def _hook_policy_block_message(self, tool_name: str) -> str | None:
if self.hook_policy_runtime is None:
return None
return self.hook_policy_runtime.denied_tool_message(tool_name)
def _hook_policy_tool_result_messages(self, tool_name: str) -> tuple[str, ...]:
if self.hook_policy_runtime is None:
return ()
return self.hook_policy_runtime.after_tool_messages(tool_name)
def _persist_session(
self,
session: AgentSessionState,
@@ -2546,15 +2786,27 @@ class LocalCodingAgent:
def render_permissions_report(self) -> str:
permissions = self.runtime_config.permissions
return '\n'.join(
[
'# Permissions',
'',
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}',
f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}',
]
)
lines = [
'# Permissions',
'',
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}',
f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}',
]
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
lines.append(
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
)
denied_tools = sorted(
{
name
for manifest in self.hook_policy_runtime.manifests
for name in manifest.deny_tools
}
)
if denied_tools:
lines.append('- Policy-denied tools: ' + ', '.join(denied_tools))
return '\n'.join(lines)
def render_tools_report(self) -> str:
permissions = self.runtime_config.permissions
@@ -2565,6 +2817,11 @@ class LocalCodingAgent:
state = 'blocked by permissions'
if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write:
state = 'blocked by permissions'
if (
self.hook_policy_runtime is not None
and self.hook_policy_runtime.denied_tool_message(tool.name) is not None
):
state = 'blocked by hook policy'
lines.append(f'- `{tool.name}`: {tool.description} [{state}]')
return '\n'.join(lines)
@@ -2575,6 +2832,64 @@ class LocalCodingAgent:
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
return '\n'.join(['# Memory', '', claude_md])
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.'
if query:
return self.mcp_runtime.render_resource_index(query=query)
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
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 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 self.mcp_runtime.render_resource(uri)
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_plan_report(self) -> str:
if self.plan_runtime is None:
return '# Plan\n\nNo local plan runtime is available.'
return self.plan_runtime.render_plan()
def render_task_report(self, task_id: str) -> str:
if self.task_runtime is None:
return '# Task\n\nNo local task runtime is available.'
return self.task_runtime.render_task(task_id)
def render_hook_policy_report(self) -> str:
if self.hook_policy_runtime is None:
return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
return '\n'.join(['# Hook Policy', '', self.hook_policy_runtime.render_summary()])
def render_trust_report(self) -> str:
trusted = True
settings: dict[str, Any] = {}
env_values: dict[str, str] = {}
if self.hook_policy_runtime is not None:
trusted = self.hook_policy_runtime.is_trusted()
settings = self.hook_policy_runtime.managed_settings()
env_values = self.hook_policy_runtime.safe_env()
lines = [
'# Trust',
'',
f'- Workspace trust mode: {"trusted" if trusted else "untrusted"}',
]
if settings:
lines.append('- Managed settings:')
lines.extend(f' - {key}={value}' for key, value in sorted(settings.items()))
if env_values:
lines.append('- Safe environment values:')
lines.extend(f' - {key}={value}' for key, value in sorted(env_values.items()))
return '\n'.join(lines)
def render_status_report(self) -> str:
lines = [
'# Status',
@@ -2585,6 +2900,16 @@ class LocalCodingAgent:
f'- Session ID: {self.active_session_id or "none"}',
f'- Last session loaded: {"yes" if self.last_session is not None else "no"}',
]
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
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.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:
lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}')
if self.last_session_path is not None:
lines.append(f'- Session path: {self.last_session_path}')
if self.last_run_result is not None:
@@ -2689,3 +3014,48 @@ class LocalCodingAgent:
}
)
return replace(result, events=tuple(appended))
def _append_runtime_after_turn_events(
self,
result: AgentRunResult,
*,
prompt: str,
turn_index: int,
) -> AgentRunResult:
updated = self._append_plugin_after_turn_events(
result,
prompt=prompt,
turn_index=turn_index,
)
if self.hook_policy_runtime is None:
return updated
injections = self.hook_policy_runtime.after_turn_messages()
if not injections:
return updated
appended = list(updated.events)
for entry in injections:
appended.append(
{
'type': 'hook_policy_after_turn',
'turn_index': turn_index,
'message': entry,
'prompt_preview': self._preview_text(prompt, 120),
'stop_reason': updated.stop_reason,
'trusted': self.hook_policy_runtime.is_trusted(),
}
)
return replace(updated, events=tuple(appended))
def _optional_policy_int(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _optional_policy_float(value: object) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
return None
+87 -8
View File
@@ -77,16 +77,16 @@ def preprocess_slash_command(
'Commands are in the form `/command [args]`.',
)
if parsed.is_mcp:
return _local_result(
input_text,
'MCP slash commands are not implemented in the Python runtime yet.',
)
spec = find_slash_command(parsed.command_name)
normalized_name = (
parsed.command_name[:-6]
if parsed.is_mcp and parsed.command_name.endswith(' (MCP)')
else parsed.command_name
)
spec = find_slash_command(normalized_name)
if spec is None:
if looks_like_command(parsed.command_name):
return _local_result(input_text, f'Unknown skill: {parsed.command_name}')
label = normalized_name if parsed.is_mcp else parsed.command_name
return _local_result(input_text, f'Unknown skill: {label}')
return SlashCommandResult(handled=False, should_query=True, prompt=input_text)
return spec.handler(agent, parsed.args.strip(), input_text)
@@ -109,6 +109,36 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the raw environment, user context, and system context snapshot.',
handler=_handle_context_raw,
),
SlashCommandSpec(
names=('mcp',),
description='Show discovered local MCP manifests and resource counts.',
handler=_handle_mcp,
),
SlashCommandSpec(
names=('resources',),
description='List local MCP resources, optionally filtered by a query string.',
handler=_handle_resources,
),
SlashCommandSpec(
names=('resource',),
description='Render a local MCP resource by URI.',
handler=_handle_resource,
),
SlashCommandSpec(
names=('tasks', 'todo'),
description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks,
),
SlashCommandSpec(
names=('plan', 'planner'),
description='Show the current local runtime plan.',
handler=_handle_plan,
),
SlashCommandSpec(
names=('task',),
description='Show a local runtime task by id.',
handler=_handle_task,
),
SlashCommandSpec(
names=('prompt', 'system-prompt'),
description='Render the effective Python system prompt.',
@@ -119,6 +149,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the active tool permission mode.',
handler=_handle_permissions,
),
SlashCommandSpec(
names=('hooks', 'policy'),
description='Show discovered local hook and policy manifests.',
handler=_handle_hooks,
),
SlashCommandSpec(
names=('trust',),
description='Show workspace trust mode, managed settings, and safe environment values.',
handler=_handle_trust,
),
SlashCommandSpec(
names=('model',),
description='Show or update the active model for the current agent instance.',
@@ -180,6 +220,37 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
return _local_result(input_text, agent.render_context_snapshot_report())
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_report(query))
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_mcp_resources_report(query))
def _handle_resource(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /resource <mcp-resource-uri>')
return _local_result(input_text, agent.render_mcp_resource_report(args))
def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
status = args or None
return _local_result(input_text, agent.render_tasks_report(status))
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_plan_report())
def _handle_task(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, 'Usage: /task <task-id>')
return _local_result(input_text, agent.render_task_report(args))
def _handle_prompt(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_system_prompt())
@@ -188,6 +259,14 @@ def _handle_permissions(agent: 'LocalCodingAgent', _args: str, input_text: str)
return _local_result(input_text, agent.render_permissions_report())
def _handle_hooks(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_hook_policy_report())
def _handle_trust(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_trust_report())
def _handle_model(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
if not args:
return _local_result(input_text, f'Current model: {agent.model_config.model}')
+485 -4
View File
@@ -2,16 +2,22 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import selectors
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterator, Union
from typing import TYPE_CHECKING, Any, Callable, Iterator, Union
from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult
if TYPE_CHECKING:
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .task_runtime import TaskRuntime
class ToolPermissionError(RuntimeError):
"""Raised when the runtime configuration does not allow a tool action."""
@@ -27,6 +33,10 @@ class ToolExecutionContext:
command_timeout_seconds: float
max_output_chars: int
permissions: AgentPermissions
extra_env: dict[str, str] = field(default_factory=dict)
mcp_runtime: 'MCPRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None
task_runtime: 'TaskRuntime | None' = None
ToolHandler = Callable[
@@ -60,8 +70,20 @@ class AgentTool:
else:
content, metadata = result, {}
return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata)
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
return ToolExecutionResult(name=self.name, ok=False, content=str(exc))
except ToolPermissionError as exc:
return ToolExecutionResult(
name=self.name,
ok=False,
content=str(exc),
metadata={'error_kind': 'permission_denied'},
)
except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
return ToolExecutionResult(
name=self.name,
ok=False,
content=str(exc),
metadata={'error_kind': 'tool_execution_error'},
)
@dataclass(frozen=True)
@@ -73,12 +95,23 @@ class ToolStreamUpdate:
metadata: dict[str, Any] = field(default_factory=dict)
def build_tool_context(config: AgentRuntimeConfig) -> ToolExecutionContext:
def build_tool_context(
config: AgentRuntimeConfig,
*,
extra_env: dict[str, str] | None = None,
mcp_runtime: 'MCPRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None,
task_runtime: 'TaskRuntime | None' = None,
) -> ToolExecutionContext:
return ToolExecutionContext(
root=config.cwd.resolve(),
command_timeout_seconds=config.command_timeout_seconds,
max_output_chars=config.max_output_chars,
permissions=config.permissions,
extra_env=dict(extra_env or {}),
mcp_runtime=mcp_runtime,
plan_runtime=plan_runtime,
task_runtime=task_runtime,
)
@@ -222,6 +255,159 @@ def default_tool_registry() -> dict[str, AgentTool]:
},
handler=_run_bash,
),
AgentTool(
name='mcp_list_resources',
description='List local MCP resources discovered from workspace MCP manifests.',
parameters={
'type': 'object',
'properties': {
'query': {'type': 'string'},
'max_resources': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_mcp_list_resources,
),
AgentTool(
name='mcp_read_resource',
description='Read a local MCP resource by URI from workspace MCP manifests.',
parameters={
'type': 'object',
'properties': {
'uri': {'type': 'string'},
'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 50000},
},
'required': ['uri'],
},
handler=_mcp_read_resource,
),
AgentTool(
name='plan_get',
description='Show the current local runtime plan.',
parameters={
'type': 'object',
'properties': {},
},
handler=_plan_get,
),
AgentTool(
name='update_plan',
description='Replace the current local runtime plan with a structured multi-step plan and optionally sync it to tasks.',
parameters={
'type': 'object',
'properties': {
'explanation': {'type': 'string'},
'sync_tasks': {'type': 'boolean'},
'items': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'step': {'type': 'string'},
'status': {'type': 'string'},
'task_id': {'type': 'string'},
'description': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['step'],
},
},
},
'required': ['items'],
},
handler=_update_plan,
),
AgentTool(
name='plan_clear',
description='Clear the current local runtime plan and optionally sync the task runtime.',
parameters={
'type': 'object',
'properties': {
'sync_tasks': {'type': 'boolean'},
},
},
handler=_plan_clear,
),
AgentTool(
name='task_list',
description='List locally stored runtime tasks.',
parameters={
'type': 'object',
'properties': {
'status': {'type': 'string'},
'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_task_list,
),
AgentTool(
name='task_get',
description='Show a locally stored runtime task by id.',
parameters={
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
},
'required': ['task_id'],
},
handler=_task_get,
),
AgentTool(
name='task_create',
description='Create a locally stored runtime task.',
parameters={
'type': 'object',
'properties': {
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
'task_id': {'type': 'string'},
},
'required': ['title'],
},
handler=_task_create,
),
AgentTool(
name='task_update',
description='Update a locally stored runtime task by id.',
parameters={
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['task_id'],
},
handler=_task_update,
),
AgentTool(
name='todo_write',
description='Replace the current local runtime task list with a structured todo list.',
parameters={
'type': 'object',
'properties': {
'items': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'task_id': {'type': 'string'},
'title': {'type': 'string'},
'description': {'type': 'string'},
'status': {'type': 'string'},
'priority': {'type': 'string'},
},
'required': ['title'],
},
},
},
'required': ['items'],
},
handler=_todo_write,
),
AgentTool(
name='delegate_agent',
description='Delegate a subtask to a nested Python coding agent and return its summary.',
@@ -531,6 +717,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
capture_output=True,
text=True,
timeout=context.command_timeout_seconds,
env=_build_subprocess_env(context),
)
stdout = completed.stdout or ''
stderr = completed.stderr or ''
@@ -554,6 +741,204 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
)
def _mcp_list_resources(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_mcp_runtime(context)
query = arguments.get('query')
if query is not None and not isinstance(query, str):
raise ToolExecutionError('query must be a string')
max_resources = _coerce_int(arguments, 'max_resources', 50)
resources = runtime.list_resources(query=query, limit=max_resources)
if not resources:
return '(no MCP resources)'
lines: list[str] = []
for resource in resources:
details = [resource.uri, f'server={resource.server_name}']
if resource.name:
details.append(f'name={resource.name}')
if resource.mime_type:
details.append(f'mime={resource.mime_type}')
if resource.resolved_path:
details.append(f'path={resource.resolved_path}')
lines.append(' ; '.join(details))
return '\n'.join(lines)
def _mcp_read_resource(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_mcp_runtime(context)
uri = _require_string(arguments, 'uri')
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
try:
content = runtime.read_resource(uri, max_chars=max_chars)
except FileNotFoundError as exc:
raise ToolExecutionError(str(exc)) from exc
return content
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context)
status = arguments.get('status')
if status is not None and not isinstance(status, str):
raise ToolExecutionError('status must be a string')
max_tasks = _coerce_int(arguments, 'max_tasks', 50)
return runtime.render_tasks(status=status, limit=max_tasks)
def _task_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context)
return runtime.render_task(_require_string(arguments, 'task_id'))
def _plan_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
del arguments
runtime = _require_plan_runtime(context)
return runtime.render_plan()
def _update_plan(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_plan_runtime(context)
items = arguments.get('items')
if not isinstance(items, list):
raise ToolExecutionError('items must be an array of plan step objects')
explanation = arguments.get('explanation')
if explanation is not None and not isinstance(explanation, str):
raise ToolExecutionError('explanation must be a string')
sync_tasks = arguments.get('sync_tasks', True)
if not isinstance(sync_tasks, bool):
raise ToolExecutionError('sync_tasks must be a boolean')
mutation = runtime.update_plan(
[item for item in items if isinstance(item, dict)],
explanation=explanation,
task_runtime=context.task_runtime,
sync_tasks=sync_tasks,
)
return (
f'updated plan with {mutation.after_count} step(s)',
_plan_mutation_metadata(
action='update_plan',
mutation=mutation,
total_steps=mutation.after_count,
sync_tasks=sync_tasks,
),
)
def _plan_clear(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_plan_runtime(context)
sync_tasks = arguments.get('sync_tasks', True)
if not isinstance(sync_tasks, bool):
raise ToolExecutionError('sync_tasks must be a boolean')
mutation = runtime.clear_plan(
task_runtime=context.task_runtime if sync_tasks else None,
)
return (
'cleared local plan',
_plan_mutation_metadata(
action='plan_clear',
mutation=mutation,
total_steps=0,
sync_tasks=sync_tasks,
),
)
def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
title = _require_string(arguments, 'title')
description = arguments.get('description')
status = arguments.get('status', 'todo')
priority = arguments.get('priority')
task_id = arguments.get('task_id')
if description is not None and not isinstance(description, str):
raise ToolExecutionError('description must be a string')
if status is not None and not isinstance(status, str):
raise ToolExecutionError('status must be a string')
if priority is not None and not isinstance(priority, str):
raise ToolExecutionError('priority must be a string')
if task_id is not None and not isinstance(task_id, str):
raise ToolExecutionError('task_id must be a string')
mutation = runtime.create_task(
title=title,
description=description,
status=status or 'todo',
priority=priority,
task_id=task_id,
)
task = mutation.task
assert task is not None
return (
f'created task {task.task_id}: {task.title} [{task.status}]',
_task_mutation_metadata(
action='task_create',
mutation=mutation,
task_id=task.task_id,
task_status=task.status,
total_tasks=mutation.after_count,
),
)
def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
task_id = _require_string(arguments, 'task_id')
title = arguments.get('title')
description = arguments.get('description')
status = arguments.get('status')
priority = arguments.get('priority')
for key, value in (
('title', title),
('description', description),
('status', status),
('priority', priority),
):
if value is not None and not isinstance(value, str):
raise ToolExecutionError(f'{key} must be a string')
try:
mutation = runtime.update_task(
task_id,
title=title,
description=description,
status=status,
priority=priority,
)
except KeyError as exc:
raise ToolExecutionError(f'Unknown task id: {task_id}') from exc
task = mutation.task
assert task is not None
return (
f'updated task {task.task_id}: {task.title} [{task.status}]',
_task_mutation_metadata(
action='task_update',
mutation=mutation,
task_id=task.task_id,
task_status=task.status,
total_tasks=mutation.after_count,
),
)
def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_task_runtime(context)
items = arguments.get('items')
if not isinstance(items, list):
raise ToolExecutionError('items must be an array of task objects')
mutation = runtime.replace_tasks(
[item for item in items if isinstance(item, dict)]
)
return (
f'replaced todo list with {mutation.after_count} task(s)',
_task_mutation_metadata(
action='todo_write',
mutation=mutation,
total_tasks=mutation.after_count,
),
)
def _stream_bash(
arguments: dict[str, Any],
context: ToolExecutionContext,
@@ -570,6 +955,7 @@ def _stream_bash(
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=_build_subprocess_env(context),
)
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
yield ToolStreamUpdate(
@@ -687,6 +1073,95 @@ def _delegate_agent_placeholder(
)
def _require_mcp_runtime(context: ToolExecutionContext):
if context.mcp_runtime is None or not context.mcp_runtime.resources:
raise ToolExecutionError(
'No local MCP resources are available. Add a .claw-mcp.json or .mcp.json manifest first.'
)
return context.mcp_runtime
def _require_plan_runtime(context: ToolExecutionContext):
if context.plan_runtime is None:
raise ToolExecutionError('Local plan runtime is not available.')
return context.plan_runtime
def _require_task_runtime(context: ToolExecutionContext):
if context.task_runtime is None:
raise ToolExecutionError('Local task runtime is not available.')
return context.task_runtime
def _task_mutation_metadata(
*,
action: str,
mutation,
task_id: str | None = None,
task_status: str | None = None,
total_tasks: int,
) -> dict[str, Any]:
try:
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
except ValueError:
relative_path = str(mutation.store_path)
payload: dict[str, Any] = {
'action': action,
'path': relative_path,
'before_sha256': mutation.before_sha256,
'after_sha256': mutation.after_sha256,
'before_preview': mutation.before_preview,
'after_preview': mutation.after_preview,
'before_task_count': mutation.before_count,
'after_task_count': mutation.after_count,
'total_tasks': total_tasks,
}
if task_id is not None:
payload['task_id'] = task_id
if task_status is not None:
payload['task_status'] = task_status
return payload
def _plan_mutation_metadata(
*,
action: str,
mutation,
total_steps: int,
sync_tasks: bool,
) -> dict[str, Any]:
try:
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
except ValueError:
relative_path = str(mutation.store_path)
payload: dict[str, Any] = {
'action': action,
'path': relative_path,
'before_sha256': mutation.before_sha256,
'after_sha256': mutation.after_sha256,
'before_preview': mutation.before_preview,
'after_preview': mutation.after_preview,
'before_plan_count': mutation.before_count,
'after_plan_count': mutation.after_count,
'total_steps': total_steps,
'sync_tasks': sync_tasks,
}
if mutation.explanation is not None:
payload['explanation'] = mutation.explanation
if mutation.synced_task_store_path is not None:
try:
payload['synced_task_store_path'] = str(
Path(mutation.synced_task_store_path).relative_to(Path.cwd())
)
except ValueError:
payload['synced_task_store_path'] = mutation.synced_task_store_path
if mutation.synced_task_sha256 is not None:
payload['synced_task_sha256'] = mutation.synced_task_sha256
if mutation.synced_tasks:
payload['synced_tasks'] = mutation.synced_tasks
return payload
def _drain_registered_streams(
selector: selectors.BaseSelector,
stdout_chunks: list[str],
@@ -721,6 +1196,12 @@ def _drain_registered_streams(
pass
def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]:
env = os.environ.copy()
env.update(context.extra_env)
return env
def _stream_static_text_result(
result: ToolExecutionResult,
*,
+371
View File
@@ -0,0 +1,371 @@
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
DEFAULT_BACKGROUND_DIR = Path('.port_sessions') / 'background'
_DETACHED_PROCESSES: dict[int, subprocess.Popen[Any]] = {}
@dataclass(frozen=True)
class BackgroundSessionRecord:
background_id: str
pid: int
prompt: str
workspace_cwd: str
model: str
mode: str
status: str
log_path: str
record_path: str
started_at: str
command: tuple[str, ...]
finished_at: str | None = None
exit_code: int | None = None
stop_reason: str | None = None
session_id: str | None = None
session_path: str | None = None
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'BackgroundSessionRecord':
return cls(
background_id=str(payload.get('background_id') or ''),
pid=int(payload.get('pid') or 0),
prompt=str(payload.get('prompt') or ''),
workspace_cwd=str(payload.get('workspace_cwd') or ''),
model=str(payload.get('model') or ''),
mode=str(payload.get('mode') or 'agent'),
status=str(payload.get('status') or 'unknown'),
log_path=str(payload.get('log_path') or ''),
record_path=str(payload.get('record_path') or ''),
started_at=str(payload.get('started_at') or ''),
command=tuple(
str(item)
for item in payload.get('command', [])
if isinstance(item, (str, int, float))
),
finished_at=(
str(payload.get('finished_at'))
if isinstance(payload.get('finished_at'), str) and payload.get('finished_at')
else None
),
exit_code=(
int(payload.get('exit_code'))
if isinstance(payload.get('exit_code'), int)
else None
),
stop_reason=(
str(payload.get('stop_reason'))
if isinstance(payload.get('stop_reason'), str) and payload.get('stop_reason')
else None
),
session_id=(
str(payload.get('session_id'))
if isinstance(payload.get('session_id'), str) and payload.get('session_id')
else None
),
session_path=(
str(payload.get('session_path'))
if isinstance(payload.get('session_path'), str) and payload.get('session_path')
else None
),
)
class BackgroundSessionRuntime:
def __init__(self, root: Path | None = None) -> None:
self.root = (root or DEFAULT_BACKGROUND_DIR).resolve()
self.root.mkdir(parents=True, exist_ok=True)
def create_id(self) -> str:
return f'bg_{uuid4().hex[:12]}'
def record_path(self, background_id: str) -> Path:
return self.root / f'{background_id}.json'
def log_path(self, background_id: str) -> Path:
return self.root / f'{background_id}.log'
def launch(
self,
command: list[str],
*,
prompt: str,
workspace_cwd: Path,
model: str,
mode: str = 'agent',
background_id: str | None = None,
process_cwd: Path | None = None,
) -> BackgroundSessionRecord:
background_id = background_id or self.create_id()
log_path = self.log_path(background_id)
record_path = self.record_path(background_id)
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open('a', encoding='utf-8') as handle:
process = subprocess.Popen(
command,
stdout=handle,
stderr=subprocess.STDOUT,
cwd=str(process_cwd or Path.cwd()),
start_new_session=True,
)
_DETACHED_PROCESSES[process.pid] = process
record = BackgroundSessionRecord(
background_id=background_id,
pid=process.pid,
prompt=prompt,
workspace_cwd=str(workspace_cwd),
model=model,
mode=mode,
status='running',
log_path=str(log_path),
record_path=str(record_path),
started_at=_utc_now(),
command=tuple(command),
)
self.save_record(record)
return record
def save_record(self, record: BackgroundSessionRecord) -> Path:
path = Path(record.record_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(record), ensure_ascii=True, indent=2), encoding='utf-8')
return path
def load_record(self, background_id: str) -> BackgroundSessionRecord:
data = json.loads(self.record_path(background_id).read_text(encoding='utf-8'))
record = BackgroundSessionRecord.from_dict(data)
return self.refresh_record(record)
def list_records(self) -> tuple[BackgroundSessionRecord, ...]:
records: list[BackgroundSessionRecord] = []
for path in sorted(self.root.glob('bg_*.json')):
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
continue
records.append(self.refresh_record(BackgroundSessionRecord.from_dict(payload)))
return tuple(sorted(records, key=lambda item: item.started_at, reverse=True))
def refresh_record(self, record: BackgroundSessionRecord) -> BackgroundSessionRecord:
if record.status != 'running':
return record
if _is_process_running(record.pid):
return record
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status='exited',
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=record.finished_at or _utc_now(),
exit_code=record.exit_code,
stop_reason=record.stop_reason,
session_id=record.session_id,
session_path=record.session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def mark_finished(
self,
background_id: str,
*,
exit_code: int,
stop_reason: str | None = None,
session_id: str | None = None,
session_path: str | None = None,
status: str | None = None,
) -> BackgroundSessionRecord:
record = self.load_record(background_id)
final_status = status or ('completed' if exit_code == 0 else 'failed')
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status=final_status,
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=_utc_now(),
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def kill(self, background_id: str) -> BackgroundSessionRecord:
record = self.load_record(background_id)
if record.status != 'running':
return record
try:
os.killpg(record.pid, signal.SIGTERM)
except OSError:
try:
os.kill(record.pid, signal.SIGTERM)
except OSError:
pass
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
waited_pid, _ = os.waitpid(record.pid, os.WNOHANG)
if waited_pid == record.pid:
break
except ChildProcessError:
break
except OSError:
break
if not _is_process_running(record.pid):
break
time.sleep(0.05)
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status='killed',
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=_utc_now(),
exit_code=-signal.SIGTERM,
stop_reason='killed',
session_id=record.session_id,
session_path=record.session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def read_logs(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
path = Path(record.log_path)
if not path.exists():
return ''
text = path.read_text(encoding='utf-8', errors='replace')
if tail is None or tail <= 0:
return text
lines = text.splitlines()
return '\n'.join(lines[-tail:])
def render_ps(self) -> str:
records = self.list_records()
lines = ['# Background Sessions', '']
if not records:
lines.append('No local background sessions are currently recorded.')
return '\n'.join(lines)
for record in records:
parts = [
record.background_id,
f'status={record.status}',
f'pid={record.pid}',
f'model={record.model}',
f'cwd={record.workspace_cwd}',
]
if record.exit_code is not None:
parts.append(f'exit_code={record.exit_code}')
lines.append('- ' + '; '.join(parts))
lines.append(f' prompt: {_snapshot_text(record.prompt)}')
return '\n'.join(lines)
def render_logs(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
log_text = self.read_logs(background_id, tail=tail)
lines = [
'# Background Logs',
'',
f'- Background session: {record.background_id}',
f'- Status: {record.status}',
f'- PID: {record.pid}',
f'- Log path: {record.log_path}',
'',
log_text.rstrip() or '(empty log)',
]
return '\n'.join(lines)
def render_attach(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
lines = [
'# Background Attach',
'',
f'- Background session: {record.background_id}',
f'- Status: {record.status}',
f'- Workspace cwd: {record.workspace_cwd}',
]
if record.session_id:
lines.append(f'- Agent session id: {record.session_id}')
if record.session_path:
lines.append(f'- Agent session path: {record.session_path}')
lines.extend(['', self.read_logs(background_id, tail=tail).rstrip() or '(empty log)'])
return '\n'.join(lines)
def build_background_worker_command(
*,
background_id: str,
prompt: str,
forwarded_args: list[str],
) -> list[str]:
return [
sys.executable,
'-m',
'src.main',
'agent-bg-worker',
background_id,
prompt,
*forwarded_args,
]
def _is_process_running(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _snapshot_text(text: str, limit: int = 140) -> str:
normalized = ' '.join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + '...'
+339
View File
@@ -0,0 +1,339 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class HookPolicyManifest:
path: str
trusted: bool | None = None
managed_settings: dict[str, Any] = field(default_factory=dict)
safe_env_names: tuple[str, ...] = ()
deny_tools: tuple[str, ...] = ()
deny_tool_prefixes: tuple[str, ...] = ()
before_prompt: tuple[str, ...] = ()
after_turn: tuple[str, ...] = ()
before_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
after_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
budget_overrides: dict[str, int | float] = field(default_factory=dict)
@dataclass
class HookPolicyRuntime:
manifests: tuple[HookPolicyManifest, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'HookPolicyRuntime':
manifests: list[HookPolicyManifest] = []
for path in _discover_policy_paths(cwd, additional_working_directories):
manifest = _load_policy_manifest(path)
if manifest is not None:
manifests.append(manifest)
return cls(manifests=tuple(manifests))
def is_trusted(self) -> bool:
explicit = [
manifest.trusted
for manifest in self.manifests
if manifest.trusted is not None
]
if not explicit:
return True
return explicit[-1]
def managed_settings(self) -> dict[str, Any]:
merged: dict[str, Any] = {}
for manifest in self.manifests:
merged.update(manifest.managed_settings)
return merged
def safe_env(self) -> dict[str, str]:
values: dict[str, str] = {}
for manifest in self.manifests:
for name in manifest.safe_env_names:
value = os.environ.get(name)
if value is not None:
values[name] = value
return values
def budget_overrides(self) -> dict[str, int | float]:
merged: dict[str, int | float] = {}
for manifest in self.manifests:
merged.update(manifest.budget_overrides)
return merged
def before_prompt_messages(self) -> tuple[str, ...]:
return tuple(
message
for manifest in self.manifests
for message in manifest.before_prompt
)
def after_turn_messages(self) -> tuple[str, ...]:
return tuple(
message
for manifest in self.manifests
for message in manifest.after_turn
)
def before_tool_messages(self, tool_name: str) -> tuple[str, ...]:
return self._tool_messages(tool_name, attr='before_tool')
def after_tool_messages(self, tool_name: str) -> tuple[str, ...]:
return self._tool_messages(tool_name, attr='after_tool')
def denied_tool_message(self, tool_name: str) -> str | None:
lowered = tool_name.lower()
for manifest in self.manifests:
if lowered in manifest.deny_tools:
return (
f'Workspace hook policy blocked tool {tool_name}. '
f'Policy file: {manifest.path}'
)
if any(lowered.startswith(prefix) for prefix in manifest.deny_tool_prefixes):
return (
f'Workspace hook policy blocked tool prefix match for {tool_name}. '
f'Policy file: {manifest.path}'
)
return None
def render_summary(self) -> str:
if not self.manifests:
return 'No local hook or policy manifests discovered.'
lines = [f'Local hook/policy manifests: {len(self.manifests)}']
lines.append(f'- trusted={self.is_trusted()}')
settings = self.managed_settings()
if settings:
lines.append(
'- managed_settings='
+ ', '.join(f'{key}={value}' for key, value in sorted(settings.items()))
)
env_values = self.safe_env()
if env_values:
lines.append(
'- safe_env='
+ ', '.join(f'{key}={value}' for key, value in sorted(env_values.items()))
)
for manifest in self.manifests:
details = [Path(manifest.path).name]
if manifest.trusted is not None:
details.append(f'trusted={manifest.trusted}')
if manifest.deny_tools:
details.append(f'deny_tools={len(manifest.deny_tools)}')
if manifest.deny_tool_prefixes:
details.append(f'deny_prefixes={len(manifest.deny_tool_prefixes)}')
if manifest.before_prompt:
details.append(f'before_prompt={len(manifest.before_prompt)}')
if manifest.after_turn:
details.append(f'after_turn={len(manifest.after_turn)}')
if manifest.before_tool:
details.append(f'before_tool={len(manifest.before_tool)}')
if manifest.after_tool:
details.append(f'after_tool={len(manifest.after_tool)}')
if manifest.budget_overrides:
details.append(f'budget_overrides={len(manifest.budget_overrides)}')
lines.append(f"- {'; '.join(details)}")
return '\n'.join(lines)
def _tool_messages(self, tool_name: str, *, attr: str) -> tuple[str, ...]:
lowered = tool_name.lower()
messages: list[str] = []
for manifest in self.manifests:
mapping = getattr(manifest, attr)
if not isinstance(mapping, dict):
continue
wildcard = mapping.get('*', ())
exact = mapping.get(lowered, ())
messages.extend(message for message in wildcard if message)
messages.extend(message for message in exact if message)
return tuple(messages)
def _discover_policy_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-policy.json')
remember(root / '.codex-policy.json')
remember(root / '.claw-hooks.json')
return tuple(candidates)
def _load_policy_manifest(path: Path) -> HookPolicyManifest | None:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
hooks = payload.get('hooks')
if not isinstance(hooks, dict):
hooks = {}
return HookPolicyManifest(
path=str(path),
trusted=(
payload.get('trusted')
if isinstance(payload.get('trusted'), bool)
else None
),
managed_settings=(
dict(payload.get('managedSettings'))
if isinstance(payload.get('managedSettings'), dict)
else dict(payload.get('managed_settings', {}))
if isinstance(payload.get('managed_settings'), dict)
else {}
),
safe_env_names=_extract_string_tuple(
payload.get('safeEnv')
if payload.get('safeEnv') is not None
else payload.get('safe_env')
),
deny_tools=tuple(
item.lower()
for item in _extract_string_tuple(
payload.get('denyTools')
if payload.get('denyTools') is not None
else payload.get('deny_tools')
)
),
deny_tool_prefixes=tuple(
item.lower()
for item in _extract_string_tuple(
payload.get('denyToolPrefixes')
if payload.get('denyToolPrefixes') is not None
else payload.get('deny_tool_prefixes')
)
),
before_prompt=_extract_hook_messages(
hooks.get('beforePrompt')
if hooks.get('beforePrompt') is not None
else hooks.get('before_prompt')
),
after_turn=_extract_hook_messages(
hooks.get('afterTurn')
if hooks.get('afterTurn') is not None
else hooks.get('after_turn')
),
before_tool=_extract_tool_hook_messages(
hooks.get('beforeTool')
if hooks.get('beforeTool') is not None
else hooks.get('before_tool')
),
after_tool=_extract_tool_hook_messages(
hooks.get('afterTool')
if hooks.get('afterTool') is not None
else hooks.get('after_tool')
),
budget_overrides=_extract_budget_overrides(payload.get('budget')),
)
def _extract_string_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(
item.strip()
for item in value
if isinstance(item, str) and item.strip()
)
def _extract_hook_messages(value: Any) -> tuple[str, ...]:
if isinstance(value, str) and value.strip():
return (value.strip(),)
if isinstance(value, list):
return tuple(
item.strip()
for item in value
if isinstance(item, str) and item.strip()
)
return ()
def _extract_tool_hook_messages(value: Any) -> dict[str, tuple[str, ...]]:
if not isinstance(value, dict):
return {}
extracted: dict[str, tuple[str, ...]] = {}
for key, raw in value.items():
if not isinstance(key, str) or not key.strip():
continue
messages = _extract_hook_messages(raw)
if messages:
extracted[key.strip().lower()] = messages
return extracted
def _extract_budget_overrides(value: Any) -> dict[str, int | float]:
if not isinstance(value, dict):
return {}
allowed_keys = {
'max_total_tokens',
'max_input_tokens',
'max_output_tokens',
'max_reasoning_tokens',
'max_total_cost_usd',
'max_tool_calls',
'max_delegated_tasks',
'max_model_calls',
'max_session_turns',
'maxTotalTokens',
'maxInputTokens',
'maxOutputTokens',
'maxReasoningTokens',
'maxTotalCostUsd',
'maxToolCalls',
'maxDelegatedTasks',
'maxModelCalls',
'maxSessionTurns',
}
normalized: dict[str, int | float] = {}
key_map = {
'maxTotalTokens': 'max_total_tokens',
'maxInputTokens': 'max_input_tokens',
'maxOutputTokens': 'max_output_tokens',
'maxReasoningTokens': 'max_reasoning_tokens',
'maxTotalCostUsd': 'max_total_cost_usd',
'maxToolCalls': 'max_tool_calls',
'maxDelegatedTasks': 'max_delegated_tasks',
'maxModelCalls': 'max_model_calls',
'maxSessionTurns': 'max_session_turns',
}
for key, raw in value.items():
if key not in allowed_keys:
continue
normalized_key = key_map.get(key, key)
if isinstance(raw, bool):
continue
if isinstance(raw, int):
normalized[normalized_key] = raw
elif isinstance(raw, float):
normalized[normalized_key] = raw
return normalized
+164
View File
@@ -2,11 +2,13 @@ from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from dataclasses import replace
import json
from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .agent_runtime import LocalCodingAgent
from .agent_types import (
AgentPermissions,
@@ -154,6 +156,64 @@ def _build_agent(args: argparse.Namespace) -> LocalCodingAgent:
)
def _append_agent_forwarded_args(
command: list[str],
args: argparse.Namespace,
*,
include_backend: bool,
) -> None:
command.extend(['--cwd', str(args.cwd)])
command.extend(['--max-turns', str(getattr(args, 'max_turns', 12))])
if include_backend:
command.extend(['--model', str(args.model)])
command.extend(['--base-url', str(args.base_url)])
command.extend(['--api-key', str(args.api_key)])
command.extend(['--temperature', str(args.temperature)])
command.extend(['--timeout-seconds', str(args.timeout_seconds)])
command.extend(['--input-cost-per-million', str(args.input_cost_per_million)])
command.extend(['--output-cost-per-million', str(args.output_cost_per_million)])
else:
command.extend(['--model', str(args.model)])
for path in getattr(args, 'add_dir', []):
command.extend(['--add-dir', str(path)])
for flag in (
('--disable-claude-md', getattr(args, 'disable_claude_md', False)),
('--allow-write', getattr(args, 'allow_write', False)),
('--allow-shell', getattr(args, 'allow_shell', False)),
('--unsafe', getattr(args, 'unsafe', False)),
('--stream', getattr(args, 'stream', False)),
('--show-transcript', getattr(args, 'show_transcript', False)),
(
'--response-schema-strict',
getattr(args, 'response_schema_strict', False),
),
):
if flag[1]:
command.append(flag[0])
for name, value in (
('--auto-snip-threshold', getattr(args, 'auto_snip_threshold', None)),
('--auto-compact-threshold', getattr(args, 'auto_compact_threshold', None)),
('--compact-preserve-messages', getattr(args, 'compact_preserve_messages', None)),
('--max-total-tokens', getattr(args, 'max_total_tokens', None)),
('--max-input-tokens', getattr(args, 'max_input_tokens', None)),
('--max-output-tokens', getattr(args, 'max_output_tokens', None)),
('--max-reasoning-tokens', getattr(args, 'max_reasoning_tokens', None)),
('--max-budget-usd', getattr(args, 'max_budget_usd', None)),
('--max-tool-calls', getattr(args, 'max_tool_calls', None)),
('--max-delegated-tasks', getattr(args, 'max_delegated_tasks', None)),
('--max-model-calls', getattr(args, 'max_model_calls', None)),
('--max-session-turns', getattr(args, 'max_session_turns', None)),
('--response-schema-file', getattr(args, 'response_schema_file', None)),
('--response-schema-name', getattr(args, 'response_schema_name', None)),
('--scratchpad-root', getattr(args, 'scratchpad_root', None)),
('--system-prompt', getattr(args, 'system_prompt', None)),
('--append-system-prompt', getattr(args, 'append_system_prompt', None)),
('--override-system-prompt', getattr(args, 'override_system_prompt', None)),
):
if value is not None:
command.extend([name, str(value)])
def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('session_id')
parser.add_argument('prompt')
@@ -487,6 +547,34 @@ def build_parser() -> argparse.ArgumentParser:
agent_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(agent_parser, include_backend=True)
background_parser = subparsers.add_parser('agent-bg', help='run the Python local-model agent as a local background session')
background_parser.add_argument('prompt')
background_parser.add_argument('--max-turns', type=int, default=12)
background_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(background_parser, include_backend=True)
background_worker_parser = subparsers.add_parser('agent-bg-worker', help=argparse.SUPPRESS)
background_worker_parser.add_argument('background_id')
background_worker_parser.add_argument('prompt')
background_worker_parser.add_argument('--background-root', required=True)
background_worker_parser.add_argument('--max-turns', type=int, default=12)
background_worker_parser.add_argument('--show-transcript', action='store_true')
_add_agent_common_args(background_worker_parser, include_backend=True)
ps_parser = subparsers.add_parser('agent-ps', help='list local background agent sessions')
ps_parser.add_argument('--tail', type=int, default=None)
logs_parser = subparsers.add_parser('agent-logs', help='show logs for a local background agent session')
logs_parser.add_argument('background_id')
logs_parser.add_argument('--tail', type=int, default=None)
attach_parser = subparsers.add_parser('agent-attach', help='show the current output snapshot for a local background agent session')
attach_parser.add_argument('background_id')
attach_parser.add_argument('--tail', type=int, default=None)
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
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')
@@ -640,6 +728,82 @@ def main(argv: list[str] | None = None) -> int:
result = agent.run(args.prompt)
_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
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,
)
if args.command == 'agent-ps':
print(BackgroundSessionRuntime().render_ps())
return 0
if args.command == 'agent-logs':
print(
BackgroundSessionRuntime().render_logs(
args.background_id,
tail=args.tail,
)
)
return 0
if args.command == 'agent-attach':
print(
BackgroundSessionRuntime().render_attach(
args.background_id,
tail=args.tail,
)
)
return 0
if args.command == 'agent-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(
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class MCPResource:
uri: str
server_name: str
source_manifest: str
name: str | None = None
description: str | None = None
mime_type: str | None = None
resolved_path: str | None = None
inline_text: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class MCPRuntime:
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'MCPRuntime':
resources: list[MCPResource] = []
for path in _discover_manifest_paths(cwd, additional_working_directories):
resources.extend(_load_resources_from_manifest(path))
return cls(resources=tuple(resources))
@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)
return tuple(seen)
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()
)
if limit is not None and limit >= 0:
resources = resources[:limit]
return resources
def get_resource(self, uri: str) -> MCPResource | None:
for resource in self.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)
def render_summary(self) -> str:
if not self.resources:
return 'No local MCP manifests or resources discovered.'
lines = [
f'Local MCP manifests: {len(self.manifests)}',
f'Local MCP resources: {len(self.resources)}',
]
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)')
return '\n'.join(lines)
def render_resource_index(
self,
*,
query: str | None = None,
limit: int = 20,
) -> str:
resources = self.list_resources(query=query, limit=limit)
if not resources:
return '# MCP Resources\n\nNo matching MCP resources discovered.'
lines = ['# MCP Resources', '']
for resource in resources:
details = [resource.uri]
details.append(f'server={resource.server_name}')
if resource.name:
details.append(f'name={resource.name}')
if resource.mime_type:
details.append(f'mime={resource.mime_type}')
if resource.resolved_path:
details.append(f'path={resource.resolved_path}')
lines.append('- ' + '; '.join(details))
return '\n'.join(lines)
def render_resource(self, uri: str, *, max_chars: int = 12000) -> str:
resource = self.get_resource(uri)
if resource is None:
return f'# MCP Resource\n\nUnknown MCP resource: {uri}'
lines = [
'# MCP Resource',
'',
f'- URI: {resource.uri}',
f'- Server: {resource.server_name}',
]
if resource.name:
lines.append(f'- Name: {resource.name}')
if resource.mime_type:
lines.append(f'- MIME Type: {resource.mime_type}')
if resource.resolved_path:
lines.append(f'- Path: {resource.resolved_path}')
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
return '\n'.join(lines)
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-mcp.json')
remember(root / '.mcp.json')
remember(root / '.codex-mcp.json')
remember(root / 'mcp.json')
return tuple(candidates)
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(payload, dict):
return []
resources: list[MCPResource] = []
if isinstance(payload.get('resources'), list):
resources.extend(
_extract_resources(
payload.get('name') if isinstance(payload.get('name'), str) else 'local',
payload['resources'],
manifest_path=path,
)
)
servers = payload.get('servers')
if isinstance(servers, list):
for item in servers:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str) or not name.strip():
continue
raw_resources = item.get('resources')
if not isinstance(raw_resources, list):
continue
resources.extend(
_extract_resources(name.strip(), raw_resources, manifest_path=path)
)
return resources
def _extract_resources(
server_name: str,
raw_resources: list[Any],
*,
manifest_path: Path,
) -> list[MCPResource]:
resources: list[MCPResource] = []
seen_uris: set[str] = set()
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
uri = uri.strip()
if uri in seen_uris:
continue
seen_uris.add(uri)
raw_path = item.get('path')
if raw_path is None:
raw_path = item.get('file')
resolved_path: str | None = None
if isinstance(raw_path, str) and raw_path.strip():
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = manifest_path.parent / candidate
resolved_path = str(candidate.resolve())
inline_text = item.get('text')
if not isinstance(inline_text, str):
inline_text = None
metadata = item.get('metadata')
resources.append(
MCPResource(
uri=uri,
server_name=server_name,
source_manifest=str(manifest_path),
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
),
resolved_path=resolved_path,
inline_text=inline_text,
metadata=dict(metadata) if isinstance(metadata, dict) else {},
)
)
return resources
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
head = text[: limit // 2]
tail = text[-(limit // 2) :]
return f'{head}\n...[truncated]...\n{tail}'
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .task_runtime import TaskRuntime
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed')
@dataclass(frozen=True)
class PlanStep:
step: str
status: str = 'pending'
task_id: str | None = None
description: str | None = None
priority: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
'step': self.step,
'status': self.status,
'task_id': self.task_id,
'description': self.description,
'priority': self.priority,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'PlanStep':
return cls(
step=str(payload.get('step') or ''),
status=_normalize_plan_status(payload.get('status')),
task_id=(
str(payload.get('task_id'))
if isinstance(payload.get('task_id'), str) and payload.get('task_id')
else None
),
description=(
str(payload.get('description'))
if isinstance(payload.get('description'), str)
and payload.get('description').strip()
else None
),
priority=(
str(payload.get('priority'))
if isinstance(payload.get('priority'), str)
and payload.get('priority').strip()
else None
),
)
@dataclass(frozen=True)
class PlanMutation:
explanation: str | None
store_path: str
before_sha256: str | None
after_sha256: str
before_preview: str | None
after_preview: str
before_count: int
after_count: int
synced_tasks: int = 0
synced_task_store_path: str | None = None
synced_task_sha256: str | None = None
@dataclass
class PlanRuntime:
steps: tuple[PlanStep, ...] = field(default_factory=tuple)
explanation: str | None = None
updated_at: str | None = None
storage_path: Path = field(default_factory=lambda: DEFAULT_PLAN_RUNTIME_PATH.resolve())
@classmethod
def from_workspace(cls, cwd: Path) -> 'PlanRuntime':
storage_path = (cwd.resolve() / DEFAULT_PLAN_RUNTIME_PATH).resolve()
if not storage_path.exists():
return cls(storage_path=storage_path)
try:
payload = json.loads(storage_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return cls(storage_path=storage_path)
raw_steps = payload.get('steps')
if not isinstance(raw_steps, list):
return cls(storage_path=storage_path)
steps: list[PlanStep] = []
for item in raw_steps:
if not isinstance(item, dict):
continue
step = PlanStep.from_dict(item)
if step.step:
steps.append(step)
explanation = payload.get('explanation')
updated_at = payload.get('updated_at')
return cls(
steps=tuple(steps),
explanation=(
explanation.strip()
if isinstance(explanation, str) and explanation.strip()
else None
),
updated_at=str(updated_at) if isinstance(updated_at, str) and updated_at else None,
storage_path=storage_path,
)
def update_plan(
self,
items: list[dict[str, Any]],
*,
explanation: str | None = None,
task_runtime: TaskRuntime | None = None,
sync_tasks: bool = True,
) -> PlanMutation:
normalized_steps: list[PlanStep] = []
for index, item in enumerate(items, start=1):
if not isinstance(item, dict):
continue
step_text = item.get('step')
if not isinstance(step_text, str) or not step_text.strip():
continue
task_id = item.get('task_id')
normalized_steps.append(
PlanStep(
step=step_text.strip(),
status=_normalize_plan_status(item.get('status')),
task_id=(
task_id.strip()
if isinstance(task_id, str) and task_id.strip()
else f'plan_{index}'
),
description=(
item.get('description').strip()
if isinstance(item.get('description'), str)
and item.get('description').strip()
else None
),
priority=(
item.get('priority').strip()
if isinstance(item.get('priority'), str)
and item.get('priority').strip()
else None
),
)
)
mutation = self._persist(
tuple(normalized_steps),
explanation=(
explanation.strip()
if isinstance(explanation, str) and explanation.strip()
else None
),
)
if sync_tasks and task_runtime is not None:
task_items = [
{
'task_id': step.task_id or f'plan_{index}',
'title': step.step,
'description': step.description,
'status': _plan_status_to_task_status(step.status),
'priority': step.priority,
}
for index, step in enumerate(self.steps, start=1)
]
task_mutation = task_runtime.replace_tasks(task_items)
return PlanMutation(
explanation=mutation.explanation,
store_path=mutation.store_path,
before_sha256=mutation.before_sha256,
after_sha256=mutation.after_sha256,
before_preview=mutation.before_preview,
after_preview=mutation.after_preview,
before_count=mutation.before_count,
after_count=mutation.after_count,
synced_tasks=task_mutation.after_count,
synced_task_store_path=task_mutation.store_path,
synced_task_sha256=task_mutation.after_sha256,
)
return mutation
def clear_plan(self, *, task_runtime: TaskRuntime | None = None) -> PlanMutation:
mutation = self._persist((), explanation=None)
if task_runtime is not None:
task_mutation = task_runtime.replace_tasks([])
return PlanMutation(
explanation=None,
store_path=mutation.store_path,
before_sha256=mutation.before_sha256,
after_sha256=mutation.after_sha256,
before_preview=mutation.before_preview,
after_preview=mutation.after_preview,
before_count=mutation.before_count,
after_count=mutation.after_count,
synced_tasks=task_mutation.after_count,
synced_task_store_path=task_mutation.store_path,
synced_task_sha256=task_mutation.after_sha256,
)
return mutation
def render_summary(self) -> str:
lines = [
f'Local plan runtime file: {self.storage_path}',
f'Total plan steps: {len(self.steps)}',
]
if self.explanation:
lines.append(f'- Explanation: {self.explanation}')
counts: dict[str, int] = {}
for step in self.steps:
counts[step.status] = counts.get(step.status, 0) + 1
if counts:
lines.append(
'- Status counts: '
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
)
if self.updated_at:
lines.append(f'- Updated: {self.updated_at}')
return '\n'.join(lines)
def render_plan(self) -> str:
if not self.steps:
return '# Plan\n\nNo stored plan is currently available.'
lines = ['# Plan', '']
if self.explanation:
lines.extend(['## Explanation', self.explanation, ''])
lines.append('## Steps')
for index, step in enumerate(self.steps, start=1):
details = [f'{index}. {step.step}', f'status={step.status}']
if step.task_id:
details.append(f'task_id={step.task_id}')
if step.priority:
details.append(f'priority={step.priority}')
lines.append('- ' + '; '.join(details))
if step.description:
lines.append(f' description: {step.description}')
return '\n'.join(lines)
def _persist(
self,
steps: tuple[PlanStep, ...],
*,
explanation: str | None,
) -> PlanMutation:
before_count = len(self.steps)
before_text = self._serialize_payload(self.steps, self.explanation, self.updated_at)
before_preview = _snapshot_text(before_text)
before_sha256 = (
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
if self.storage_path.exists() or self.steps or self.explanation
else None
)
updated_at = datetime.now(timezone.utc).isoformat()
payload_text = self._serialize_payload(steps, explanation, updated_at)
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
self.storage_path.write_text(payload_text, encoding='utf-8')
self.steps = steps
self.explanation = explanation
self.updated_at = updated_at
after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest()
return PlanMutation(
explanation=explanation,
store_path=str(self.storage_path),
before_sha256=before_sha256,
after_sha256=after_sha256,
before_preview=before_preview if before_text.strip() else None,
after_preview=_snapshot_text(payload_text),
before_count=before_count if before_text.strip() else 0,
after_count=len(steps),
)
def _serialize_payload(
self,
steps: tuple[PlanStep, ...],
explanation: str | None,
updated_at: str | None,
) -> str:
payload = {
'explanation': explanation,
'updated_at': updated_at,
'steps': [step.to_dict() for step in steps],
}
return json.dumps(payload, ensure_ascii=True, indent=2)
def _normalize_plan_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
aliases = {
'todo': 'pending',
'open': 'pending',
'done': 'completed',
'complete': 'completed',
'in-progress': 'in_progress',
'in progress': 'in_progress',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_PLAN_STATUSES:
return lowered
return 'pending'
def _plan_status_to_task_status(status: str) -> str:
if status == 'completed':
return 'done'
if status == 'in_progress':
return 'in_progress'
return 'todo'
def _snapshot_text(text: str, limit: int = 240) -> str:
normalized = ' '.join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + '...'
+78 -5
View File
@@ -1,5 +1,78 @@
from __future__ import annotations
from .task import PortingTask
__all__ = ['PortingTask']
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
@dataclass(frozen=True)
class PortingTask:
task_id: str
title: str
status: str = 'todo'
description: str | None = None
priority: str | None = None
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
updated_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def to_dict(self) -> dict[str, Any]:
return {
'task_id': self.task_id,
'title': self.title,
'status': self.status,
'description': self.description,
'priority': self.priority,
'created_at': self.created_at,
'updated_at': self.updated_at,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'PortingTask':
return cls(
task_id=str(payload.get('task_id') or payload.get('id') or ''),
title=str(payload.get('title') or ''),
status=_normalize_task_status(payload.get('status')),
description=(
str(payload.get('description'))
if isinstance(payload.get('description'), str)
else None
),
priority=(
str(payload.get('priority'))
if isinstance(payload.get('priority'), str)
else None
),
created_at=(
str(payload.get('created_at'))
if isinstance(payload.get('created_at'), str)
else datetime.now(timezone.utc).isoformat()
),
updated_at=(
str(payload.get('updated_at'))
if isinstance(payload.get('updated_at'), str)
else datetime.now(timezone.utc).isoformat()
),
)
def _normalize_task_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower()
aliases = {
'in-progress': 'in_progress',
'in progress': 'in_progress',
'complete': 'done',
'completed': 'done',
'open': 'todo',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'
+278
View File
@@ -0,0 +1,278 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from .task import PortingTask, VALID_TASK_STATUSES
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
@dataclass(frozen=True)
class TaskMutation:
task: PortingTask | None
store_path: str
before_sha256: str | None
after_sha256: str
before_preview: str | None
after_preview: str
before_count: int
after_count: int
@dataclass
class TaskRuntime:
tasks: tuple[PortingTask, ...] = field(default_factory=tuple)
storage_path: Path = field(default_factory=lambda: DEFAULT_TASK_RUNTIME_PATH.resolve())
@classmethod
def from_workspace(cls, cwd: Path) -> 'TaskRuntime':
storage_path = (cwd.resolve() / DEFAULT_TASK_RUNTIME_PATH).resolve()
if not storage_path.exists():
return cls(tasks=(), storage_path=storage_path)
try:
payload = json.loads(storage_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return cls(tasks=(), storage_path=storage_path)
raw_tasks = payload.get('tasks')
if not isinstance(raw_tasks, list):
return cls(tasks=(), storage_path=storage_path)
tasks: list[PortingTask] = []
for item in raw_tasks:
if not isinstance(item, dict):
continue
task = PortingTask.from_dict(item)
if not task.task_id or not task.title:
continue
tasks.append(task)
return cls(tasks=tuple(tasks), storage_path=storage_path)
def list_tasks(
self,
*,
status: str | None = None,
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 limit is not None and limit >= 0:
tasks = tasks[:limit]
return tasks
def get_task(self, task_id: str) -> PortingTask | None:
for task in self.tasks:
if task.task_id == task_id:
return task
return None
def create_task(
self,
*,
title: str,
description: str | None = None,
status: str = 'todo',
priority: str | None = None,
task_id: str | None = None,
) -> TaskMutation:
task = PortingTask(
task_id=task_id or f'task_{uuid4().hex[:10]}',
title=title.strip(),
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,
)
return self._persist((*self.tasks, task), task=task)
def update_task(
self,
task_id: str,
*,
title: str | None = None,
description: str | None = None,
status: str | None = None,
priority: str | None = None,
) -> TaskMutation:
existing = self.get_task(task_id)
if existing is None:
raise KeyError(task_id)
updated = replace(
existing,
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
description=(
description.strip()
if isinstance(description, str) and description.strip()
else None if description == ''
else existing.description
),
status=_normalize_status(status) if status is not None else existing.status,
priority=(
priority.strip()
if isinstance(priority, str) and priority.strip()
else None if priority == ''
else existing.priority
),
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 replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
tasks: list[PortingTask] = []
now = datetime.now(timezone.utc).isoformat()
for index, item in enumerate(items, start=1):
if not isinstance(item, dict):
continue
title = item.get('title')
if not isinstance(title, str) or not title.strip():
continue
task_id = item.get('task_id')
if not isinstance(task_id, str) or not task_id.strip():
task_id = item.get('id')
tasks.append(
PortingTask(
task_id=(
task_id.strip()
if isinstance(task_id, str) and task_id.strip()
else f'task_{index}_{uuid4().hex[:6]}'
),
title=title.strip(),
description=(
item.get('description').strip()
if isinstance(item.get('description'), str)
and item.get('description').strip()
else None
),
status=_normalize_status(item.get('status')),
priority=(
item.get('priority').strip()
if isinstance(item.get('priority'), str)
and item.get('priority').strip()
else None
),
created_at=(
str(item.get('created_at'))
if isinstance(item.get('created_at'), str)
else now
),
updated_at=now,
)
)
mutation = self._persist(tuple(tasks), task=None)
return mutation
def render_summary(self) -> str:
lines = [
f'Local task runtime file: {self.storage_path}',
f'Total tasks: {len(self.tasks)}',
]
counts: dict[str, int] = {}
for task in self.tasks:
counts[task.status] = counts.get(task.status, 0) + 1
if counts:
lines.append(
'- Status counts: '
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
)
if self.tasks:
preview = ', '.join(task.title for task in 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)
if not tasks:
return '# Tasks\n\nNo tasks are currently stored.'
lines = ['# Tasks', '']
for task in tasks:
details = [task.task_id, f'status={task.status}']
if task.priority:
details.append(f'priority={task.priority}')
details.append(f'title={task.title}')
lines.append('- ' + '; '.join(details))
if task.description:
lines.append(f' description: {task.description}')
return '\n'.join(lines)
def render_task(self, task_id: str) -> str:
task = self.get_task(task_id)
if task is None:
return f'# Task\n\nUnknown task id: {task_id}'
lines = [
'# Task',
'',
f'- ID: {task.task_id}',
f'- Status: {task.status}',
f'- Title: {task.title}',
]
if task.priority:
lines.append(f'- Priority: {task.priority}')
if task.description:
lines.append(f'- Description: {task.description}')
lines.append(f'- Updated: {task.updated_at}')
return '\n'.join(lines)
def _persist(
self,
tasks: tuple[PortingTask, ...],
*,
task: PortingTask | None,
) -> TaskMutation:
before_text = self._serialize_payload(self.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
else None
)
payload_text = self._serialize_payload(tasks)
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
self.storage_path.write_text(payload_text, encoding='utf-8')
self.tasks = tasks
after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest()
return TaskMutation(
task=task,
store_path=str(self.storage_path),
before_sha256=before_sha256,
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,
after_count=len(tasks),
)
def _serialize_payload(self, tasks: tuple[PortingTask, ...]) -> str:
payload = {
'tasks': [task.to_dict() for task in tasks],
}
return json.dumps(payload, ensure_ascii=True, indent=2)
def _snapshot_text(text: str, limit: int = 240) -> str:
normalized = ' '.join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + '...'
def _normalize_status(value: Any) -> str:
if isinstance(value, str):
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
aliases = {
'complete': 'done',
'completed': 'done',
'open': 'todo',
}
lowered = aliases.get(lowered, lowered)
if lowered in VALID_TASK_STATUSES:
return lowered
return 'todo'