Implemented the next parity slice.

New runtime/code:

  - src/ask_user_runtime.py
  - src/team_runtime.py

  New real tools in src/agent_tools.py:

  - ask_user_question
  - team_create
  - team_delete
  - team_list
  - team_get
  - send_message
  - team_messages
  - notebook_edit
This commit is contained in:
Abdelrahman Abdallah
2026-04-07 02:51:30 +02:00
parent a54c90b18f
commit a5629295ac
21 changed files with 1886 additions and 24 deletions
+8
View File
@@ -1,6 +1,7 @@
"""Python porting workspace for the Claude Code rewrite effort."""
from .account_runtime import AccountRuntime, AccountProfile, AccountSessionState, AccountStatusReport
from .ask_user_runtime import AskUserRuntime, AskUserResponse, QueuedUserAnswer
from .agent_context import (
AgentContextSnapshot,
build_context_snapshot,
@@ -29,6 +30,7 @@ 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 .team_runtime import TeamDefinition, TeamMessage, TeamRuntime
from .tokenizer_runtime import TokenCounterInfo, clear_token_counter_cache, count_tokens, describe_token_counter
from .tools import PORTED_TOOLS, build_tool_backlog
@@ -42,6 +44,8 @@ __all__ = [
'AccountRuntime',
'AccountSessionState',
'AccountStatusReport',
'AskUserResponse',
'AskUserRuntime',
'AgentMessage',
'AgentSessionState',
'BackgroundSessionRuntime',
@@ -60,6 +64,7 @@ __all__ = [
'PortRuntime',
'PluginRuntime',
'PortingTask',
'QueuedUserAnswer',
'QueryEnginePort',
'RuntimeSession',
'SearchProviderProfile',
@@ -68,6 +73,9 @@ __all__ = [
'SearchStatusReport',
'StoredSession',
'TaskRuntime',
'TeamDefinition',
'TeamMessage',
'TeamRuntime',
'TokenCounterInfo',
'TurnResult',
'PORTED_COMMANDS',
+8
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from .agent_plugin_cache import load_plugin_cache_summary
from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
@@ -18,6 +19,7 @@ from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
from .agent_types import AgentRuntimeConfig
MAX_STATUS_CHARS = 2000
@@ -237,6 +239,9 @@ def _get_user_context_cached(
account_runtime = AccountRuntime.from_workspace(Path(cwd), additional_working_directories)
if account_runtime.has_account_state():
context['accountRuntime'] = account_runtime.render_summary()
ask_user_runtime = AskUserRuntime.from_workspace(Path(cwd), additional_working_directories)
if ask_user_runtime.has_state():
context['askUserRuntime'] = ask_user_runtime.render_summary()
config_runtime = ConfigRuntime.from_workspace(Path(cwd))
if config_runtime.has_config():
context['configRuntime'] = config_runtime.render_summary()
@@ -246,6 +251,9 @@ def _get_user_context_cached(
task_runtime = TaskRuntime.from_workspace(Path(cwd))
if task_runtime.tasks:
context['taskRuntime'] = task_runtime.render_summary()
team_runtime = TeamRuntime.from_workspace(Path(cwd), additional_working_directories)
if team_runtime.has_team_state():
context['teamRuntime'] = team_runtime.render_summary()
return context
+26
View File
@@ -95,9 +95,11 @@ def build_system_prompt_parts(
get_remote_guidance_section(prompt_context),
get_search_guidance_section(prompt_context),
get_account_guidance_section(prompt_context),
get_ask_user_guidance_section(prompt_context),
get_config_guidance_section(prompt_context),
get_plan_guidance_section(prompt_context),
get_task_guidance_section(prompt_context),
get_team_guidance_section(prompt_context),
get_hook_policy_guidance_section(prompt_context),
get_tone_and_style_section(),
get_output_efficiency_section(),
@@ -277,6 +279,18 @@ def get_account_guidance_section(prompt_context: PromptContext) -> str:
return '\n'.join(['# Account', *prepend_bullets(items)])
def get_ask_user_guidance_section(prompt_context: PromptContext) -> str:
ask_user_runtime = prompt_context.user_context.get('askUserRuntime')
if not ask_user_runtime:
return ''
items = [
'A local ask-user runtime may be available with queued answers or optional interactive prompting.',
'Use ask_user_question when you genuinely need a user decision or clarification that should not be guessed.',
'If ask_user_question reports that no queued answer is available, explain the limitation or ask the human user directly outside the tool loop.',
]
return '\n'.join(['# Ask User', *prepend_bullets(items)])
def get_config_guidance_section(prompt_context: PromptContext) -> str:
config_runtime = prompt_context.user_context.get('configRuntime')
if not config_runtime:
@@ -302,6 +316,18 @@ def get_task_guidance_section(prompt_context: PromptContext) -> str:
return '\n'.join(['# Tasks', *prepend_bullets(items)])
def get_team_guidance_section(prompt_context: PromptContext) -> str:
team_runtime = prompt_context.user_context.get('teamRuntime')
if not team_runtime:
return ''
items = [
'A local collaboration team runtime may be available with persisted teams and message history.',
'Use the team tools when the task needs local team state, simple collaboration metadata, or persisted teammate messages.',
'Use send_message to record a concrete handoff or note to a team instead of burying it in free-form assistant text.',
]
return '\n'.join(['# Teams', *prepend_bullets(items)])
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
plan_runtime = prompt_context.user_context.get('planRuntime')
if not plan_runtime:
+69
View File
@@ -12,6 +12,7 @@ from .agent_manager import AgentManager
from .agent_context import clear_context_caches
from .agent_context import render_context_report as render_agent_context_report
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime
from .hook_policy import HookPolicyRuntime
from .mcp_runtime import MCPRuntime
@@ -48,6 +49,7 @@ from .plugin_runtime import PluginRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
from .tokenizer_runtime import describe_token_counter
from .session_store import (
StoredAgentSession,
@@ -84,9 +86,11 @@ class LocalCodingAgent:
remote_runtime: RemoteRuntime | None = None
search_runtime: SearchRuntime | None = None
account_runtime: AccountRuntime | None = None
ask_user_runtime: AskUserRuntime | None = None
config_runtime: ConfigRuntime | None = None
plan_runtime: PlanRuntime | None = None
task_runtime: TaskRuntime | None = None
team_runtime: TeamRuntime | 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)
@@ -129,12 +133,22 @@ class LocalCodingAgent:
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.ask_user_runtime is None:
self.ask_user_runtime = AskUserRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
if self.config_runtime is None:
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if self.plan_runtime is None:
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if self.task_runtime is None:
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if self.team_runtime is None:
self.team_runtime = TeamRuntime.from_workspace(
self.runtime_config.cwd,
tuple(str(path) for path in self.runtime_config.additional_working_directories),
)
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)
@@ -155,11 +169,13 @@ class LocalCodingAgent:
),
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
ask_user_runtime=self.ask_user_runtime,
config_runtime=self.config_runtime,
mcp_runtime=self.mcp_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
team_runtime=self.team_runtime,
)
def set_model(self, model: str) -> None:
@@ -3063,6 +3079,37 @@ class LocalCodingAgent:
return '# Task\n\nNo local task runtime is available.'
return self.task_runtime.render_task(task_id)
def render_ask_user_report(self) -> str:
if self.ask_user_runtime is None:
return '# Ask User\n\nNo local ask-user runtime is available.'
return '\n'.join(['# Ask User', '', self.ask_user_runtime.render_summary()])
def render_ask_user_history_report(self) -> str:
if self.ask_user_runtime is None:
return '# Ask User History\n\nNo local ask-user runtime is available.'
return self.ask_user_runtime.render_history()
def render_teams_report(self, query: str | None = None) -> str:
if self.team_runtime is None:
return '# Teams\n\nNo local team runtime is available.'
return self.team_runtime.render_teams_index(query=query)
def render_team_report(self, team_name: str) -> str:
if self.team_runtime is None:
return '# Team\n\nNo local team runtime is available.'
try:
return self.team_runtime.render_team(team_name)
except KeyError:
return f'# Team\n\nUnknown team: {team_name}'
def render_team_messages_report(self, team_name: str | None = None) -> str:
if self.team_runtime is None:
return '# Team Messages\n\nNo local team runtime is available.'
try:
return self.team_runtime.render_messages(team_name=team_name)
except KeyError:
return f'# Team Messages\n\nUnknown team: {team_name}'
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.'
@@ -3131,6 +3178,9 @@ class LocalCodingAgent:
lines.append(
f'- Active account: {session.provider} -> {session.identity}'
)
if self.ask_user_runtime is not None and self.ask_user_runtime.has_state():
lines.append(f'- Ask-user queued answers: {len(self.ask_user_runtime.queued_answers)}')
lines.append(f'- Ask-user history: {len(self.ask_user_runtime.history)}')
if self.config_runtime is not None and self.config_runtime.has_config():
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
lines.append(
@@ -3140,6 +3190,9 @@ class LocalCodingAgent:
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.team_runtime is not None and self.team_runtime.has_team_state():
lines.append(f'- Local teams: {len(self.team_runtime.teams)}')
lines.append(f'- Team messages: {len(self.team_runtime.messages)}')
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:
@@ -3198,6 +3251,10 @@ class LocalCodingAgent:
'account_login',
'account_logout',
'config_set',
'ask_user_question',
'team_create',
'team_delete',
'send_message',
}
if tool_name not in refresh_tool_names:
return
@@ -3220,21 +3277,33 @@ class LocalCodingAgent:
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name == 'ask_user_question':
self.ask_user_runtime = AskUserRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
if tool_name == 'config_set':
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('task_') or tool_name == 'todo_write':
self.task_runtime = TaskRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('plan_') or tool_name == 'update_plan':
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
if tool_name.startswith('team_') or tool_name == 'send_message':
self.team_runtime = TeamRuntime.from_workspace(
self.runtime_config.cwd,
additional_working_directories=additional_dirs,
)
self.tool_context = replace(
self.tool_context,
tool_registry=self.tool_registry,
search_runtime=self.search_runtime,
account_runtime=self.account_runtime,
ask_user_runtime=self.ask_user_runtime,
config_runtime=self.config_runtime,
remote_runtime=self.remote_runtime,
plan_runtime=self.plan_runtime,
task_runtime=self.task_runtime,
team_runtime=self.team_runtime,
)
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
+46
View File
@@ -129,6 +129,11 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show local account runtime status or configured account profiles.',
handler=_handle_account,
),
SlashCommandSpec(
names=('ask',),
description='Show local ask-user runtime status or ask-user history.',
handler=_handle_ask,
),
SlashCommandSpec(
names=('login',),
description='Activate a local account profile or ephemeral identity.',
@@ -189,6 +194,21 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Show the local runtime task list, optionally filtered by status.',
handler=_handle_tasks,
),
SlashCommandSpec(
names=('teams',),
description='List the locally configured collaboration teams.',
handler=_handle_teams,
),
SlashCommandSpec(
names=('team',),
description='Show one local collaboration team by name.',
handler=_handle_team,
),
SlashCommandSpec(
names=('messages',),
description='Show recorded collaboration messages for all teams or one team.',
handler=_handle_messages,
),
SlashCommandSpec(
names=('task-next', 'next-task'),
description='Show the next actionable tasks from the local runtime task list.',
@@ -343,6 +363,15 @@ def _handle_account(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sl
return _local_result(input_text, 'Usage: /account [profiles|profile <name>]')
def _handle_ask(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
command = args.strip()
if not command:
return _local_result(input_text, agent.render_ask_user_report())
if command == 'history':
return _local_result(input_text, agent.render_ask_user_history_report())
return _local_result(input_text, 'Usage: /ask [history]')
def _handle_login(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
target = args.strip()
if not target:
@@ -429,6 +458,23 @@ def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> Slas
return _local_result(input_text, agent.render_tasks_report(status))
def _handle_teams(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
query = args or None
return _local_result(input_text, agent.render_teams_report(query))
def _handle_team(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
team_name = args.strip()
if not team_name:
return _local_result(input_text, 'Usage: /team <team-name>')
return _local_result(input_text, agent.render_team_report(team_name))
def _handle_messages(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
team_name = args.strip() or None
return _local_result(input_text, agent.render_team_messages_report(team_name))
def _handle_task_next(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
return _local_result(input_text, agent.render_next_tasks_report())
+395
View File
@@ -18,12 +18,14 @@ from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResu
if TYPE_CHECKING:
from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .config_runtime import ConfigRuntime
from .mcp_runtime import MCPRuntime
from .plan_runtime import PlanRuntime
from .remote_runtime import RemoteRuntime
from .search_runtime import SearchRuntime
from .task_runtime import TaskRuntime
from .team_runtime import TeamRuntime
class ToolPermissionError(RuntimeError):
@@ -44,11 +46,13 @@ class ToolExecutionContext:
tool_registry: dict[str, 'AgentTool'] | None = None
search_runtime: 'SearchRuntime | None' = None
account_runtime: 'AccountRuntime | None' = None
ask_user_runtime: 'AskUserRuntime | None' = None
config_runtime: 'ConfigRuntime | None' = None
mcp_runtime: 'MCPRuntime | None' = None
remote_runtime: 'RemoteRuntime | None' = None
plan_runtime: 'PlanRuntime | None' = None
task_runtime: 'TaskRuntime | None' = None
team_runtime: 'TeamRuntime | None' = None
ToolHandler = Callable[
@@ -114,11 +118,13 @@ def build_tool_context(
tool_registry: dict[str, AgentTool] | None = None,
search_runtime: 'SearchRuntime | None' = None,
account_runtime: 'AccountRuntime | None' = None,
ask_user_runtime: 'AskUserRuntime | None' = None,
config_runtime: 'ConfigRuntime | None' = None,
mcp_runtime: 'MCPRuntime | None' = None,
remote_runtime: 'RemoteRuntime | None' = None,
plan_runtime: 'PlanRuntime | None' = None,
task_runtime: 'TaskRuntime | None' = None,
team_runtime: 'TeamRuntime | None' = None,
) -> ToolExecutionContext:
return ToolExecutionContext(
root=config.cwd.resolve(),
@@ -129,11 +135,13 @@ def build_tool_context(
tool_registry=tool_registry,
search_runtime=search_runtime,
account_runtime=account_runtime,
ask_user_runtime=ask_user_runtime,
config_runtime=config_runtime,
mcp_runtime=mcp_runtime,
remote_runtime=remote_runtime,
plan_runtime=plan_runtime,
task_runtime=task_runtime,
team_runtime=team_runtime,
)
@@ -238,6 +246,22 @@ def default_tool_registry() -> dict[str, AgentTool]:
},
handler=_edit_file,
),
AgentTool(
name='notebook_edit',
description='Edit a Jupyter notebook cell by replacing or appending source in a .ipynb file.',
parameters={
'type': 'object',
'properties': {
'path': {'type': 'string'},
'cell_index': {'type': 'integer', 'minimum': 0},
'source': {'type': 'string'},
'cell_type': {'type': 'string'},
'create_cell': {'type': 'boolean'},
},
'required': ['path', 'cell_index', 'source'],
},
handler=_notebook_edit,
),
AgentTool(
name='glob_search',
description='Find files matching a glob pattern inside the workspace.',
@@ -368,6 +392,25 @@ def default_tool_registry() -> dict[str, AgentTool]:
},
handler=_sleep,
),
AgentTool(
name='ask_user_question',
description='Request an answer from the local ask-user runtime using queued or interactive answers.',
parameters={
'type': 'object',
'properties': {
'question': {'type': 'string'},
'header': {'type': 'string'},
'question_id': {'type': 'string'},
'choices': {
'type': 'array',
'items': {'type': 'string'},
},
'allow_free_text': {'type': 'boolean'},
},
'required': ['question'],
},
handler=_ask_user_question,
),
AgentTool(
name='account_status',
description='Show local account runtime summary or a specific configured account profile.',
@@ -631,6 +674,85 @@ def default_tool_registry() -> dict[str, AgentTool]:
},
handler=_task_next,
),
AgentTool(
name='team_list',
description='List locally configured collaboration teams.',
parameters={
'type': 'object',
'properties': {
'query': {'type': 'string'},
'max_teams': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_team_list,
),
AgentTool(
name='team_get',
description='Show a locally configured collaboration team by name.',
parameters={
'type': 'object',
'properties': {
'team_name': {'type': 'string'},
},
'required': ['team_name'],
},
handler=_team_get,
),
AgentTool(
name='team_create',
description='Create a locally stored collaboration team.',
parameters={
'type': 'object',
'properties': {
'team_name': {'type': 'string'},
'description': {'type': 'string'},
'members': {'type': 'array', 'items': {'type': 'string'}},
'metadata': {'type': 'object'},
},
'required': ['team_name'],
},
handler=_team_create,
),
AgentTool(
name='team_delete',
description='Delete a locally stored collaboration team and its recorded messages.',
parameters={
'type': 'object',
'properties': {
'team_name': {'type': 'string'},
},
'required': ['team_name'],
},
handler=_team_delete,
),
AgentTool(
name='send_message',
description='Send a local collaboration message to a team or teammate and persist it in the team runtime.',
parameters={
'type': 'object',
'properties': {
'team_name': {'type': 'string'},
'message': {'type': 'string'},
'sender': {'type': 'string'},
'recipient': {'type': 'string'},
'metadata': {'type': 'object'},
},
'required': ['team_name', 'message'],
},
handler=_send_message,
),
AgentTool(
name='team_messages',
description='Show locally recorded collaboration messages for all teams or one team.',
parameters={
'type': 'object',
'properties': {
'team_name': {'type': 'string'},
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 200},
},
},
handler=_team_messages,
),
AgentTool(
name='task_list',
description='List locally stored runtime tasks.',
@@ -1047,6 +1169,85 @@ def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
)
def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False)
if target.suffix != '.ipynb':
raise ToolExecutionError('notebook_edit requires a .ipynb target')
if not target.is_file():
raise ToolExecutionError(f'Path is not a file: {target}')
cell_index = arguments.get('cell_index')
if isinstance(cell_index, bool) or not isinstance(cell_index, int) or cell_index < 0:
raise ToolExecutionError('cell_index must be an integer >= 0')
source = arguments.get('source')
if not isinstance(source, str):
raise ToolExecutionError('source must be a string')
cell_type = arguments.get('cell_type', 'code')
if cell_type is not None and not isinstance(cell_type, str):
raise ToolExecutionError('cell_type must be a string')
create_cell = arguments.get('create_cell', False)
if not isinstance(create_cell, bool):
raise ToolExecutionError('create_cell must be a boolean')
raw = target.read_text(encoding='utf-8', errors='replace')
before_sha256 = hashlib.sha256(raw.encode('utf-8')).hexdigest()
try:
notebook = json.loads(raw)
except json.JSONDecodeError as exc:
raise ToolExecutionError(f'Notebook is not valid JSON: {target}') from exc
if not isinstance(notebook, dict):
raise ToolExecutionError('Notebook payload must be a JSON object')
cells = notebook.get('cells')
if not isinstance(cells, list):
raise ToolExecutionError('Notebook does not contain a cells array')
while len(cells) <= cell_index:
if not create_cell:
raise ToolExecutionError(
f'Notebook cell {cell_index} does not exist; pass create_cell=true to append missing cells'
)
cells.append(
{
'cell_type': cell_type or 'code',
'metadata': {},
'source': [],
'outputs': [],
'execution_count': None,
}
)
cell = cells[cell_index]
if not isinstance(cell, dict):
raise ToolExecutionError(f'Notebook cell {cell_index} is not a JSON object')
existing_type = cell.get('cell_type')
if not isinstance(existing_type, str):
existing_type = 'code'
source_lines = source.splitlines(keepends=True)
if source and not source.endswith('\n'):
source_lines = [*source_lines[:-1], source_lines[-1]]
cell['cell_type'] = cell_type or existing_type
cell['source'] = source_lines
if cell['cell_type'] == 'code':
cell.setdefault('outputs', [])
cell.setdefault('execution_count', None)
updated = json.dumps(notebook, ensure_ascii=True, indent=1) + '\n'
target.write_text(updated, encoding='utf-8')
after_sha256 = hashlib.sha256(updated.encode('utf-8')).hexdigest()
rel = target.relative_to(context.root)
return (
f'updated notebook cell {cell_index} in {rel}',
{
'action': 'notebook_edit',
'path': str(rel),
'cell_index': cell_index,
'cell_type': cell['cell_type'],
'before_sha256': before_sha256,
'after_sha256': after_sha256,
'before_preview': _snapshot_text(raw),
'after_preview': _snapshot_text(updated),
},
)
def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
pattern = _require_string(arguments, 'pattern')
matches = sorted(context.root.glob(pattern))
@@ -1321,6 +1522,69 @@ def _sleep(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
)
def _ask_user_question(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_ask_user_runtime(context)
question = _require_string(arguments, 'question')
header = arguments.get('header')
question_id = arguments.get('question_id')
if header is not None and not isinstance(header, str):
raise ToolExecutionError('header must be a string')
if question_id is not None and not isinstance(question_id, str):
raise ToolExecutionError('question_id must be a string')
raw_choices = arguments.get('choices', ())
if raw_choices is None:
raw_choices = ()
if not isinstance(raw_choices, (list, tuple)):
raise ToolExecutionError('choices must be an array of strings')
choices = tuple(
item.strip()
for item in raw_choices
if isinstance(item, str) and item.strip()
)
if len(choices) != len(raw_choices):
raise ToolExecutionError('choices must contain only non-empty strings')
allow_free_text = arguments.get('allow_free_text', True)
if not isinstance(allow_free_text, bool):
raise ToolExecutionError('allow_free_text must be a boolean')
try:
response = runtime.answer(
question=question,
choices=choices,
question_id=question_id,
header=header,
allow_free_text=allow_free_text,
)
except LookupError as exc:
raise ToolExecutionError(str(exc)) from exc
lines = ['# Ask User', '']
if header:
lines.append(f'- Header: {header}')
if question_id:
lines.append(f'- Question ID: {question_id}')
lines.append(f'- Question: {question}')
if choices:
lines.append('- Choices: ' + ', '.join(choices))
lines.extend(
[
f'- Source: {response.source}',
'',
response.answer,
]
)
return (
'\n'.join(lines),
{
'action': 'ask_user_question',
'question': question,
'question_id': question_id,
'header': header,
'source': response.source,
'answer_preview': _snapshot_text(response.answer),
'choices': list(choices),
},
)
def _account_status(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_account_runtime(context)
profile = arguments.get('profile')
@@ -1579,6 +1843,125 @@ def _remote_disconnect(
)
def _team_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_team_runtime(context)
query = arguments.get('query')
if query is not None and not isinstance(query, str):
raise ToolExecutionError('query must be a string')
max_teams = _coerce_int(arguments, 'max_teams', 50)
return runtime.render_teams_index(query=query, limit=max_teams)
def _team_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_team_runtime(context)
team_name = _require_string(arguments, 'team_name')
try:
return runtime.render_team(team_name)
except KeyError as exc:
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
def _team_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_team_runtime(context)
team_name = _require_string(arguments, 'team_name')
description = arguments.get('description')
members = arguments.get('members', ())
metadata = arguments.get('metadata')
if description is not None and not isinstance(description, str):
raise ToolExecutionError('description must be a string')
if not isinstance(members, (list, tuple)):
raise ToolExecutionError('members must be an array of strings')
if metadata is not None and not isinstance(metadata, dict):
raise ToolExecutionError('metadata must be an object')
try:
team = runtime.create_team(
team_name,
description=description,
members=members,
metadata=metadata,
)
except KeyError as exc:
raise ToolExecutionError(f'Team already exists: {team_name}') from exc
return (
f'created team {team.name}',
{
'action': 'team_create',
'team_name': team.name,
'member_count': len(team.members),
'path': str(runtime.state_path),
},
)
def _team_delete(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_team_runtime(context)
team_name = _require_string(arguments, 'team_name')
try:
team = runtime.delete_team(team_name)
except KeyError as exc:
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
return (
f'deleted team {team.name}',
{
'action': 'team_delete',
'team_name': team.name,
'path': str(runtime.state_path),
},
)
def _send_message(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context)
runtime = _require_team_runtime(context)
team_name = _require_string(arguments, 'team_name')
message = _require_string(arguments, 'message')
sender = arguments.get('sender', 'agent')
recipient = arguments.get('recipient')
metadata = arguments.get('metadata')
if sender is not None and not isinstance(sender, str):
raise ToolExecutionError('sender must be a string')
if recipient is not None and not isinstance(recipient, str):
raise ToolExecutionError('recipient must be a string')
if metadata is not None and not isinstance(metadata, dict):
raise ToolExecutionError('metadata must be an object')
try:
stored = runtime.send_message(
team_name=team_name,
text=message,
sender=sender or 'agent',
recipient=recipient,
metadata=metadata,
)
except KeyError as exc:
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
return (
f'sent message to team {stored.team_name}',
{
'action': 'send_message',
'team_name': stored.team_name,
'sender': stored.sender,
'recipient': stored.recipient,
'message_id': stored.message_id,
'message_preview': _snapshot_text(stored.text),
'path': str(runtime.state_path),
},
)
def _team_messages(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_team_runtime(context)
team_name = arguments.get('team_name')
if team_name is not None and not isinstance(team_name, str):
raise ToolExecutionError('team_name must be a string')
limit = _coerce_int(arguments, 'limit', 20)
try:
return runtime.render_messages(team_name=team_name, limit=limit)
except KeyError as exc:
raise ToolExecutionError(f'Unknown team: {team_name}') from exc
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
runtime = _require_task_runtime(context)
status = arguments.get('status')
@@ -2048,6 +2431,12 @@ def _require_account_runtime(context: ToolExecutionContext):
return context.account_runtime
def _require_ask_user_runtime(context: ToolExecutionContext):
if context.ask_user_runtime is None:
raise ToolExecutionError('No local ask-user runtime is available.')
return context.ask_user_runtime
def _require_search_runtime(context: ToolExecutionContext):
if context.search_runtime is None or not context.search_runtime.has_search_runtime():
raise ToolExecutionError(
@@ -2095,6 +2484,12 @@ def _require_task_runtime(context: ToolExecutionContext):
return context.task_runtime
def _require_team_runtime(context: ToolExecutionContext):
if context.team_runtime is None:
raise ToolExecutionError('Local team runtime is not available.')
return context.team_runtime
def _task_mutation_metadata(
*,
action: str,
+320
View File
@@ -0,0 +1,320 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
DEFAULT_ASK_USER_STATE_FILE = Path('.port_sessions') / 'ask_user_runtime.json'
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class QueuedUserAnswer:
answer: str
question: str | None = None
question_id: str | None = None
header: str | None = None
match: str = 'exact'
consume: bool = True
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'QueuedUserAnswer | None':
answer = payload.get('answer')
if not isinstance(answer, str) or not answer.strip():
return None
question = payload.get('question')
if question is not None and not isinstance(question, str):
question = None
question_id = payload.get('question_id')
if question_id is not None and not isinstance(question_id, str):
question_id = None
header = payload.get('header')
if header is not None and not isinstance(header, str):
header = None
match = payload.get('match')
if not isinstance(match, str) or match not in {'exact', 'contains'}:
match = 'exact'
consume = payload.get('consume', True)
if not isinstance(consume, bool):
consume = True
return cls(
answer=answer,
question=question.strip() if question else None,
question_id=question_id.strip() if question_id else None,
header=header.strip() if header else None,
match=match,
consume=consume,
)
def matches(
self,
*,
question: str,
question_id: str | None,
header: str | None,
) -> bool:
if question_id and self.question_id and self.question_id == question_id:
return True
if header and self.header and self.header.lower() == header.lower():
return True
if self.question is None:
return False
if self.match == 'contains':
return self.question.lower() in question.lower()
return self.question.strip().lower() == question.strip().lower()
def to_dict(self) -> dict[str, Any]:
return {
'answer': self.answer,
'question': self.question,
'question_id': self.question_id,
'header': self.header,
'match': self.match,
'consume': self.consume,
}
@dataclass(frozen=True)
class AskUserResponse:
answer: str
source: str
matched_question: str | None = None
question_id: str | None = None
header: str | None = None
@dataclass
class AskUserRuntime:
cwd: Path
queued_answers: tuple[QueuedUserAnswer, ...] = field(default_factory=tuple)
history: tuple[dict[str, Any], ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_ASK_USER_STATE_FILE.resolve())
interactive: bool = False
input_func: Callable[[str], str] | None = None
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
*,
interactive: bool | None = None,
input_func: Callable[[str], str] | None = None,
) -> 'AskUserRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
manifest_answers: list[QueuedUserAnswer] = []
for manifest_path in manifest_paths:
manifest_answers.extend(_load_answers_from_manifest(manifest_path))
state_path = cwd.resolve() / DEFAULT_ASK_USER_STATE_FILE
payload = _load_state_payload(state_path)
queued_payload = payload.get('queued_answers')
if isinstance(queued_payload, list):
queued_answers = tuple(
answer
for answer in (
QueuedUserAnswer.from_dict(item)
for item in queued_payload
if isinstance(item, dict)
)
if answer is not None
)
else:
queued_answers = tuple(manifest_answers)
history_payload = payload.get('history')
history = tuple(item for item in history_payload if isinstance(item, dict)) if isinstance(history_payload, list) else ()
if interactive is None:
env_value = os.environ.get('CLAW_ASK_USER_INTERACTIVE', '')
interactive = env_value.strip().lower() in {'1', 'true', 'yes', 'on'}
return cls(
cwd=cwd.resolve(),
queued_answers=queued_answers,
history=history,
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
interactive=bool(interactive),
input_func=input_func,
)
def has_state(self) -> bool:
return bool(self.queued_answers or self.history or self.manifests)
def answer(
self,
*,
question: str,
choices: tuple[str, ...] = (),
question_id: str | None = None,
header: str | None = None,
allow_free_text: bool = True,
) -> AskUserResponse:
for index, entry in enumerate(self.queued_answers):
if not entry.matches(question=question, question_id=question_id, header=header):
continue
if entry.consume:
queued_answers = list(self.queued_answers)
queued_answers.pop(index)
self.queued_answers = tuple(queued_answers)
response = AskUserResponse(
answer=entry.answer,
source='queued',
matched_question=entry.question,
question_id=question_id or entry.question_id,
header=header or entry.header,
)
self._record_history(question, response, choices=choices)
self._persist_state()
return response
if self.interactive and self.input_func is not None:
prompt_lines = ['# Ask User']
if header:
prompt_lines.append(f'header={header}')
if question_id:
prompt_lines.append(f'question_id={question_id}')
prompt_lines.append(question)
if choices:
prompt_lines.append('choices=' + ', '.join(choices))
raw_answer = self.input_func('\n'.join(prompt_lines) + '\nanswer> ')
answer = raw_answer.strip()
if not answer:
raise LookupError('Interactive ask-user prompt returned an empty answer.')
if choices and not allow_free_text and answer not in choices:
raise LookupError(
'Interactive answer did not match the allowed choices: '
+ ', '.join(choices)
)
response = AskUserResponse(
answer=answer,
source='interactive',
question_id=question_id,
header=header,
)
self._record_history(question, response, choices=choices)
self._persist_state()
return response
raise LookupError(
'No queued ask-user answer is available. '
'Add .claw-ask-user.json or enable CLAW_ASK_USER_INTERACTIVE=1 for interactive prompting.'
)
def render_summary(self) -> str:
lines = [
f'Ask-user manifests: {len(self.manifests)}',
f'Queued answers: {len(self.queued_answers)}',
f'History entries: {len(self.history)}',
f'Interactive mode: {self.interactive}',
]
if self.queued_answers:
lines.append('- Pending queued answers:')
for entry in self.queued_answers[:10]:
label = entry.question_id or entry.header or entry.question or '(wildcard answer)'
lines.append(f' - {label}')
if len(self.queued_answers) > 10:
lines.append(f' - ... plus {len(self.queued_answers) - 10} more')
return '\n'.join(lines)
def render_history(self, *, limit: int = 20) -> str:
lines = ['# Ask User History', '']
entries = list(self.history[-limit:])
if not entries:
lines.append('No ask-user interactions recorded.')
return '\n'.join(lines)
for entry in entries:
lines.append(f"- {entry.get('created_at', '(unknown time)')} :: {entry.get('question', '(unknown question)')}")
lines.append(f" - answer={entry.get('answer', '')}")
lines.append(f" - source={entry.get('source', 'unknown')}")
if entry.get('choices'):
lines.append(' - choices=' + ', '.join(entry['choices']))
return '\n'.join(lines)
def _record_history(
self,
question: str,
response: AskUserResponse,
*,
choices: tuple[str, ...],
) -> None:
entry = {
'question': question,
'answer': response.answer,
'source': response.source,
'question_id': response.question_id,
'header': response.header,
'choices': list(choices),
'created_at': _utc_now(),
}
self.history = (*self.history, entry)
def _persist_state(self) -> None:
payload = {
'queued_answers': [answer.to_dict() for answer in self.queued_answers],
'history': list(self.history),
}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2),
encoding='utf-8',
)
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
candidates = [
cwd.resolve() / '.claw-ask-user.json',
cwd.resolve() / '.claude' / 'ask-user.json',
]
for raw_path in additional_working_directories:
root = Path(raw_path).resolve()
candidates.extend(
[
root / '.claw-ask-user.json',
root / '.claude' / 'ask-user.json',
]
)
discovered: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
if not candidate.is_file():
continue
resolved = candidate.resolve()
if resolved in seen:
continue
seen.add(resolved)
discovered.append(resolved)
return tuple(discovered)
def _load_answers_from_manifest(path: Path) -> list[QueuedUserAnswer]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
answers_payload = payload.get('answers')
if not isinstance(answers_payload, list):
return []
answers: list[QueuedUserAnswer] = []
for item in answers_payload:
if not isinstance(item, dict):
continue
answer = QueuedUserAnswer.from_dict(item)
if answer is not None:
answers.append(answer)
return answers
def _load_state_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
+83
View File
@@ -10,6 +10,7 @@ from typing import Callable
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
from .account_runtime import AccountRuntime
from .ask_user_runtime import AskUserRuntime
from .agent_runtime import LocalCodingAgent
from .agent_types import (
AgentPermissions,
@@ -37,6 +38,7 @@ from .remote_runtime import (
run_teleport_mode,
)
from .search_runtime import SearchRuntime
from .team_runtime import TeamRuntime
from .runtime import PortRuntime
from .session_store import (
StoredAgentSession,
@@ -613,6 +615,10 @@ def build_parser() -> argparse.ArgumentParser:
account_login_parser.add_argument('--cwd', default='.')
account_logout_parser = subparsers.add_parser('account-logout', help='clear the active local account session')
account_logout_parser.add_argument('--cwd', default='.')
ask_status_parser = subparsers.add_parser('ask-status', help='show local ask-user runtime status')
ask_status_parser.add_argument('--cwd', default='.')
ask_history_parser = subparsers.add_parser('ask-history', help='show local ask-user interaction history')
ask_history_parser.add_argument('--cwd', default='.')
search_status_parser = subparsers.add_parser('search-status', help='show local search runtime status')
search_status_parser.add_argument('--cwd', default='.')
search_status_parser.add_argument('--provider')
@@ -661,6 +667,25 @@ def build_parser() -> argparse.ArgumentParser:
config_set_parser.add_argument('value_json')
config_set_parser.add_argument('--source', default='local')
config_set_parser.add_argument('--cwd', default='.')
teams_status_parser = subparsers.add_parser('team-status', help='show local collaboration team runtime summary')
teams_status_parser.add_argument('--cwd', default='.')
teams_list_parser = subparsers.add_parser('team-list', help='list local collaboration teams')
teams_list_parser.add_argument('--cwd', default='.')
teams_list_parser.add_argument('--query')
team_get_parser = subparsers.add_parser('team-get', help='show one local collaboration team')
team_get_parser.add_argument('team_name')
team_get_parser.add_argument('--cwd', default='.')
team_create_parser = subparsers.add_parser('team-create', help='create a local collaboration team')
team_create_parser.add_argument('team_name')
team_create_parser.add_argument('--description')
team_create_parser.add_argument('--member', action='append', default=[])
team_create_parser.add_argument('--cwd', default='.')
team_delete_parser = subparsers.add_parser('team-delete', help='delete a local collaboration team')
team_delete_parser.add_argument('team_name')
team_delete_parser.add_argument('--cwd', default='.')
team_messages_parser = subparsers.add_parser('team-messages', help='show local team messages')
team_messages_parser.add_argument('--team-name')
team_messages_parser.add_argument('--cwd', default='.')
show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name')
show_command.add_argument('name')
@@ -905,6 +930,16 @@ def main(argv: list[str] | None = None) -> int:
runtime = AccountRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.logout().as_text())
return 0
if args.command == 'ask-status':
runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve())
print('# Ask User')
print()
print(runtime.render_summary())
return 0
if args.command == 'ask-history':
runtime = AskUserRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_history())
return 0
if args.command == 'search-status':
runtime = SearchRuntime.from_workspace(Path(args.cwd).resolve())
if args.provider:
@@ -1003,6 +1038,54 @@ def main(argv: list[str] | None = None) -> int:
print(f'effective_key_count={mutation.effective_key_count}')
print(runtime.render_value(args.key_path))
return 0
if args.command == 'team-status':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
print('# Teams')
print()
print(runtime.render_summary())
return 0
if args.command == 'team-list':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
print(runtime.render_teams_index(query=args.query))
return 0
if args.command == 'team-get':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
try:
print(runtime.render_team(args.team_name))
except KeyError:
print(f'Unknown team: {args.team_name}')
return 1
return 0
if args.command == 'team-create':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
try:
team = runtime.create_team(
args.team_name,
description=args.description,
members=args.member,
)
except KeyError:
print(f'Team already exists: {args.team_name}')
return 1
print(f'created team {team.name}')
return 0
if args.command == 'team-delete':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
try:
team = runtime.delete_team(args.team_name)
except KeyError:
print(f'Unknown team: {args.team_name}')
return 1
print(f'deleted team {team.name}')
return 0
if args.command == 'team-messages':
runtime = TeamRuntime.from_workspace(Path(args.cwd).resolve())
try:
print(runtime.render_messages(team_name=args.team_name))
except KeyError:
print(f'Unknown team: {args.team_name}')
return 1
return 0
if args.command == 'show-command':
module = get_command(args.name)
if module is None:
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
DEFAULT_TEAM_STATE_FILE = Path('.port_sessions') / 'team_runtime.json'
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class TeamDefinition:
name: str
description: str | None = None
members: tuple[str, ...] = ()
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'TeamDefinition | None':
name = payload.get('name')
if not isinstance(name, str) or not name.strip():
return None
description = payload.get('description')
if description is not None and not isinstance(description, str):
description = None
raw_members = payload.get('members', ())
members = tuple(
item.strip()
for item in raw_members
if isinstance(item, str) and item.strip()
) if isinstance(raw_members, (list, tuple)) else ()
metadata = payload.get('metadata', {})
if not isinstance(metadata, dict):
metadata = {}
created_at = payload.get('created_at')
if not isinstance(created_at, str) or not created_at.strip():
created_at = _utc_now()
return cls(
name=name.strip(),
description=description.strip() if description else None,
members=members,
metadata=dict(metadata),
created_at=created_at,
)
def to_dict(self) -> dict[str, Any]:
return {
'name': self.name,
'description': self.description,
'members': list(self.members),
'metadata': dict(self.metadata),
'created_at': self.created_at,
}
@dataclass(frozen=True)
class TeamMessage:
message_id: str
team_name: str
sender: str
text: str
recipient: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'TeamMessage | None':
message_id = payload.get('message_id')
team_name = payload.get('team_name')
sender = payload.get('sender')
text = payload.get('text')
if not all(isinstance(value, str) and value.strip() for value in (message_id, team_name, sender, text)):
return None
recipient = payload.get('recipient')
if recipient is not None and not isinstance(recipient, str):
recipient = None
metadata = payload.get('metadata', {})
if not isinstance(metadata, dict):
metadata = {}
created_at = payload.get('created_at')
if not isinstance(created_at, str) or not created_at.strip():
created_at = _utc_now()
return cls(
message_id=message_id.strip(),
team_name=team_name.strip(),
sender=sender.strip(),
text=text,
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
metadata=dict(metadata),
created_at=created_at,
)
def to_dict(self) -> dict[str, Any]:
return {
'message_id': self.message_id,
'team_name': self.team_name,
'sender': self.sender,
'text': self.text,
'recipient': self.recipient,
'metadata': dict(self.metadata),
'created_at': self.created_at,
}
@dataclass
class TeamRuntime:
cwd: Path
teams: tuple[TeamDefinition, ...] = field(default_factory=tuple)
messages: tuple[TeamMessage, ...] = field(default_factory=tuple)
manifests: tuple[str, ...] = field(default_factory=tuple)
state_path: Path = field(default_factory=lambda: DEFAULT_TEAM_STATE_FILE.resolve())
@classmethod
def from_workspace(
cls,
cwd: Path,
additional_working_directories: tuple[str, ...] = (),
) -> 'TeamRuntime':
manifest_paths = _discover_manifest_paths(cwd, additional_working_directories)
manifest_teams: list[TeamDefinition] = []
for manifest_path in manifest_paths:
manifest_teams.extend(_load_teams_from_manifest(manifest_path))
state_path = cwd.resolve() / DEFAULT_TEAM_STATE_FILE
payload = _load_state_payload(state_path)
raw_teams = payload.get('teams')
raw_messages = payload.get('messages')
if isinstance(raw_teams, list):
teams = tuple(
team
for team in (
TeamDefinition.from_dict(item)
for item in raw_teams
if isinstance(item, dict)
)
if team is not None
)
else:
teams = tuple(manifest_teams)
messages = tuple(
message
for message in (
TeamMessage.from_dict(item)
for item in raw_messages
if isinstance(item, dict)
)
if message is not None
) if isinstance(raw_messages, list) else ()
return cls(
cwd=cwd.resolve(),
teams=teams,
messages=messages,
manifests=tuple(str(path) for path in manifest_paths),
state_path=state_path,
)
def has_team_state(self) -> bool:
return bool(self.teams or self.messages or self.manifests)
def list_teams(
self,
*,
query: str | None = None,
limit: int | None = None,
) -> tuple[TeamDefinition, ...]:
teams = self.teams
if query:
needle = query.lower()
teams = tuple(
team
for team in teams
if needle in team.name.lower()
or needle in (team.description or '').lower()
or any(needle in member.lower() for member in team.members)
)
teams = tuple(sorted(teams, key=lambda team: team.name.lower()))
if limit is not None and limit >= 0:
teams = teams[:limit]
return teams
def get_team(self, name: str) -> TeamDefinition | None:
needle = name.strip().lower()
for team in self.teams:
if team.name.lower() == needle:
return team
return None
def create_team(
self,
name: str,
*,
description: str | None = None,
members: tuple[str, ...] | list[str] = (),
metadata: dict[str, Any] | None = None,
) -> TeamDefinition:
normalized = name.strip()
if not normalized:
raise KeyError('team_name')
if self.get_team(normalized) is not None:
raise KeyError(normalized)
team = TeamDefinition(
name=normalized,
description=description.strip() if isinstance(description, str) and description.strip() else None,
members=tuple(
member.strip()
for member in members
if isinstance(member, str) and member.strip()
),
metadata=dict(metadata or {}),
)
self.teams = tuple(sorted((*self.teams, team), key=lambda item: item.name.lower()))
self._persist_state()
return team
def delete_team(self, name: str) -> TeamDefinition:
team = self.get_team(name)
if team is None:
raise KeyError(name)
self.teams = tuple(existing for existing in self.teams if existing.name != team.name)
self.messages = tuple(message for message in self.messages if message.team_name != team.name)
self._persist_state()
return team
def send_message(
self,
*,
team_name: str,
text: str,
sender: str,
recipient: str | None = None,
metadata: dict[str, Any] | None = None,
) -> TeamMessage:
team = self.get_team(team_name)
if team is None:
raise KeyError(team_name)
message = TeamMessage(
message_id=f'msg_{uuid4().hex[:10]}',
team_name=team.name,
sender=sender.strip(),
text=text,
recipient=recipient.strip() if isinstance(recipient, str) and recipient.strip() else None,
metadata=dict(metadata or {}),
)
self.messages = (*self.messages, message)
self._persist_state()
return message
def render_summary(self) -> str:
lines = [
f'Configured teams: {len(self.teams)}',
f'Message history entries: {len(self.messages)}',
f'Team manifests: {len(self.manifests)}',
]
if self.teams:
lines.append('- Teams:')
for team in self.list_teams(limit=10):
lines.append(f' - {team.name} ({len(team.members)} members)')
if len(self.teams) > 10:
lines.append(f' - ... plus {len(self.teams) - 10} more')
return '\n'.join(lines)
def render_team(self, name: str) -> str:
team = self.get_team(name)
if team is None:
raise KeyError(name)
lines = ['# Team', '', f'- Name: {team.name}']
if team.description:
lines.append(f'- Description: {team.description}')
lines.append(f'- Members: {len(team.members)}')
if team.members:
lines.extend(f' - {member}' for member in team.members)
recent_messages = [message for message in self.messages if message.team_name == team.name][-5:]
if recent_messages:
lines.extend(['', '## Recent Messages'])
for message in recent_messages:
target = f' -> {message.recipient}' if message.recipient else ''
lines.append(f'- {message.sender}{target}: {message.text}')
return '\n'.join(lines)
def render_teams_index(self, *, query: str | None = None, limit: int = 50) -> str:
lines = ['# Teams', '']
teams = self.list_teams(query=query, limit=limit)
if not teams:
lines.append('No local teams are configured.')
return '\n'.join(lines)
for team in teams:
details = [team.name]
if team.description:
details.append(team.description)
if team.members:
details.append(f'members={",".join(team.members)}')
lines.append('- ' + ' ; '.join(details))
return '\n'.join(lines)
def render_messages(self, *, team_name: str | None = None, limit: int = 20) -> str:
lines = ['# Team Messages', '']
messages = list(self.messages)
if team_name:
team = self.get_team(team_name)
if team is None:
raise KeyError(team_name)
messages = [message for message in messages if message.team_name == team.name]
messages = messages[-limit:]
if not messages:
lines.append('No team messages recorded.')
return '\n'.join(lines)
for message in messages:
target = f' -> {message.recipient}' if message.recipient else ''
lines.append(
f'- [{message.team_name}] {message.sender}{target}: {message.text}'
)
return '\n'.join(lines)
def _persist_state(self) -> None:
payload = {
'teams': [team.to_dict() for team in self.teams],
'messages': [message.to_dict() for message in self.messages],
}
self.state_path.parent.mkdir(parents=True, exist_ok=True)
self.state_path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2),
encoding='utf-8',
)
def _discover_manifest_paths(cwd: Path, additional_working_directories: tuple[str, ...]) -> tuple[Path, ...]:
candidates = [
cwd.resolve() / '.claw-teams.json',
cwd.resolve() / '.claw-team.json',
cwd.resolve() / '.claude' / 'teams.json',
]
for raw_path in additional_working_directories:
root = Path(raw_path).resolve()
candidates.extend(
[
root / '.claw-teams.json',
root / '.claw-team.json',
root / '.claude' / 'teams.json',
]
)
discovered: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
if not candidate.is_file():
continue
resolved = candidate.resolve()
if resolved in seen:
continue
seen.add(resolved)
discovered.append(resolved)
return tuple(discovered)
def _load_teams_from_manifest(path: Path) -> list[TeamDefinition]:
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return []
teams_payload = payload.get('teams')
if not isinstance(teams_payload, list):
return []
teams: list[TeamDefinition] = []
for item in teams_payload:
if not isinstance(item, dict):
continue
team = TeamDefinition.from_dict(item)
if team is not None:
teams.append(team)
return teams
def _load_state_payload(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}