@scemoon/cdh 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai-dlc-skill/SKILL.md +75 -0
- package/ai-dlc-skill/agents/deliver-agent.md +24 -0
- package/ai-dlc-skill/agents/master.md +44 -0
- package/ai-dlc-skill/agents/plan-agent.md +23 -0
- package/ai-dlc-skill/agents/understand-agent.md +23 -0
- package/ai-dlc-skill/agents/verify-agent.md +27 -0
- package/ai-dlc-skill/architecture/project-structure.md +78 -0
- package/ai-dlc-skill/brownfield/README.md +30 -0
- package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
- package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
- package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
- package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
- package/ai-dlc-skill/components/backend.md +34 -0
- package/ai-dlc-skill/components/desktop.md +30 -0
- package/ai-dlc-skill/components/mya.md +29 -0
- package/ai-dlc-skill/components/native.md +30 -0
- package/ai-dlc-skill/components/tta.md +29 -0
- package/ai-dlc-skill/components/web.md +31 -0
- package/ai-dlc-skill/components/wxa.md +29 -0
- package/ai-dlc-skill/contracts/README.md +25 -0
- package/ai-dlc-skill/core/adaptive-flow.md +36 -0
- package/ai-dlc-skill/core/security.md +29 -0
- package/ai-dlc-skill/cross-tool/cline/export.sh +31 -0
- package/ai-dlc-skill/cross-tool/copilot/export.sh +47 -0
- package/ai-dlc-skill/cross-tool/cursor/export.sh +32 -0
- package/ai-dlc-skill/cross-tool/export.sh +22 -0
- package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +17 -0
- package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +31 -0
- package/ai-dlc-skill/cross-tool/onecode/export.sh +48 -0
- package/ai-dlc-skill/phases/deliver/entry.md +26 -0
- package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
- package/ai-dlc-skill/phases/deliver/prompt.md +40 -0
- package/ai-dlc-skill/phases/deliver/rules.md +61 -0
- package/ai-dlc-skill/phases/plan/entry.md +25 -0
- package/ai-dlc-skill/phases/plan/lifecycle.md +204 -0
- package/ai-dlc-skill/phases/plan/prompt.md +38 -0
- package/ai-dlc-skill/phases/plan/rules.md +25 -0
- package/ai-dlc-skill/phases/understand/entry.md +26 -0
- package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
- package/ai-dlc-skill/phases/understand/prompt.md +41 -0
- package/ai-dlc-skill/phases/understand/rules.md +37 -0
- package/ai-dlc-skill/phases/verify/entry.md +26 -0
- package/ai-dlc-skill/phases/verify/lifecycle.md +192 -0
- package/ai-dlc-skill/phases/verify/prompt.md +43 -0
- package/ai-dlc-skill/phases/verify/rules.md +73 -0
- package/ai-dlc-skill/practices/bdd.md +91 -0
- package/ai-dlc-skill/practices/sdd.md +84 -0
- package/ai-dlc-skill/practices/tdd.md +95 -0
- package/ai-dlc-skill/providers/README.md +114 -0
- package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
- package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
- package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
- package/ai-dlc-skill/providers/tcb/deployment.yaml +69 -0
- package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
- package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
- package/ai-dlc-skill/requirements.md +140 -0
- package/ai-dlc-skill/skill.yaml +147 -0
- package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
- package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
- package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
- package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
- package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
- package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
- package/ai-dlc-skill/templates/plan/design.md +49 -0
- package/ai-dlc-skill/templates/plan/task-list.md +32 -0
- package/ai-dlc-skill/templates/project/README.md +127 -0
- package/ai-dlc-skill/templates/project/template.md +119 -0
- package/ai-dlc-skill/templates/understand/feature.feature +23 -0
- package/ai-dlc-skill/templates/understand/intent.md +29 -0
- package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
- package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
- package/ai-dlc-skill/walkthrough/collect.sh +50 -0
- package/ai-dlc-skill/walkthrough/template.md +54 -0
- package/ai-dlc-skill/workflows/ai-dlc.yaml +110 -0
- package/ai-dlc-skill/workflows/deployment.yaml +199 -0
- package/cdh/__init__.py +0 -0
- package/cdh/__main__.py +4 -0
- package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
- package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/cli.py +361 -0
- package/cdh/scaffold.py +312 -0
- package/cli.js +2 -0
- package/onecode/__init__.py +1 -0
- package/onecode/__main__.py +4 -0
- package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
- package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
- package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
- package/onecode/agent/__init__.py +0 -0
- package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__init__.py +29 -0
- package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/types.py +662 -0
- package/onecode/agent/attachments.py +113 -0
- package/onecode/agent/cdh_loader.py +262 -0
- package/onecode/agent/context.py +338 -0
- package/onecode/agent/engine.py +1894 -0
- package/onecode/agent/hooks.py +86 -0
- package/onecode/agent/onecode_agent_acp.py +2203 -0
- package/onecode/agent/permissions.py +151 -0
- package/onecode/agent/permissions_store.py +62 -0
- package/onecode/agent/session.py +186 -0
- package/onecode/agent/snapshot.py +145 -0
- package/onecode/agent/tools/__init__.py +17 -0
- package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/agent_tools.py +134 -0
- package/onecode/agent/tools/apply_patch_tool.py +144 -0
- package/onecode/agent/tools/bash_tool.py +91 -0
- package/onecode/agent/tools/communication_tools.py +149 -0
- package/onecode/agent/tools/config_tool.py +130 -0
- package/onecode/agent/tools/cron_tools.py +179 -0
- package/onecode/agent/tools/errors.py +17 -0
- package/onecode/agent/tools/file_ops.py +187 -0
- package/onecode/agent/tools/file_tools.py +226 -0
- package/onecode/agent/tools/git_tools.py +88 -0
- package/onecode/agent/tools/lsp_tools.py +404 -0
- package/onecode/agent/tools/mcp_tools.py +129 -0
- package/onecode/agent/tools/permission_handler.py +46 -0
- package/onecode/agent/tools/permissions.py +77 -0
- package/onecode/agent/tools/protocol.py +20 -0
- package/onecode/agent/tools/registry.py +134 -0
- package/onecode/agent/tools/sandbox.py +264 -0
- package/onecode/agent/tools/schema_validation.py +100 -0
- package/onecode/agent/tools/skill_tools.py +52 -0
- package/onecode/agent/tools/task_tools.py +249 -0
- package/onecode/agent/tools/web_tools.py +170 -0
- package/onecode/builtin_skills/git/SKILL.md +64 -0
- package/onecode/builtin_skills/git/skill.yaml +7 -0
- package/onecode/builtin_skills/shell/SKILL.md +58 -0
- package/onecode/builtin_skills/shell/skill.yaml +8 -0
- package/onecode/cli.py +755 -0
- package/onecode/cloud/__init__.py +0 -0
- package/onecode/cloud/base.py +20 -0
- package/onecode/codebase/__init__.py +69 -0
- package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
- package/onecode/codebase/chunker.py +130 -0
- package/onecode/codebase/indexer.py +136 -0
- package/onecode/codebase/retriever.py +192 -0
- package/onecode/codebase/storage.py +130 -0
- package/onecode/codebase/tools.py +59 -0
- package/onecode/commands.py +276 -0
- package/onecode/config.py +285 -0
- package/onecode/config_screen.py +594 -0
- package/onecode/cron/__init__.py +3 -0
- package/onecode/lsp/__init__.py +3 -0
- package/onecode/mcp/__init__.py +4 -0
- package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/mcp/client.py +193 -0
- package/onecode/mcp/manager.py +191 -0
- package/onecode/memory/__init__.py +95 -0
- package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/symbolic.cpython-314.pyc +0 -0
- package/onecode/memory/backend.py +185 -0
- package/onecode/memory/offload.py +114 -0
- package/onecode/memory/pyramid.py +202 -0
- package/onecode/memory/recall.py +168 -0
- package/onecode/memory/symbolic.py +164 -0
- package/onecode/models/__init__.py +4 -0
- package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/models/errors.py +94 -0
- package/onecode/models/messages.py +662 -0
- package/onecode/models/provider.py +450 -0
- package/onecode/models/providers/__init__.py +17 -0
- package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +224 -0
- package/onecode/models/providers/deepseek_provider.py +210 -0
- package/onecode/models/providers/glm_provider.py +154 -0
- package/onecode/models/providers/minimax_provider.py +154 -0
- package/onecode/models/providers/minimaxi_provider.py +363 -0
- package/onecode/models/providers/ollama_provider.py +96 -0
- package/onecode/models/providers/openai_provider.py +233 -0
- package/onecode/models/registry.py +212 -0
- package/onecode/server/__init__.py +3 -0
- package/onecode/server/app.py +224 -0
- package/onecode/skills/__init__.py +11 -0
- package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
- package/onecode/skills/argument_substitution.py +39 -0
- package/onecode/skills/create.py +56 -0
- package/onecode/skills/frontmatter.py +33 -0
- package/onecode/skills/loader.py +157 -0
- package/onecode/skills/manager.py +75 -0
- package/onecode/skills/model.py +43 -0
- package/onecode/storage/__init__.py +0 -0
- package/onecode/storage/activity.py +64 -0
- package/onecode/storage/project.py +54 -0
- package/onecode/storage/session.py +113 -0
- package/onecode/tasks/__init__.py +26 -0
- package/onecode/tasks/manager.py +219 -0
- package/onecode/tasks/models.py +240 -0
- package/onecode/trace/__init__.py +0 -0
- package/onecode/trace/tracer.py +174 -0
- package/onecode/utils/__init__.py +0 -0
- package/onecode/utils/helpers.py +20 -0
- package/onecode-cli.js +2 -0
- package/package.json +32 -0
- package/pyproject.toml +60 -0
- package/run.js +86 -0
- package/tui/__init__.py +46 -0
- package/tui/__main__.py +4 -0
- package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/agents.cpython-314.pyc +0 -0
- package/tui/__pycache__/answer.cpython-314.pyc +0 -0
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
- package/tui/__pycache__/complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/constants.cpython-314.pyc +0 -0
- package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
- package/tui/__pycache__/danger.cpython-314.pyc +0 -0
- package/tui/__pycache__/db.cpython-314.pyc +0 -0
- package/tui/__pycache__/dec.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
- package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
- package/tui/__pycache__/history.cpython-314.pyc +0 -0
- package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
- package/tui/__pycache__/menus.cpython-314.pyc +0 -0
- package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
- package/tui/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/__pycache__/pill.cpython-314.pyc +0 -0
- package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
- package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
- package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
- package/tui/__pycache__/version.cpython-314.pyc +0 -0
- package/tui/_loop.py +86 -0
- package/tui/_path_fuzzy_search.py +48 -0
- package/tui/_path_match.py +141 -0
- package/tui/about.py +95 -0
- package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/acp/agent.py +886 -0
- package/tui/acp/api.py +55 -0
- package/tui/acp/encode_tool_call_id.py +12 -0
- package/tui/acp/messages.py +190 -0
- package/tui/acp/prompt.py +110 -0
- package/tui/acp/protocol.py +462 -0
- package/tui/agent.py +70 -0
- package/tui/agent_schema.py +76 -0
- package/tui/agents.py +45 -0
- package/tui/ansi/__init__.py +1 -0
- package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
- package/tui/ansi/_ansi.py +1689 -0
- package/tui/ansi/_ansi_colors.py +264 -0
- package/tui/ansi/_control_codes.py +37 -0
- package/tui/ansi/_keys.py +251 -0
- package/tui/ansi/_sgr_styles.py +64 -0
- package/tui/ansi/_stream_parser.py +418 -0
- package/tui/answer.py +12 -0
- package/tui/app.py +1236 -0
- package/tui/atomic.py +37 -0
- package/tui/cli.py +313 -0
- package/tui/code_analyze.py +27 -0
- package/tui/complete.py +38 -0
- package/tui/constants.py +61 -0
- package/tui/conversation_markdown.py +30 -0
- package/tui/danger.py +372 -0
- package/tui/data/agents/ampcode.com.toml +51 -0
- package/tui/data/agents/augmentcode.com.toml +40 -0
- package/tui/data/agents/cdh.cloud-dev-harness.toml +22 -0
- package/tui/data/agents/claude.com.toml +41 -0
- package/tui/data/agents/copilot.github.com.toml +29 -0
- package/tui/data/agents/crowai.dev.toml +35 -0
- package/tui/data/agents/cursor.com.toml +38 -0
- package/tui/data/agents/deepagents.langchain.com +33 -0
- package/tui/data/agents/docker.com.toml +59 -0
- package/tui/data/agents/geminicli.com.toml +28 -0
- package/tui/data/agents/goose.ai.toml +51 -0
- package/tui/data/agents/inference.huggingface.co.toml +41 -0
- package/tui/data/agents/kimi.com.toml +35 -0
- package/tui/data/agents/kiro.dev.toml +42 -0
- package/tui/data/agents/openai.com.toml +53 -0
- package/tui/data/agents/openclaw.ai.toml +37 -0
- package/tui/data/agents/opencode.ai.toml +39 -0
- package/tui/data/agents/openhands.dev.toml +45 -0
- package/tui/data/agents/stakpak.dev.toml +61 -0
- package/tui/data/agents/vibe.mistral.ai.toml +30 -0
- package/tui/data/agents/vtcode.dev.toml +62 -0
- package/tui/data/images/frog.png +0 -0
- package/tui/data/sounds/question.wav +0 -0
- package/tui/data/sounds/turn-over.wav +0 -0
- package/tui/db.py +190 -0
- package/tui/dec.py +332 -0
- package/tui/directory.py +234 -0
- package/tui/directory_suggester.py +93 -0
- package/tui/directory_watcher.py +197 -0
- package/tui/format_path.py +25 -0
- package/tui/fuzzy.py +140 -0
- package/tui/fuzzy_index.py +275 -0
- package/tui/gist.py +33 -0
- package/tui/history.py +138 -0
- package/tui/jsonrpc.py +578 -0
- package/tui/menus.py +14 -0
- package/tui/message_log.py +69 -0
- package/tui/messages.py +139 -0
- package/tui/option_content.py +51 -0
- package/tui/os.py +0 -0
- package/tui/path_complete.py +217 -0
- package/tui/path_filter.py +124 -0
- package/tui/paths.py +71 -0
- package/tui/pill.py +23 -0
- package/tui/platform_commands.py +52 -0
- package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
- package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
- package/tui/prompt/extract.py +19 -0
- package/tui/prompt/resource.py +68 -0
- package/tui/protocol.py +28 -0
- package/tui/screens/__init__.py +0 -0
- package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
- package/tui/screens/action_modal.py +94 -0
- package/tui/screens/agent_modal.py +175 -0
- package/tui/screens/command_edit_modal.py +74 -0
- package/tui/screens/log.py +553 -0
- package/tui/screens/main.py +411 -0
- package/tui/screens/main.tcss +655 -0
- package/tui/screens/permissions.py +404 -0
- package/tui/screens/permissions.tcss +72 -0
- package/tui/screens/projects.tcss +66 -0
- package/tui/screens/projects_app.py +319 -0
- package/tui/screens/projects_screen.py +142 -0
- package/tui/screens/session_resume_modal.py +172 -0
- package/tui/screens/session_resume_modal.tcss +35 -0
- package/tui/screens/sessions.py +166 -0
- package/tui/screens/sessions.tcss +119 -0
- package/tui/screens/settings.py +254 -0
- package/tui/screens/settings.tcss +101 -0
- package/tui/screens/store.py +500 -0
- package/tui/screens/store.tcss +274 -0
- package/tui/screens/tool_content_screen.py +59 -0
- package/tui/session_tracker.py +120 -0
- package/tui/settings.py +353 -0
- package/tui/settings_schema.py +431 -0
- package/tui/shell.py +304 -0
- package/tui/shell_read.py +42 -0
- package/tui/slash_command.py +34 -0
- package/tui/tui.tcss +246 -0
- package/tui/twitter.py +38 -0
- package/tui/version.py +80 -0
- package/tui/visuals/__init__.py +0 -0
- package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
- package/tui/visuals/columns.py +273 -0
- package/tui/widgets/__init__.py +0 -0
- package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
- package/tui/widgets/acp_content.py +70 -0
- package/tui/widgets/agent_response.py +77 -0
- package/tui/widgets/agent_thought.py +168 -0
- package/tui/widgets/ask_user.py +264 -0
- package/tui/widgets/command_pane.py +223 -0
- package/tui/widgets/condensed_path.py +115 -0
- package/tui/widgets/context_stats.py +66 -0
- package/tui/widgets/conversation.py +2441 -0
- package/tui/widgets/danger_warning.py +65 -0
- package/tui/widgets/diff_view.py +59 -0
- package/tui/widgets/directory_input.py +8 -0
- package/tui/widgets/flash.py +81 -0
- package/tui/widgets/future_text.py +126 -0
- package/tui/widgets/grid_select.py +234 -0
- package/tui/widgets/highlighted_textarea.py +180 -0
- package/tui/widgets/mandelbrot.py +294 -0
- package/tui/widgets/markdown_note.py +13 -0
- package/tui/widgets/menu.py +147 -0
- package/tui/widgets/modified_files.py +106 -0
- package/tui/widgets/non_selectable_label.py +5 -0
- package/tui/widgets/note.py +18 -0
- package/tui/widgets/path_search.py +492 -0
- package/tui/widgets/plan.py +263 -0
- package/tui/widgets/project_directory_tree.py +90 -0
- package/tui/widgets/project_grid_select.py +35 -0
- package/tui/widgets/project_summary.py +33 -0
- package/tui/widgets/prompt.py +795 -0
- package/tui/widgets/question.py +360 -0
- package/tui/widgets/session_grid_select.py +224 -0
- package/tui/widgets/session_summary.py +115 -0
- package/tui/widgets/session_tabs.py +188 -0
- package/tui/widgets/shell_result.py +35 -0
- package/tui/widgets/shell_terminal.py +40 -0
- package/tui/widgets/side_bar.py +114 -0
- package/tui/widgets/slash_complete.py +235 -0
- package/tui/widgets/strike_text.py +70 -0
- package/tui/widgets/subagent.py +87 -0
- package/tui/widgets/terminal.py +577 -0
- package/tui/widgets/terminal_tool.py +351 -0
- package/tui/widgets/throbber.py +100 -0
- package/tui/widgets/tool_call.py +368 -0
- package/tui/widgets/user_input.py +23 -0
- package/tui/widgets/version.py +5 -0
- package/tui/widgets/welcome.py +31 -0
|
@@ -0,0 +1,2203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CDH ACP Adapter - Allows CDH Agent to communicate via ACP protocol.
|
|
3
|
+
|
|
4
|
+
This adapter runs as a subprocess and translates JSONRPC calls from A2TUI
|
|
5
|
+
into CDH AgentEngine calls.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("onecode.agent.cdh_acp")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _short_path(p: str, project_dir: Path | None = None) -> str:
|
|
22
|
+
"""Return *p* relative to *project_dir* if it is an absolute path under it."""
|
|
23
|
+
if not p or not project_dir:
|
|
24
|
+
return p
|
|
25
|
+
try:
|
|
26
|
+
resolved = Path(p).resolve()
|
|
27
|
+
return str(resolved.relative_to(project_dir.resolve()))
|
|
28
|
+
except (ValueError, OSError):
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def debug_log(*args, **kwargs):
|
|
33
|
+
"""Debug log via the ``onecode.agent.onecode_acp`` logger.
|
|
34
|
+
|
|
35
|
+
Falls back to stderr if the logging system has not been configured
|
|
36
|
+
yet (e.g. during very early adapter import when ``setup_logging`` has
|
|
37
|
+
not been called). This keeps the function safe to call from any
|
|
38
|
+
module-import-time path without breaking the JSON-RPC stream on stdout.
|
|
39
|
+
"""
|
|
40
|
+
if logger.handlers or logging.getLogger().handlers:
|
|
41
|
+
logger.debug(" ".join(str(a) for a in args), **kwargs)
|
|
42
|
+
else:
|
|
43
|
+
print("[onecode]", *args, file=sys.stderr, flush=True)
|
|
44
|
+
|
|
45
|
+
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
46
|
+
from onecode.agent.engine import AgentEngine
|
|
47
|
+
from onecode.agent.permissions_store import PermissionStore
|
|
48
|
+
from onecode.agent.session import AgentSession
|
|
49
|
+
from onecode.config import load_config
|
|
50
|
+
from onecode.models.provider import ProviderRegistry
|
|
51
|
+
from onecode.models.registry import ModelRegistry
|
|
52
|
+
from onecode.models.messages import (
|
|
53
|
+
StreamEventType,
|
|
54
|
+
ToolCategory,
|
|
55
|
+
AgentMessage,
|
|
56
|
+
TextBlock,
|
|
57
|
+
ThinkBlock,
|
|
58
|
+
ToolCall as MsgToolCall,
|
|
59
|
+
ToolResult,
|
|
60
|
+
SubAgentBlock,
|
|
61
|
+
LifecycleStatus,
|
|
62
|
+
get_tool_category,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
from onecode.models.providers.minimaxi_provider import MiniMaxiProvider
|
|
66
|
+
from onecode.models.providers.minimax_provider import MiniMaxProvider
|
|
67
|
+
from onecode.models.providers.anthropic_provider import AnthropicProvider
|
|
68
|
+
from onecode.models.providers.openai_provider import OpenAIProvider
|
|
69
|
+
from onecode.models.providers.deepseek_provider import DeepSeekProvider
|
|
70
|
+
from onecode.models.providers.glm_provider import GLMProvider
|
|
71
|
+
from onecode.models.providers.ollama_provider import OllamaProvider
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_CATEGORY_TO_ACP_KIND: dict[str, str] = {
|
|
75
|
+
ToolCategory.FILE_READ: "read",
|
|
76
|
+
ToolCategory.FILE_WRITE: "edit",
|
|
77
|
+
ToolCategory.FILE_EDIT: "edit",
|
|
78
|
+
ToolCategory.FILE_LIST: "read",
|
|
79
|
+
ToolCategory.FILE_GLOB: "search",
|
|
80
|
+
ToolCategory.FILE_GREP: "search",
|
|
81
|
+
ToolCategory.BASH: "execute",
|
|
82
|
+
ToolCategory.WEB_FETCH: "fetch",
|
|
83
|
+
ToolCategory.WEB_SEARCH: "search",
|
|
84
|
+
ToolCategory.TASK: "other",
|
|
85
|
+
ToolCategory.TASK_MGMT: "other",
|
|
86
|
+
ToolCategory.INTERACTION: "other",
|
|
87
|
+
ToolCategory.UNKNOWN: "other",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _kind_for_category(cat: ToolCategory) -> str:
|
|
92
|
+
"""Map internal ``ToolCategory`` enum to ACP wire-format ``kind`` string.
|
|
93
|
+
|
|
94
|
+
The TUI expects ``"read"``, ``"edit"``, ``"search"``, ``"execute"``,
|
|
95
|
+
``"fetch"`` or ``"other"`` (see ``tui/acp/protocol.py:ToolCall``).
|
|
96
|
+
"""
|
|
97
|
+
return _CATEGORY_TO_ACP_KIND.get(cat, "other")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_DEFAULT_MODES = {
|
|
101
|
+
"currentModeId": "build",
|
|
102
|
+
"availableModes": [
|
|
103
|
+
{"id": "build", "name": "Build", "description": "Full development agent. Edits and shell commands require user approval."},
|
|
104
|
+
{"id": "plan", "name": "Plan", "description": "Read-only planning and analysis. Edits and shell commands require approval."},
|
|
105
|
+
{"id": "solo", "name": "Solo", "description": "Independent mode with plan-first workflow. Edits allowed, shell commands require approval."},
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_OPEN_MARKER = "[TOOL_CALL]"
|
|
110
|
+
_CLOSE_MARKER = "[/TOOL_CALL]"
|
|
111
|
+
|
|
112
|
+
# Structured tool call markers (see engine.py MINIMAX_TOOL_CALL_RE).
|
|
113
|
+
# The adapter's stream callback strips these from the user-visible text so
|
|
114
|
+
# raw ``<minimax:tool_call>`` XML never reaches the chat.
|
|
115
|
+
_MINIMAX_OPEN = "<minimax:tool_call>"
|
|
116
|
+
_MINIMAX_CLOSE = "</minimax:tool_call>"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_text_from_blocks(content: list) -> str:
|
|
120
|
+
"""Concatenate the `text` field of each `text` block in a content list."""
|
|
121
|
+
parts: list[str] = []
|
|
122
|
+
for item in content:
|
|
123
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
124
|
+
parts.append(item.get("text", ""))
|
|
125
|
+
return "".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
|
|
129
|
+
"""Return position of the matching '}' for the '{' at open_pos.
|
|
130
|
+
|
|
131
|
+
Handles nested braces, single/double-quoted strings, and
|
|
132
|
+
single-line comments. Returns None if no matching brace is found.
|
|
133
|
+
"""
|
|
134
|
+
if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
|
|
135
|
+
return None
|
|
136
|
+
depth = 0
|
|
137
|
+
i = open_pos
|
|
138
|
+
n = len(text)
|
|
139
|
+
while i < n:
|
|
140
|
+
c = text[i]
|
|
141
|
+
if c == "{":
|
|
142
|
+
depth += 1
|
|
143
|
+
i += 1
|
|
144
|
+
elif c == "}":
|
|
145
|
+
depth -= 1
|
|
146
|
+
if depth == 0:
|
|
147
|
+
return i
|
|
148
|
+
i += 1
|
|
149
|
+
elif c == '"':
|
|
150
|
+
i += 1
|
|
151
|
+
while i < n and text[i] != '"':
|
|
152
|
+
if text[i] == "\\" and i + 1 < n:
|
|
153
|
+
i += 2
|
|
154
|
+
else:
|
|
155
|
+
i += 1
|
|
156
|
+
i += 1
|
|
157
|
+
elif c == "'":
|
|
158
|
+
i += 1
|
|
159
|
+
while i < n and text[i] != "'":
|
|
160
|
+
if text[i] == "\\" and i + 1 < n:
|
|
161
|
+
i += 2
|
|
162
|
+
else:
|
|
163
|
+
i += 1
|
|
164
|
+
i += 1
|
|
165
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
166
|
+
while i < n and text[i] != "\n":
|
|
167
|
+
i += 1
|
|
168
|
+
else:
|
|
169
|
+
i += 1
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_tool_call_body(body: str) -> dict:
|
|
174
|
+
"""Parse the body of a [TOOL_CALL]...[/TOOL_CALL] block.
|
|
175
|
+
|
|
176
|
+
Returns a dict with 'name' (str | None) and 'arguments' (dict).
|
|
177
|
+
"""
|
|
178
|
+
body = body.strip()
|
|
179
|
+
if not body or not body.startswith("{"):
|
|
180
|
+
return {"name": None, "arguments": {}}
|
|
181
|
+
|
|
182
|
+
body_close = _scan_balanced_braces(body, 0)
|
|
183
|
+
if body_close is None:
|
|
184
|
+
return {"name": None, "arguments": {}}
|
|
185
|
+
inner = body[1:body_close]
|
|
186
|
+
|
|
187
|
+
name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
|
|
188
|
+
name = name_match.group(1) if name_match else None
|
|
189
|
+
|
|
190
|
+
arguments: dict = {}
|
|
191
|
+
args_match = re.search(r'args\s*=>\s*\{', inner)
|
|
192
|
+
if args_match:
|
|
193
|
+
args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
|
|
194
|
+
if args_outer is not None:
|
|
195
|
+
args_body = inner[args_match.end():args_outer]
|
|
196
|
+
for am in re.finditer(
|
|
197
|
+
r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
|
|
198
|
+
):
|
|
199
|
+
arguments[am.group(1)] = am.group(2)
|
|
200
|
+
|
|
201
|
+
return {"name": name, "arguments": arguments}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_legacy_tool_call(text: str) -> dict | None:
|
|
205
|
+
"""Parse a complete [TOOL_CALL]...[/TOOL_CALL] block from text.
|
|
206
|
+
|
|
207
|
+
Returns a dict with:
|
|
208
|
+
- name: tool name
|
|
209
|
+
- arguments: dict of argument name -> value
|
|
210
|
+
- span: (start, end) of the full [TOOL_CALL]...[/TOOL_CALL]
|
|
211
|
+
Returns None if no complete block is found.
|
|
212
|
+
"""
|
|
213
|
+
open_idx = text.find(_OPEN_MARKER)
|
|
214
|
+
if open_idx < 0:
|
|
215
|
+
return None
|
|
216
|
+
body_start = open_idx + len(_OPEN_MARKER)
|
|
217
|
+
close_idx = text.find(_CLOSE_MARKER, body_start)
|
|
218
|
+
if close_idx < 0:
|
|
219
|
+
return None
|
|
220
|
+
body = text[body_start:close_idx]
|
|
221
|
+
span_end = close_idx + len(_CLOSE_MARKER)
|
|
222
|
+
|
|
223
|
+
parsed = _parse_tool_call_body(body)
|
|
224
|
+
parsed["span"] = (open_idx, span_end)
|
|
225
|
+
return parsed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_LANG_BY_EXT: dict[str, str] = {
|
|
229
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
230
|
+
".jsx": "jsx", ".tsx": "tsx", ".json": "json", ".yml": "yaml",
|
|
231
|
+
".yaml": "yaml", ".toml": "toml", ".md": "markdown", ".sh": "bash",
|
|
232
|
+
".html": "html", ".css": "css", ".rs": "rust", ".go": "go",
|
|
233
|
+
".java": "java", ".kt": "kotlin", ".swift": "swift", ".rb": "ruby",
|
|
234
|
+
".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".sql": "sql",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _language_for_path(path: str) -> str:
|
|
239
|
+
"""Return a Markdown code-fence language hint based on file extension."""
|
|
240
|
+
if not path:
|
|
241
|
+
return ""
|
|
242
|
+
dot = path.rfind(".")
|
|
243
|
+
slash = max(path.rfind("/"), path.rfind("\\"))
|
|
244
|
+
if dot > slash and dot >= 0:
|
|
245
|
+
return _LANG_BY_EXT.get(path[dot:].lower(), "")
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_STATUS_TO_WIRE: dict[str, str] = {
|
|
250
|
+
"pending": "pending",
|
|
251
|
+
"in_progress": "in_progress",
|
|
252
|
+
"running": "in_progress",
|
|
253
|
+
"complete": "completed",
|
|
254
|
+
"completed": "completed",
|
|
255
|
+
"failed": "failed",
|
|
256
|
+
"cancelled": "failed",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _wire_status(value: str | None) -> str:
|
|
261
|
+
"""Map an internal LifecycleStatus value to the ACP wire literal.
|
|
262
|
+
|
|
263
|
+
The ACP protocol (TUI side) expects ``ToolCallStatus = Literal["pending",
|
|
264
|
+
"in_progress", "completed", "failed"]``; the internal enum uses ``complete``
|
|
265
|
+
and ``cancelled``. This helper normalises everything to a wire-valid value
|
|
266
|
+
and defaults to ``"completed"`` for any unknown value.
|
|
267
|
+
"""
|
|
268
|
+
if not value:
|
|
269
|
+
return "completed"
|
|
270
|
+
return _STATUS_TO_WIRE.get(value, "completed")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _try_pretty_print_json(text: str) -> str:
|
|
274
|
+
"""If *text* looks like JSON, pretty-print it; otherwise return as-is.
|
|
275
|
+
|
|
276
|
+
Handles both raw JSON and JSON that was embedded as a string inside
|
|
277
|
+
another JSON value (i.e. with ``\\n``, ``\\"`` etc. as literal escapes).
|
|
278
|
+
"""
|
|
279
|
+
stripped = text.strip()
|
|
280
|
+
if not (stripped.startswith("{") or stripped.startswith("[")):
|
|
281
|
+
return text
|
|
282
|
+
|
|
283
|
+
# Direct parse
|
|
284
|
+
try:
|
|
285
|
+
obj = json.loads(stripped)
|
|
286
|
+
return json.dumps(obj, indent=2, ensure_ascii=False)
|
|
287
|
+
except (json.JSONDecodeError, ValueError):
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
# The text may contain literal escape sequences (e.g. ``\n``, ``\"``)
|
|
291
|
+
# from being nested inside another JSON string. Unescape and retry.
|
|
292
|
+
try:
|
|
293
|
+
unescaped = stripped.encode("utf-8").decode("unicode_escape")
|
|
294
|
+
obj = json.loads(unescaped)
|
|
295
|
+
return json.dumps(obj, indent=2, ensure_ascii=False)
|
|
296
|
+
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
|
|
297
|
+
return text
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _build_tool_call_content(name: str | None, arguments: dict) -> list:
|
|
301
|
+
"""Convert a tool's args into the ACP content blocks the TUI expects.
|
|
302
|
+
|
|
303
|
+
The TUI has dedicated widgets for `diff` (DiffView) and structured text
|
|
304
|
+
(Markdown / Static). JSON-fence fallbacks only kick in for tools we
|
|
305
|
+
don't recognise, so known tools render as their intended visual.
|
|
306
|
+
|
|
307
|
+
Write emits a Markdown block (`path` header + code fence) so the
|
|
308
|
+
content reads as code rather than a noisy all-additions diff.
|
|
309
|
+
Edit keeps the diff block because there are real before/after changes.
|
|
310
|
+
"""
|
|
311
|
+
if not arguments:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
if name == "Read":
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
_HEADER_ONLY_TOOLS = frozenset({
|
|
318
|
+
"TaskCreate", "TaskGet", "TaskList", "TaskUpdate", "TaskOutput", "TaskStop",
|
|
319
|
+
"TodoCreate", "TodoList", "TodoComplete", "Glob",
|
|
320
|
+
})
|
|
321
|
+
if name in _HEADER_ONLY_TOOLS:
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
if name == "Write":
|
|
325
|
+
path = str(arguments.get("path", ""))
|
|
326
|
+
content = str(arguments.get("content", ""))
|
|
327
|
+
if path:
|
|
328
|
+
lang = _language_for_path(path)
|
|
329
|
+
return [{
|
|
330
|
+
"type": "content",
|
|
331
|
+
"content": {
|
|
332
|
+
"type": "text",
|
|
333
|
+
"text": f"📄 {path}\n```{lang}\n{content}\n```",
|
|
334
|
+
},
|
|
335
|
+
}]
|
|
336
|
+
|
|
337
|
+
if name == "Edit":
|
|
338
|
+
path = str(arguments.get("path", ""))
|
|
339
|
+
old = str(arguments.get("old_string", arguments.get("oldText", "")))
|
|
340
|
+
new = str(arguments.get("new_string", arguments.get("newText", "")))
|
|
341
|
+
if path:
|
|
342
|
+
return [{
|
|
343
|
+
"type": "diff",
|
|
344
|
+
"path": path,
|
|
345
|
+
"oldText": old,
|
|
346
|
+
"newText": new,
|
|
347
|
+
}]
|
|
348
|
+
|
|
349
|
+
if name == "Bash":
|
|
350
|
+
cmd = str(arguments.get("command", arguments.get("cmd", "")))
|
|
351
|
+
if cmd:
|
|
352
|
+
return [{
|
|
353
|
+
"type": "content",
|
|
354
|
+
"content": {
|
|
355
|
+
"type": "text",
|
|
356
|
+
"text": f"```bash\n$ {cmd}\n```",
|
|
357
|
+
},
|
|
358
|
+
}]
|
|
359
|
+
|
|
360
|
+
if name == "ApplyPatch":
|
|
361
|
+
patch = str(arguments.get("patch", ""))
|
|
362
|
+
if patch:
|
|
363
|
+
return [{
|
|
364
|
+
"type": "content",
|
|
365
|
+
"content": {"type": "text", "text": f"```diff\n{patch}\n```"},
|
|
366
|
+
}]
|
|
367
|
+
|
|
368
|
+
if name == "Insert":
|
|
369
|
+
path = str(arguments.get("path", ""))
|
|
370
|
+
line = arguments.get("line", -1)
|
|
371
|
+
text = str(arguments.get("text", ""))
|
|
372
|
+
if path:
|
|
373
|
+
lang = _language_for_path(path)
|
|
374
|
+
return [{
|
|
375
|
+
"type": "content",
|
|
376
|
+
"content": {
|
|
377
|
+
"type": "text",
|
|
378
|
+
"text": f"📝 {path} (line {line})\n```{lang}\n{text}\n```",
|
|
379
|
+
},
|
|
380
|
+
}]
|
|
381
|
+
|
|
382
|
+
if name == "UndoEdit":
|
|
383
|
+
path = str(arguments.get("path", ""))
|
|
384
|
+
if path:
|
|
385
|
+
return [{
|
|
386
|
+
"type": "content",
|
|
387
|
+
"content": {"type": "text", "text": f"↩️ Undo edit on {path}"},
|
|
388
|
+
}]
|
|
389
|
+
|
|
390
|
+
if name == "AskUser":
|
|
391
|
+
q = str(arguments.get("question", ""))
|
|
392
|
+
opts = arguments.get("options", [])
|
|
393
|
+
lines = [f"❓ {q}"]
|
|
394
|
+
for o in opts:
|
|
395
|
+
label = o.get("label", "")
|
|
396
|
+
desc = o.get("description", "")
|
|
397
|
+
if desc:
|
|
398
|
+
lines.append(f" • {label} — {desc}")
|
|
399
|
+
else:
|
|
400
|
+
lines.append(f" • {label}")
|
|
401
|
+
return [{
|
|
402
|
+
"type": "content",
|
|
403
|
+
"content": {"type": "text", "text": "\n".join(lines)},
|
|
404
|
+
}] if lines else []
|
|
405
|
+
|
|
406
|
+
args_text = json.dumps(arguments, indent=2, ensure_ascii=False)
|
|
407
|
+
return [{
|
|
408
|
+
"type": "content",
|
|
409
|
+
"content": {"type": "text", "text": f"```json\n{args_text}\n```"},
|
|
410
|
+
}]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _format_tui_display_text(result_text: str, tool_name: str = "") -> str:
|
|
414
|
+
"""Convert internal tool result JSON to user-visible text for TUI.
|
|
415
|
+
|
|
416
|
+
Tools return dicts like ``{"success": true, "path": "..."}`` that are
|
|
417
|
+
meaningful to the LLM but noisy for the user. This function strips
|
|
418
|
+
internal JSON wrappers and returns only what the user should see.
|
|
419
|
+
|
|
420
|
+
Success results that have a ``path`` field render as ``✓ /path``;
|
|
421
|
+
other success results fall through to a compact view of the rest
|
|
422
|
+
of the dict.
|
|
423
|
+
|
|
424
|
+
Task / Todo tools (TaskCreate, TaskUpdate, TaskList, …) are special-
|
|
425
|
+
cased: their verbose ``{"task": {...}}`` / ``{"tasks": [...]}`` results
|
|
426
|
+
are collapsed to a single concise marker (``✓ updated``) since the
|
|
427
|
+
LLM's tool-call args already show what the agent did, and re-printing
|
|
428
|
+
the full task object after every call clutters the conversation view.
|
|
429
|
+
"""
|
|
430
|
+
if not result_text:
|
|
431
|
+
return ""
|
|
432
|
+
try:
|
|
433
|
+
parsed = json.loads(result_text)
|
|
434
|
+
except json.JSONDecodeError:
|
|
435
|
+
return result_text
|
|
436
|
+
if not isinstance(parsed, dict):
|
|
437
|
+
return result_text
|
|
438
|
+
if "error" in parsed:
|
|
439
|
+
return str(parsed["error"])
|
|
440
|
+
# Read: show actual file content (code fence + language wrapping in _emit_tool_result)
|
|
441
|
+
if tool_name == "Read":
|
|
442
|
+
return parsed.get("content", "")
|
|
443
|
+
# Task / Todo tools: collapse verbose state echoes to one-liner.
|
|
444
|
+
if tool_name in _STATUS_ONLY_TOOLS:
|
|
445
|
+
return ""
|
|
446
|
+
if parsed.get("success") is True:
|
|
447
|
+
if path := parsed.get("path"):
|
|
448
|
+
return f"✓ {path}"
|
|
449
|
+
visible = {k: v for k, v in parsed.items() if k != "success"}
|
|
450
|
+
if not visible:
|
|
451
|
+
return "✓ done"
|
|
452
|
+
try:
|
|
453
|
+
return json.dumps(visible, ensure_ascii=False)
|
|
454
|
+
except (TypeError, ValueError):
|
|
455
|
+
return str(visible)
|
|
456
|
+
return result_text
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
_STATUS_ONLY_TOOLS = frozenset({
|
|
460
|
+
"TaskCreate",
|
|
461
|
+
"TaskUpdate",
|
|
462
|
+
"TaskStop",
|
|
463
|
+
"TodoCreate",
|
|
464
|
+
"TodoComplete",
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
_BARE_LEGACY_RE = re.compile(
|
|
469
|
+
r"\{[^{}]*tool\s*=>\s*\"(?P<name>\w+)\"[^{}]*args\s*=>\s*\{",
|
|
470
|
+
re.DOTALL,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _find_bare_legacy_tool_call(text: str) -> int | None:
|
|
475
|
+
"""Find the start of a bare `{tool => "X", args => { ... }}` body.
|
|
476
|
+
|
|
477
|
+
Unlike `[TOOL_CALL]...[/TOOL_CALL]`, this pattern has no enclosing
|
|
478
|
+
markers — we have to scan to the matching `}` ourselves.
|
|
479
|
+
"""
|
|
480
|
+
m = _BARE_LEGACY_RE.search(text)
|
|
481
|
+
if m is None:
|
|
482
|
+
return None
|
|
483
|
+
# Reject matches that look like the inside of a fenced code block.
|
|
484
|
+
if "```" in text[: m.start()]:
|
|
485
|
+
last_fence = text.rfind("```", 0, m.start())
|
|
486
|
+
if last_fence >= 0 and text.count("```", last_fence, m.start()) == 1:
|
|
487
|
+
return None
|
|
488
|
+
return m.start()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _filter_tool_call_text(text: str) -> str:
|
|
492
|
+
"""Remove complete ``[TOOL_CALL]...[/TOOL_CALL]`` and
|
|
493
|
+
``<minimax:tool_call>...</minimax:tool_call>`` blocks from text.
|
|
494
|
+
|
|
495
|
+
Incomplete blocks (no closing marker) are left in place so the caller
|
|
496
|
+
can hold the buffer and wait for more input.
|
|
497
|
+
"""
|
|
498
|
+
out: list[str] = []
|
|
499
|
+
pos = 0
|
|
500
|
+
while pos < len(text):
|
|
501
|
+
parsed = _extract_legacy_tool_call(text[pos:])
|
|
502
|
+
minimax_start = text.find(_MINIMAX_OPEN, pos)
|
|
503
|
+
minimax_end = (
|
|
504
|
+
text.find(_MINIMAX_CLOSE, pos) if minimax_start >= 0 else -1
|
|
505
|
+
)
|
|
506
|
+
minimax_span = (
|
|
507
|
+
(minimax_start, minimax_end + len(_MINIMAX_CLOSE))
|
|
508
|
+
if minimax_start >= 0 and minimax_end >= 0
|
|
509
|
+
else None
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
candidates: list[tuple[int, tuple[int, int]]] = []
|
|
513
|
+
if parsed is not None:
|
|
514
|
+
candidates.append((parsed["span"][0], parsed["span"]))
|
|
515
|
+
if minimax_span is not None:
|
|
516
|
+
candidates.append((minimax_span[0], minimax_span))
|
|
517
|
+
|
|
518
|
+
if not candidates:
|
|
519
|
+
out.append(text[pos:])
|
|
520
|
+
break
|
|
521
|
+
|
|
522
|
+
candidates.sort(key=lambda c: c[0])
|
|
523
|
+
rel_start, span = candidates[0]
|
|
524
|
+
rel_end = span[1]
|
|
525
|
+
abs_start = pos + rel_start
|
|
526
|
+
abs_end = pos + rel_end
|
|
527
|
+
before = text[pos:abs_start]
|
|
528
|
+
if before:
|
|
529
|
+
out.append(before)
|
|
530
|
+
pos = abs_end
|
|
531
|
+
return "".join(out)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _create_engine(cwd: str, perm_store: PermissionStore | None = None) -> AgentEngine:
|
|
535
|
+
cfg = load_config()
|
|
536
|
+
ModelRegistry.initialize()
|
|
537
|
+
provider_cls = ProviderRegistry.get(cfg.default_provider)
|
|
538
|
+
if provider_cls is None:
|
|
539
|
+
provider_cls = ProviderRegistry.get("minimaxi")
|
|
540
|
+
|
|
541
|
+
class MinimalApp:
|
|
542
|
+
config = cfg
|
|
543
|
+
current_provider = cfg.default_provider
|
|
544
|
+
current_model = cfg.default_model
|
|
545
|
+
|
|
546
|
+
project_dir = Path(cwd).resolve() if cwd else Path.cwd()
|
|
547
|
+
return AgentEngine(MinimalApp(), project_dir=project_dir, perm_store=perm_store)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class TextChunker:
|
|
551
|
+
"""对文本流按语义边界分组后发送,减少 ACP 消息频率。
|
|
552
|
+
|
|
553
|
+
三种模式:
|
|
554
|
+
- "words": 每 N 个分隔符(空格/换行/tab)切一次
|
|
555
|
+
- "line": 每遇到换行切一次
|
|
556
|
+
- "auto": 自动检测 ``` 代码块:代码块内用 line,外部用 words
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
send_fn: 收到完整文本块时的回调
|
|
560
|
+
mode: "words" | "line" | "auto"
|
|
561
|
+
words: words 模式下的分隔符计数阈值
|
|
562
|
+
line_max: line 模式下无换行时的强制截断长度
|
|
563
|
+
word_max: words 模式下无分隔符时的强制截断长度
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
def __init__(
|
|
567
|
+
self,
|
|
568
|
+
send_fn: Callable[[str], None],
|
|
569
|
+
mode: str = "auto",
|
|
570
|
+
words: int = 5,
|
|
571
|
+
line_max: int = 500,
|
|
572
|
+
word_max: int = 100,
|
|
573
|
+
):
|
|
574
|
+
self._buf = ""
|
|
575
|
+
self._send = send_fn
|
|
576
|
+
self._mode = mode
|
|
577
|
+
self._words = words
|
|
578
|
+
self._line_max = line_max
|
|
579
|
+
self._word_max = word_max
|
|
580
|
+
|
|
581
|
+
def append(self, text: str) -> None:
|
|
582
|
+
if not text:
|
|
583
|
+
return
|
|
584
|
+
self._buf += text
|
|
585
|
+
self._process()
|
|
586
|
+
|
|
587
|
+
def flush(self) -> None:
|
|
588
|
+
if self._buf:
|
|
589
|
+
self._send(self._buf)
|
|
590
|
+
self._buf = ""
|
|
591
|
+
|
|
592
|
+
def _process(self) -> None:
|
|
593
|
+
while self._buf:
|
|
594
|
+
in_code = False
|
|
595
|
+
if self._mode == "auto":
|
|
596
|
+
in_code = (self._buf.count("```") % 2 == 1)
|
|
597
|
+
|
|
598
|
+
if in_code or self._mode == "line":
|
|
599
|
+
n = self._buf.find("\n")
|
|
600
|
+
if n >= 0:
|
|
601
|
+
n += 1
|
|
602
|
+
elif len(self._buf) >= self._line_max:
|
|
603
|
+
n = self._line_max
|
|
604
|
+
else:
|
|
605
|
+
break
|
|
606
|
+
else:
|
|
607
|
+
n = self._buf.find("\n")
|
|
608
|
+
if n >= 0:
|
|
609
|
+
n += 1
|
|
610
|
+
else:
|
|
611
|
+
sep_count = 0
|
|
612
|
+
for i, ch in enumerate(self._buf):
|
|
613
|
+
if ch in (' ', '\n', '\t'):
|
|
614
|
+
sep_count += 1
|
|
615
|
+
if sep_count >= self._words:
|
|
616
|
+
n = i + 1
|
|
617
|
+
break
|
|
618
|
+
if n < 0:
|
|
619
|
+
if len(self._buf) >= self._word_max:
|
|
620
|
+
n = self._word_max
|
|
621
|
+
else:
|
|
622
|
+
break
|
|
623
|
+
|
|
624
|
+
chunk = self._buf[:n]
|
|
625
|
+
self._buf = self._buf[n:].lstrip()
|
|
626
|
+
if chunk:
|
|
627
|
+
self._send(chunk)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class CDHACPAdapter:
|
|
631
|
+
"""Adapter that translates ACP protocol to CDH AgentEngine."""
|
|
632
|
+
|
|
633
|
+
def __init__(self):
|
|
634
|
+
self.agent = None
|
|
635
|
+
self.session_id = None
|
|
636
|
+
self.tool_calls = {}
|
|
637
|
+
self.in_thinking = False
|
|
638
|
+
self._pending_requests: dict[str, asyncio.Future[dict]] = {}
|
|
639
|
+
self._request_seq = 0
|
|
640
|
+
self._perm_store = PermissionStore()
|
|
641
|
+
self._ask_user_future: asyncio.Future[dict] | None = None
|
|
642
|
+
|
|
643
|
+
def _perm_store_load(self, cwd: str | None = None) -> None:
|
|
644
|
+
"""Load permission overrides from ``.cdh/permissions.json``."""
|
|
645
|
+
project_dir = Path(cwd).resolve() if cwd else (self.agent._project_dir if self.agent else Path.cwd())
|
|
646
|
+
cdh_dir = CdhProjectLoader.find_cdh_dir(project_dir)
|
|
647
|
+
if cdh_dir:
|
|
648
|
+
data = CdhProjectLoader.load_permissions(cdh_dir)
|
|
649
|
+
if data:
|
|
650
|
+
self._perm_store = PermissionStore.from_dict(data)
|
|
651
|
+
|
|
652
|
+
def _perm_store_save(self) -> None:
|
|
653
|
+
"""Save permission overrides to ``.cdh/permissions.json``."""
|
|
654
|
+
if not self.agent:
|
|
655
|
+
return
|
|
656
|
+
cdh_dir = CdhProjectLoader.find_cdh_dir(self.agent._project_dir)
|
|
657
|
+
if cdh_dir:
|
|
658
|
+
CdhProjectLoader.save_permissions(cdh_dir, self._perm_store.to_dict())
|
|
659
|
+
|
|
660
|
+
def _handle_reset_permission(self, key: str = "") -> dict:
|
|
661
|
+
"""Handle /permission slash command.
|
|
662
|
+
|
|
663
|
+
Without argument: clears all overrides.
|
|
664
|
+
With argument (e.g. ``bash``): clears that specific override.
|
|
665
|
+
"""
|
|
666
|
+
if key:
|
|
667
|
+
old = self._perm_store.get_override(key)
|
|
668
|
+
if old:
|
|
669
|
+
self._perm_store.clear_override(key)
|
|
670
|
+
setattr(self.agent.current_agent, f"permission_{key}", None)
|
|
671
|
+
msg = f"Permission override `{key}` ({old.value}) cleared."
|
|
672
|
+
else:
|
|
673
|
+
msg = f"No override found for `{key}`."
|
|
674
|
+
else:
|
|
675
|
+
count = len(self._perm_store._overrides)
|
|
676
|
+
self._perm_store.clear_all()
|
|
677
|
+
msg = f"All {count} permission override(s) cleared."
|
|
678
|
+
self._perm_store_save()
|
|
679
|
+
self.send_session_update({
|
|
680
|
+
"sessionUpdate": "agent_message_chunk",
|
|
681
|
+
"content": {"type": "text", "text": f"✅ {msg}"},
|
|
682
|
+
})
|
|
683
|
+
return {"stopReason": "end_turn"}
|
|
684
|
+
|
|
685
|
+
@staticmethod
|
|
686
|
+
def _content_to_blocks(content: str | list) -> list:
|
|
687
|
+
"""Convert old-format message content to list of AgentBlock objects."""
|
|
688
|
+
blocks: list = []
|
|
689
|
+
if isinstance(content, str):
|
|
690
|
+
blocks = CDHACPAdapter._text_to_blocks(content)
|
|
691
|
+
elif isinstance(content, list):
|
|
692
|
+
for item in content:
|
|
693
|
+
if not isinstance(item, dict):
|
|
694
|
+
continue
|
|
695
|
+
item_type = item.get("type", "")
|
|
696
|
+
if item_type == "text":
|
|
697
|
+
blocks.append(TextBlock(content=item.get("text", "")))
|
|
698
|
+
elif item_type == "thinking":
|
|
699
|
+
blocks.append(ThinkBlock(content=item.get("thinking", "")))
|
|
700
|
+
elif item_type == "tool_use":
|
|
701
|
+
blocks.append(MsgToolCall(
|
|
702
|
+
id=item.get("id", ""),
|
|
703
|
+
name=item.get("name", ""),
|
|
704
|
+
arguments=item.get("input", {}),
|
|
705
|
+
status=LifecycleStatus(item.get("status", "complete")),
|
|
706
|
+
))
|
|
707
|
+
elif item_type == "tool_result":
|
|
708
|
+
blocks.append(ToolResult(
|
|
709
|
+
tool_use_id=item.get("tool_use_id", ""),
|
|
710
|
+
content=item.get("content", ""),
|
|
711
|
+
is_error=item.get("is_error", False),
|
|
712
|
+
))
|
|
713
|
+
elif item_type == "subagent":
|
|
714
|
+
blocks.append(SubAgentBlock(
|
|
715
|
+
id=item.get("id", ""),
|
|
716
|
+
agent_type=item.get("agent_type", "default"),
|
|
717
|
+
prompt=item.get("prompt", ""),
|
|
718
|
+
result=item.get("result", ""),
|
|
719
|
+
status=item.get("status", "complete"),
|
|
720
|
+
))
|
|
721
|
+
return blocks
|
|
722
|
+
|
|
723
|
+
@staticmethod
|
|
724
|
+
def _text_to_blocks(text: str) -> list:
|
|
725
|
+
"""Convert text containing legacy [TOOL_CALL] / <thinking> patterns to blocks.
|
|
726
|
+
|
|
727
|
+
Parses text that may contain:
|
|
728
|
+
- [TOOL_CALL] {tool => "Name", args => { ... }} [/TOOL_CALL] patterns
|
|
729
|
+
- <thinking>...</thinking> tags
|
|
730
|
+
Returns a list of TextBlock, ThinkBlock, and MsgToolCall blocks.
|
|
731
|
+
"""
|
|
732
|
+
THINKING_RE = re.compile(r'<think(?:ing)?>(.*?)</think(?:ing)?>', re.DOTALL)
|
|
733
|
+
|
|
734
|
+
blocks: list = []
|
|
735
|
+
remaining = text
|
|
736
|
+
legacy_id = 0
|
|
737
|
+
|
|
738
|
+
while remaining:
|
|
739
|
+
parsed_tool = _extract_legacy_tool_call(remaining)
|
|
740
|
+
tool_start = parsed_tool["span"][0] if parsed_tool else -1
|
|
741
|
+
think_match = THINKING_RE.search(remaining)
|
|
742
|
+
|
|
743
|
+
candidates: list[tuple[int, str]] = []
|
|
744
|
+
if parsed_tool is not None:
|
|
745
|
+
candidates.append((tool_start, "tool"))
|
|
746
|
+
if think_match is not None:
|
|
747
|
+
candidates.append((think_match.start(), "think"))
|
|
748
|
+
|
|
749
|
+
if not candidates:
|
|
750
|
+
break
|
|
751
|
+
|
|
752
|
+
candidates.sort()
|
|
753
|
+
next_start, match_type = candidates[0]
|
|
754
|
+
|
|
755
|
+
before = remaining[:next_start]
|
|
756
|
+
if before.strip():
|
|
757
|
+
blocks.append(TextBlock(content=before))
|
|
758
|
+
|
|
759
|
+
if match_type == "tool":
|
|
760
|
+
assert parsed_tool is not None
|
|
761
|
+
tool_name = parsed_tool["name"]
|
|
762
|
+
span_end = parsed_tool["span"][1]
|
|
763
|
+
if tool_name:
|
|
764
|
+
blocks.append(MsgToolCall(
|
|
765
|
+
id=f"legacy-{legacy_id}",
|
|
766
|
+
name=tool_name,
|
|
767
|
+
arguments=parsed_tool["arguments"],
|
|
768
|
+
status=LifecycleStatus.COMPLETE,
|
|
769
|
+
))
|
|
770
|
+
legacy_id += 1
|
|
771
|
+
remaining = remaining[span_end:]
|
|
772
|
+
else:
|
|
773
|
+
assert think_match is not None
|
|
774
|
+
blocks.append(ThinkBlock(content=think_match.group(1)))
|
|
775
|
+
remaining = remaining[think_match.end():]
|
|
776
|
+
|
|
777
|
+
if remaining.strip():
|
|
778
|
+
blocks.append(TextBlock(content=remaining))
|
|
779
|
+
|
|
780
|
+
return blocks
|
|
781
|
+
|
|
782
|
+
def send_notification(self, method: str, params: dict):
|
|
783
|
+
"""Send a JSONRPC notification to A2TUI."""
|
|
784
|
+
notification = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
785
|
+
print(json.dumps(notification), flush=True)
|
|
786
|
+
|
|
787
|
+
def send_request(self, method: str, params: dict) -> asyncio.Future[dict]:
|
|
788
|
+
"""Send a JSONRPC request to A2TUI and return a Future for the response."""
|
|
789
|
+
self._request_seq += 1
|
|
790
|
+
req_id = f"svr_{self._request_seq}"
|
|
791
|
+
future: asyncio.Future[dict] = asyncio.Future()
|
|
792
|
+
self._pending_requests[req_id] = future
|
|
793
|
+
request = {"jsonrpc": "2.0", "method": method, "params": params, "id": req_id}
|
|
794
|
+
print(json.dumps(request), flush=True)
|
|
795
|
+
return future
|
|
796
|
+
|
|
797
|
+
def resolve_pending_request(self, req_id: str, result: dict) -> bool:
|
|
798
|
+
"""Resolve a pending request with the given result. Returns True if found."""
|
|
799
|
+
future = self._pending_requests.pop(req_id, None)
|
|
800
|
+
if future is not None and not future.done():
|
|
801
|
+
future.set_result(result)
|
|
802
|
+
return True
|
|
803
|
+
return False
|
|
804
|
+
|
|
805
|
+
def cancel_prompt(self):
|
|
806
|
+
"""Synchronously cancel the current prompt (called from main loop)."""
|
|
807
|
+
if self.agent:
|
|
808
|
+
self.agent._cancelled = True
|
|
809
|
+
for child in self.agent._child_engines:
|
|
810
|
+
child._cancelled = True
|
|
811
|
+
|
|
812
|
+
def resolve_ask_user(self, answer: str, cancelled: bool) -> dict:
|
|
813
|
+
"""Resolve the pending ask_user request with the user's answer.
|
|
814
|
+
|
|
815
|
+
Called from the main loop when a ``session/ask_user_answer`` request arrives.
|
|
816
|
+
"""
|
|
817
|
+
if self._ask_user_future is not None and not self._ask_user_future.done():
|
|
818
|
+
self._ask_user_future.set_result({"answer": answer, "cancelled": cancelled})
|
|
819
|
+
return {"ok": True}
|
|
820
|
+
|
|
821
|
+
def send_session_update(self, update: dict):
|
|
822
|
+
"""Send a session/update notification with proper ACP protocol format."""
|
|
823
|
+
self.send_notification("session/update", {
|
|
824
|
+
"sessionId": self.session_id,
|
|
825
|
+
"update": update,
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
def _emit_tool_result(self, block: ToolResult) -> None:
|
|
829
|
+
"""Send a tool_result as a tool_call_update, accumulating with existing content."""
|
|
830
|
+
# Look up tool name from the tracked tool_calls entry
|
|
831
|
+
tool_name = self.tool_calls.get(block.tool_use_id, {}).get("title", "")
|
|
832
|
+
# Title may be like "Bash: ls -la" — keep just the leading tool name
|
|
833
|
+
if tool_name and ":" in tool_name:
|
|
834
|
+
tool_name = tool_name.split(":", 1)[0].strip()
|
|
835
|
+
display_text = _format_tui_display_text(block.content, tool_name)
|
|
836
|
+
|
|
837
|
+
# Update title with path/subject from result if not already set
|
|
838
|
+
if block.content and block.tool_use_id in self.tool_calls:
|
|
839
|
+
try:
|
|
840
|
+
parsed = json.loads(block.content)
|
|
841
|
+
except json.JSONDecodeError:
|
|
842
|
+
pass
|
|
843
|
+
else:
|
|
844
|
+
if isinstance(parsed, dict) and parsed.get("success") is True:
|
|
845
|
+
current_title = self.tool_calls[block.tool_use_id].get("title", "")
|
|
846
|
+
if ":" not in current_title:
|
|
847
|
+
if tool_name in ("TaskCreate", "TaskUpdate"):
|
|
848
|
+
task_info = parsed.get("task", {})
|
|
849
|
+
if isinstance(task_info, dict):
|
|
850
|
+
if subject := task_info.get("subject", ""):
|
|
851
|
+
self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {subject}"
|
|
852
|
+
elif path := parsed.get("path"):
|
|
853
|
+
self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
|
|
854
|
+
elif path := parsed.get("path"):
|
|
855
|
+
self.tool_calls[block.tool_use_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
|
|
856
|
+
|
|
857
|
+
# Wrap multi-line results in a fenced code block for proper rendering.
|
|
858
|
+
# Use bash-style fence for Bash/Glob, detect language for Read.
|
|
859
|
+
if display_text and "\n" in display_text and "```" not in display_text:
|
|
860
|
+
lang = ""
|
|
861
|
+
tool_info = self.tool_calls.get(block.tool_use_id, {})
|
|
862
|
+
tname = tool_info.get("_tool_name", "")
|
|
863
|
+
targs = tool_info.get("_tool_args", {})
|
|
864
|
+
if tname in ("Bash", "Glob"):
|
|
865
|
+
lang = "bash"
|
|
866
|
+
elif tname == "Read" and isinstance(targs, dict):
|
|
867
|
+
path = str(targs.get("path", ""))
|
|
868
|
+
if path:
|
|
869
|
+
lang = _language_for_path(path)
|
|
870
|
+
if lang:
|
|
871
|
+
display_text = f"```{lang}\n{display_text}\n```"
|
|
872
|
+
else:
|
|
873
|
+
display_text = f"```\n{display_text}\n```"
|
|
874
|
+
|
|
875
|
+
content_block = [{
|
|
876
|
+
"type": "content",
|
|
877
|
+
"content": {"type": "text", "text": display_text},
|
|
878
|
+
}] if display_text else []
|
|
879
|
+
existing = self.tool_calls.get(block.tool_use_id, {}).get("content", [])
|
|
880
|
+
update = {
|
|
881
|
+
"sessionUpdate": "tool_call_update",
|
|
882
|
+
"toolCallId": block.tool_use_id,
|
|
883
|
+
"status": "failed" if block.is_error else "completed",
|
|
884
|
+
"content": existing + content_block,
|
|
885
|
+
}
|
|
886
|
+
if block.tool_use_id in self.tool_calls:
|
|
887
|
+
self.tool_calls[block.tool_use_id].update(update)
|
|
888
|
+
else:
|
|
889
|
+
self.tool_calls[block.tool_use_id] = {
|
|
890
|
+
"sessionUpdate": "tool_call",
|
|
891
|
+
"toolCallId": block.tool_use_id,
|
|
892
|
+
"title": "Tool call",
|
|
893
|
+
"kind": "other",
|
|
894
|
+
**update,
|
|
895
|
+
}
|
|
896
|
+
self.send_session_update(self.tool_calls[block.tool_use_id])
|
|
897
|
+
|
|
898
|
+
async def initialize(self, protocol_version: int, client_capabilities: dict, client_info: dict):
|
|
899
|
+
"""Handle ACP initialize."""
|
|
900
|
+
return {
|
|
901
|
+
"protocolVersion": 1,
|
|
902
|
+
"agentCapabilities": {
|
|
903
|
+
"loadSession": True,
|
|
904
|
+
"promptCapabilities": {
|
|
905
|
+
"audio": False,
|
|
906
|
+
"embeddedContent": True,
|
|
907
|
+
"image": True,
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
"authMethods": [],
|
|
911
|
+
"serverInfo": {
|
|
912
|
+
"name": "cdh-agent",
|
|
913
|
+
"title": "CDH Agent",
|
|
914
|
+
"version": "1.0.0",
|
|
915
|
+
},
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
def _send_available_commands(self) -> None:
|
|
919
|
+
"""Send slash commands available in the current agent mode."""
|
|
920
|
+
self.send_session_update({
|
|
921
|
+
"sessionUpdate": "available_commands_update",
|
|
922
|
+
"availableCommands": [
|
|
923
|
+
{"name": "exit", "description": "Exit A2TUI", "input": None},
|
|
924
|
+
{"name": "permission", "description": "Clear permission overrides (use `/permission edit` for specific key)", "input": None},
|
|
925
|
+
],
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
async def session_new(self, cwd: str, mcp_servers: list):
|
|
929
|
+
"""Create new session."""
|
|
930
|
+
cfg = load_config()
|
|
931
|
+
self.agent = _create_engine(cwd, perm_store=self._perm_store)
|
|
932
|
+
self.agent.set_agent(cfg.default_mode)
|
|
933
|
+
self._perm_store_load()
|
|
934
|
+
self._perm_store.apply_to(self.agent.current_agent)
|
|
935
|
+
|
|
936
|
+
session = AgentSession()
|
|
937
|
+
session.name = "New Session"
|
|
938
|
+
session.mode = cfg.default_mode
|
|
939
|
+
session.project = str(Path(cwd).resolve() if cwd else Path.cwd())
|
|
940
|
+
session.model = cfg.default_model
|
|
941
|
+
session.provider = cfg.default_provider
|
|
942
|
+
session.save()
|
|
943
|
+
self.agent.attach_session(session)
|
|
944
|
+
self.session_id = session.id
|
|
945
|
+
|
|
946
|
+
# Restore tasks from project .cdh/ if available
|
|
947
|
+
self.agent.load_tasks_from_project()
|
|
948
|
+
|
|
949
|
+
self._send_available_commands()
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
"sessionId": self.session_id,
|
|
953
|
+
"modes": {
|
|
954
|
+
"currentModeId": cfg.default_mode,
|
|
955
|
+
"availableModes": _DEFAULT_MODES["availableModes"],
|
|
956
|
+
},
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async def session_load(self, cwd: str, mcp_servers: list, session_id: str):
|
|
960
|
+
"""Load existing session."""
|
|
961
|
+
self._perm_store_load(cwd)
|
|
962
|
+
self.agent = _create_engine(cwd, perm_store=self._perm_store)
|
|
963
|
+
self.session_id = session_id
|
|
964
|
+
|
|
965
|
+
loaded = self.agent.load_session(session_id)
|
|
966
|
+
cfg = load_config()
|
|
967
|
+
self.agent.set_agent(cfg.default_mode)
|
|
968
|
+
self._perm_store.apply_to(self.agent.current_agent)
|
|
969
|
+
self._send_available_commands()
|
|
970
|
+
|
|
971
|
+
# Also restore tasks from project .cdh/ as fallback / supplement
|
|
972
|
+
self.agent.load_tasks_from_project()
|
|
973
|
+
|
|
974
|
+
if not loaded:
|
|
975
|
+
return {"modes": _DEFAULT_MODES}
|
|
976
|
+
|
|
977
|
+
# Walk the in-memory context (preserves list-of-blocks structure)
|
|
978
|
+
# instead of the raw _session.messages dicts, which only carry
|
|
979
|
+
# `content: str`. Engine.chat_stream() stores tool_use / tool_result
|
|
980
|
+
# blocks in context.Message.content, and we need them at replay time.
|
|
981
|
+
for ctx_msg in self.agent.context.messages:
|
|
982
|
+
role = ctx_msg.role
|
|
983
|
+
content = ctx_msg.content
|
|
984
|
+
if role == "user":
|
|
985
|
+
user_text = content if isinstance(content, str) else _extract_text_from_blocks(content)
|
|
986
|
+
self.send_session_update({
|
|
987
|
+
"sessionUpdate": "user_message_chunk",
|
|
988
|
+
"content": {"type": "text", "text": user_text},
|
|
989
|
+
})
|
|
990
|
+
elif role == "assistant":
|
|
991
|
+
if isinstance(content, str):
|
|
992
|
+
blocks = self._content_to_blocks(content)
|
|
993
|
+
else:
|
|
994
|
+
blocks = self._content_to_blocks(content)
|
|
995
|
+
|
|
996
|
+
for block in blocks:
|
|
997
|
+
if isinstance(block, TextBlock):
|
|
998
|
+
filtered_text = _filter_tool_call_text(block.content)
|
|
999
|
+
if filtered_text.strip():
|
|
1000
|
+
self.send_session_update({
|
|
1001
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1002
|
+
"content": {"type": "text", "text": filtered_text},
|
|
1003
|
+
})
|
|
1004
|
+
elif isinstance(block, ThinkBlock):
|
|
1005
|
+
filtered_thought = _filter_tool_call_text(block.content)
|
|
1006
|
+
if filtered_thought.strip():
|
|
1007
|
+
self.send_session_update({
|
|
1008
|
+
"sessionUpdate": "agent_thought_chunk",
|
|
1009
|
+
"content": {"type": "text", "text": filtered_thought},
|
|
1010
|
+
})
|
|
1011
|
+
elif isinstance(block, MsgToolCall):
|
|
1012
|
+
tool_kind = _kind_for_category(get_tool_category(block.name))
|
|
1013
|
+
content_blocks = _build_tool_call_content(
|
|
1014
|
+
block.name, block.arguments or {}
|
|
1015
|
+
)
|
|
1016
|
+
title = block.name
|
|
1017
|
+
args = block.arguments or {}
|
|
1018
|
+
if path := args.get("path", ""):
|
|
1019
|
+
title = f"{block.name}: {_short_path(path, self.agent._project_dir)}"
|
|
1020
|
+
elif block.name == "Bash":
|
|
1021
|
+
cmd = str(args.get("command", ""))
|
|
1022
|
+
title = f"Bash: {cmd}"
|
|
1023
|
+
elif block.name == "TaskCreate":
|
|
1024
|
+
subject = str(args.get("subject", ""))
|
|
1025
|
+
if subject:
|
|
1026
|
+
title = f"TaskCreate: {subject}"
|
|
1027
|
+
elif block.name == "TaskUpdate":
|
|
1028
|
+
subject = str(args.get("subject", ""))
|
|
1029
|
+
if subject:
|
|
1030
|
+
title = f"TaskUpdate: {subject}"
|
|
1031
|
+
elif block.name in ("TaskGet", "TaskStop", "TaskOutput"):
|
|
1032
|
+
tid = str(args.get("taskId", args.get("task_id", "")))
|
|
1033
|
+
if tid:
|
|
1034
|
+
title = f"{block.name}: {tid}"
|
|
1035
|
+
elif block.name == "TodoCreate":
|
|
1036
|
+
text = str(args.get("text", ""))
|
|
1037
|
+
if text:
|
|
1038
|
+
title = f"TodoCreate: {text[:40]}"
|
|
1039
|
+
elif block.name == "Glob":
|
|
1040
|
+
pattern = str(args.get("pattern", ""))
|
|
1041
|
+
if pattern:
|
|
1042
|
+
title = f"Glob: {pattern}"
|
|
1043
|
+
self.tool_calls[block.id] = {
|
|
1044
|
+
"sessionUpdate": "tool_call",
|
|
1045
|
+
"toolCallId": block.id,
|
|
1046
|
+
"title": title,
|
|
1047
|
+
"kind": tool_kind,
|
|
1048
|
+
"status": _wire_status(block.status.value),
|
|
1049
|
+
"content": content_blocks,
|
|
1050
|
+
"_tool_name": block.name,
|
|
1051
|
+
"_tool_args": block.arguments or {},
|
|
1052
|
+
}
|
|
1053
|
+
self.send_session_update(self.tool_calls[block.id])
|
|
1054
|
+
elif isinstance(block, ToolResult):
|
|
1055
|
+
self._emit_tool_result(block)
|
|
1056
|
+
elif isinstance(block, SubAgentBlock):
|
|
1057
|
+
content_block = [{
|
|
1058
|
+
"type": "content",
|
|
1059
|
+
"content": {"type": "text", "text": block.result},
|
|
1060
|
+
}] if block.result else []
|
|
1061
|
+
self.send_session_update({
|
|
1062
|
+
"sessionUpdate": "subagent_start",
|
|
1063
|
+
"subagentId": block.id,
|
|
1064
|
+
"agentType": block.agent_type,
|
|
1065
|
+
})
|
|
1066
|
+
if block.result:
|
|
1067
|
+
self.send_session_update({
|
|
1068
|
+
"sessionUpdate": "subagent_chunk",
|
|
1069
|
+
"subagentId": block.id,
|
|
1070
|
+
"text": block.result,
|
|
1071
|
+
})
|
|
1072
|
+
self.send_session_update({
|
|
1073
|
+
"sessionUpdate": "subagent_end",
|
|
1074
|
+
"subagentId": block.id,
|
|
1075
|
+
"agentType": block.agent_type,
|
|
1076
|
+
})
|
|
1077
|
+
elif role == "tool":
|
|
1078
|
+
if isinstance(content, list):
|
|
1079
|
+
for item in content:
|
|
1080
|
+
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
1081
|
+
tr = ToolResult(
|
|
1082
|
+
tool_use_id=item.get("tool_use_id", ""),
|
|
1083
|
+
content=item.get("content", ""),
|
|
1084
|
+
is_error=item.get("is_error", False),
|
|
1085
|
+
)
|
|
1086
|
+
self._emit_tool_result(tr)
|
|
1087
|
+
elif isinstance(content, dict) and content.get("type") == "tool_result":
|
|
1088
|
+
tr = ToolResult(
|
|
1089
|
+
tool_use_id=content.get("tool_use_id", ""),
|
|
1090
|
+
content=content.get("content", ""),
|
|
1091
|
+
is_error=content.get("is_error", False),
|
|
1092
|
+
)
|
|
1093
|
+
self._emit_tool_result(tr)
|
|
1094
|
+
|
|
1095
|
+
return {
|
|
1096
|
+
"modes": {
|
|
1097
|
+
"currentModeId": cfg.default_mode,
|
|
1098
|
+
"availableModes": _DEFAULT_MODES["availableModes"],
|
|
1099
|
+
},
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
def _make_stream_callback(self):
|
|
1103
|
+
"""Create a thinking-aware streaming callback for real-time token output.
|
|
1104
|
+
|
|
1105
|
+
Buffers text deltas from the provider, detects <thinking>...</thinking>,
|
|
1106
|
+
[TOOL_CALL]...[/TOOL_CALL], <minimax:tool_call>...</minimax:tool_call>,
|
|
1107
|
+
and bare `{tool => "X", args => { ... }}` boundaries (all cross-chunk
|
|
1108
|
+
tolerant), strips them from the text buffer so they don't appear as
|
|
1109
|
+
raw agent messages, and routes remaining content to the appropriate
|
|
1110
|
+
ACP session update type.
|
|
1111
|
+
|
|
1112
|
+
Tool call notifications are sent by TOOL_CALL_START/TOOL_CALL_COMPLETE
|
|
1113
|
+
events from the engine, not from text parsing here.
|
|
1114
|
+
"""
|
|
1115
|
+
# Safety cap on the held buffer: if a model emits an unclosed
|
|
1116
|
+
# ``<thinking>`` / ``[TOOL_CALL]`` / ``<minimax:tool_call>`` tag
|
|
1117
|
+
# and then the stream is truncated (network drop, cancel,
|
|
1118
|
+
# provider error before the close marker arrives), the buffer
|
|
1119
|
+
# would otherwise grow forever and every subsequent chunk
|
|
1120
|
+
# would be silently swallowed. When the buffer exceeds this
|
|
1121
|
+
# cap we give up on detecting markers and flush the held text
|
|
1122
|
+
# as a plain message so the user still sees *something*.
|
|
1123
|
+
_MAX_HELD_BYTES = 64 * 1024
|
|
1124
|
+
|
|
1125
|
+
chunker = getattr(self, "_text_chunker", None)
|
|
1126
|
+
_direct_send = self.send_session_update # fallback when chunker not configured
|
|
1127
|
+
text_buffer = ""
|
|
1128
|
+
in_thinking = False
|
|
1129
|
+
in_tool_call = False
|
|
1130
|
+
in_minimax_tool_call = False
|
|
1131
|
+
in_bare_tool_call = False
|
|
1132
|
+
bare_tool_start = 0
|
|
1133
|
+
thinking_sent_len = 0 # how much of text_buffer has been sent to TUI
|
|
1134
|
+
|
|
1135
|
+
def _flush_held_buffer() -> None:
|
|
1136
|
+
"""Force-emit the held buffer as a plain message and reset
|
|
1137
|
+
all "in marker" flags. Used by the watchdog below and by
|
|
1138
|
+
the early-exit path when a turn ends without a close
|
|
1139
|
+
marker.
|
|
1140
|
+
"""
|
|
1141
|
+
nonlocal text_buffer, thinking_sent_len
|
|
1142
|
+
nonlocal in_thinking, in_tool_call
|
|
1143
|
+
nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
|
|
1144
|
+
if chunker:
|
|
1145
|
+
chunker.flush()
|
|
1146
|
+
if text_buffer.strip():
|
|
1147
|
+
_direct_send({
|
|
1148
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1149
|
+
"content": {"type": "text", "text": text_buffer},
|
|
1150
|
+
})
|
|
1151
|
+
text_buffer = ""
|
|
1152
|
+
thinking_sent_len = 0
|
|
1153
|
+
in_thinking = in_tool_call = False
|
|
1154
|
+
in_minimax_tool_call = in_bare_tool_call = False
|
|
1155
|
+
bare_tool_start = 0
|
|
1156
|
+
|
|
1157
|
+
def _emit_message(text: str) -> None:
|
|
1158
|
+
if not text:
|
|
1159
|
+
return
|
|
1160
|
+
if chunker:
|
|
1161
|
+
chunker.append(text)
|
|
1162
|
+
else:
|
|
1163
|
+
_direct_send({
|
|
1164
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1165
|
+
"content": {"type": "text", "text": text},
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
def _safe_start(s: str) -> str:
|
|
1169
|
+
"""Return text before any partial <thinking / <think or [TOOL_CALL] or
|
|
1170
|
+
<minimax:tool_call> tag at buffer end."""
|
|
1171
|
+
tags = ("<thinking>", "<think>", "[TOOL_CALL]", _MINIMAX_OPEN)
|
|
1172
|
+
cut = len(s)
|
|
1173
|
+
for tag in tags:
|
|
1174
|
+
for i in range(len(tag) - 1, 0, -1):
|
|
1175
|
+
if s.endswith(tag[:i]):
|
|
1176
|
+
cut = min(cut, len(s) - i)
|
|
1177
|
+
break
|
|
1178
|
+
return s[:cut]
|
|
1179
|
+
|
|
1180
|
+
def on_chunk(text: str):
|
|
1181
|
+
nonlocal text_buffer, thinking_sent_len, in_thinking, in_tool_call
|
|
1182
|
+
nonlocal in_minimax_tool_call, in_bare_tool_call, bare_tool_start
|
|
1183
|
+
text_buffer += text
|
|
1184
|
+
|
|
1185
|
+
# Watchdog: if a marker opened but the close never arrived
|
|
1186
|
+
# and the buffer has grown past the safety cap, give up
|
|
1187
|
+
# and emit the held text as a plain message. The model
|
|
1188
|
+
# is either malformed or the stream was truncated; either
|
|
1189
|
+
# way the user should see *something* rather than a
|
|
1190
|
+
# silently held buffer that never resolves.
|
|
1191
|
+
if (
|
|
1192
|
+
in_thinking
|
|
1193
|
+
or in_tool_call
|
|
1194
|
+
or in_minimax_tool_call
|
|
1195
|
+
or in_bare_tool_call
|
|
1196
|
+
) and len(text_buffer) > _MAX_HELD_BYTES:
|
|
1197
|
+
_flush_held_buffer()
|
|
1198
|
+
# After flushing, fall through into the normal
|
|
1199
|
+
# scanning loop on the now-empty buffer.
|
|
1200
|
+
|
|
1201
|
+
while text_buffer:
|
|
1202
|
+
if in_thinking:
|
|
1203
|
+
close_thinking = text_buffer.find("</thinking>")
|
|
1204
|
+
close_think = (
|
|
1205
|
+
text_buffer.find("</think>")
|
|
1206
|
+
if close_thinking < 0 else -1
|
|
1207
|
+
)
|
|
1208
|
+
idx = close_thinking if close_thinking >= 0 else close_think
|
|
1209
|
+
close_len = (
|
|
1210
|
+
len("</thinking>") if close_thinking >= 0
|
|
1211
|
+
else len("</think>") if close_think >= 0 else 0
|
|
1212
|
+
)
|
|
1213
|
+
if idx >= 0:
|
|
1214
|
+
thinking = text_buffer[:idx]
|
|
1215
|
+
if thinking:
|
|
1216
|
+
# Send any remaining partial before the close
|
|
1217
|
+
if thinking_sent_len < len(thinking):
|
|
1218
|
+
partial = thinking[thinking_sent_len:]
|
|
1219
|
+
self.send_session_update({
|
|
1220
|
+
"sessionUpdate": "agent_thought_chunk",
|
|
1221
|
+
"content": {"type": "text", "text": partial},
|
|
1222
|
+
})
|
|
1223
|
+
# Persist COMPLETE thinking to engine context
|
|
1224
|
+
on_thinking = getattr(self.agent, "on_thinking", None)
|
|
1225
|
+
if on_thinking is not None:
|
|
1226
|
+
try:
|
|
1227
|
+
on_thinking(thinking)
|
|
1228
|
+
except Exception:
|
|
1229
|
+
debug_log("on_thinking callback failed", exc_info=True)
|
|
1230
|
+
text_buffer = text_buffer[idx + close_len:]
|
|
1231
|
+
thinking_sent_len = 0
|
|
1232
|
+
in_thinking = False
|
|
1233
|
+
else:
|
|
1234
|
+
# Stream partial thinking content incrementally to TUI
|
|
1235
|
+
if thinking_sent_len < len(text_buffer):
|
|
1236
|
+
partial = text_buffer[thinking_sent_len:]
|
|
1237
|
+
self.send_session_update({
|
|
1238
|
+
"sessionUpdate": "agent_thought_chunk",
|
|
1239
|
+
"content": {"type": "text", "text": partial},
|
|
1240
|
+
})
|
|
1241
|
+
thinking_sent_len = len(text_buffer)
|
|
1242
|
+
break
|
|
1243
|
+
elif in_tool_call:
|
|
1244
|
+
close_idx = text_buffer.find(_CLOSE_MARKER)
|
|
1245
|
+
if close_idx < 0:
|
|
1246
|
+
# No close marker yet — hold the whole buffer.
|
|
1247
|
+
break
|
|
1248
|
+
# Strip [TOOL_CALL]...[/TOOL_CALL] from text buffer.
|
|
1249
|
+
# The engine emits TOOL_CALL_START/COMPLETE separately.
|
|
1250
|
+
text_buffer = text_buffer[close_idx + len(_CLOSE_MARKER):]
|
|
1251
|
+
in_tool_call = False
|
|
1252
|
+
elif in_minimax_tool_call:
|
|
1253
|
+
close_idx = text_buffer.find(_MINIMAX_CLOSE)
|
|
1254
|
+
if close_idx < 0:
|
|
1255
|
+
# No close marker yet — hold the whole buffer.
|
|
1256
|
+
break
|
|
1257
|
+
# Strip <minimax:tool_call>...</minimax:tool_call> from
|
|
1258
|
+
# the text buffer. The engine emits the structured
|
|
1259
|
+
# TOOL_CALL_START/COMPLETE events separately.
|
|
1260
|
+
text_buffer = text_buffer[close_idx + len(_MINIMAX_CLOSE):]
|
|
1261
|
+
in_minimax_tool_call = False
|
|
1262
|
+
elif in_bare_tool_call:
|
|
1263
|
+
m = _BARE_LEGACY_RE.match(text_buffer, bare_tool_start)
|
|
1264
|
+
if m is None:
|
|
1265
|
+
# Pattern got invalidated — discard the held span.
|
|
1266
|
+
text_buffer = text_buffer[bare_tool_start:]
|
|
1267
|
+
in_bare_tool_call = False
|
|
1268
|
+
bare_tool_start = 0
|
|
1269
|
+
break
|
|
1270
|
+
args_open = text_buffer.find("{", m.end() - 1)
|
|
1271
|
+
if args_open < 0:
|
|
1272
|
+
break
|
|
1273
|
+
args_close = _scan_balanced_braces(text_buffer, args_open)
|
|
1274
|
+
if args_close is None:
|
|
1275
|
+
# Need more chunks to close the inner args.
|
|
1276
|
+
break
|
|
1277
|
+
outer_close = _scan_balanced_braces(text_buffer, bare_tool_start)
|
|
1278
|
+
if outer_close is None or outer_close <= args_close:
|
|
1279
|
+
break
|
|
1280
|
+
text_buffer = text_buffer[outer_close + 1:]
|
|
1281
|
+
in_bare_tool_call = False
|
|
1282
|
+
bare_tool_start = 0
|
|
1283
|
+
else:
|
|
1284
|
+
tc_idx = text_buffer.find(_OPEN_MARKER)
|
|
1285
|
+
minimax_idx = text_buffer.find(_MINIMAX_OPEN)
|
|
1286
|
+
think_idx = text_buffer.find("<thinking>")
|
|
1287
|
+
if think_idx < 0:
|
|
1288
|
+
think_short_idx = text_buffer.find("<think>")
|
|
1289
|
+
else:
|
|
1290
|
+
think_short_idx = -1
|
|
1291
|
+
bare_idx = _find_bare_legacy_tool_call(text_buffer)
|
|
1292
|
+
|
|
1293
|
+
# Pick the earliest opener among all known markers.
|
|
1294
|
+
candidates: list[tuple[int, str]] = []
|
|
1295
|
+
if tc_idx >= 0:
|
|
1296
|
+
candidates.append((tc_idx, "legacy"))
|
|
1297
|
+
if minimax_idx >= 0:
|
|
1298
|
+
candidates.append((minimax_idx, "minimax"))
|
|
1299
|
+
if think_idx >= 0:
|
|
1300
|
+
candidates.append((think_idx, "think"))
|
|
1301
|
+
if think_short_idx >= 0:
|
|
1302
|
+
candidates.append((think_short_idx, "think_short"))
|
|
1303
|
+
if bare_idx is not None:
|
|
1304
|
+
candidates.append((bare_idx, "bare"))
|
|
1305
|
+
|
|
1306
|
+
if not candidates:
|
|
1307
|
+
safe = _safe_start(text_buffer)
|
|
1308
|
+
if safe:
|
|
1309
|
+
_emit_message(safe)
|
|
1310
|
+
text_buffer = text_buffer[len(safe):]
|
|
1311
|
+
if not _safe_start(text_buffer):
|
|
1312
|
+
text_buffer = ""
|
|
1313
|
+
continue
|
|
1314
|
+
|
|
1315
|
+
candidates.sort()
|
|
1316
|
+
next_idx, kind = candidates[0]
|
|
1317
|
+
|
|
1318
|
+
if next_idx > 0:
|
|
1319
|
+
_emit_message(text_buffer[:next_idx])
|
|
1320
|
+
|
|
1321
|
+
if kind == "minimax":
|
|
1322
|
+
text_buffer = text_buffer[next_idx + len(_MINIMAX_OPEN):]
|
|
1323
|
+
in_minimax_tool_call = True
|
|
1324
|
+
elif kind == "legacy":
|
|
1325
|
+
text_buffer = text_buffer[next_idx + len(_OPEN_MARKER):]
|
|
1326
|
+
in_tool_call = True
|
|
1327
|
+
elif kind in ("think", "think_short"):
|
|
1328
|
+
tag_len = len("<thinking>") if kind == "think" else len("<think>")
|
|
1329
|
+
text_buffer = text_buffer[next_idx + tag_len:]
|
|
1330
|
+
in_thinking = True
|
|
1331
|
+
else:
|
|
1332
|
+
# Bare legacy: peek whether the closing brace is
|
|
1333
|
+
# already in the buffer.
|
|
1334
|
+
m = _BARE_LEGACY_RE.match(text_buffer, next_idx)
|
|
1335
|
+
args_open = text_buffer.find("{", m.end() - 1) if m else -1
|
|
1336
|
+
args_close = (
|
|
1337
|
+
_scan_balanced_braces(text_buffer, args_open)
|
|
1338
|
+
if args_open >= 0 else None
|
|
1339
|
+
)
|
|
1340
|
+
outer_close = (
|
|
1341
|
+
_scan_balanced_braces(text_buffer, next_idx)
|
|
1342
|
+
if args_close is not None else None
|
|
1343
|
+
)
|
|
1344
|
+
if (
|
|
1345
|
+
m is not None and args_open >= 0
|
|
1346
|
+
and args_close is not None
|
|
1347
|
+
and outer_close is not None
|
|
1348
|
+
and outer_close > args_close
|
|
1349
|
+
):
|
|
1350
|
+
text_buffer = text_buffer[outer_close + 1:]
|
|
1351
|
+
else:
|
|
1352
|
+
in_bare_tool_call = True
|
|
1353
|
+
bare_tool_start = next_idx
|
|
1354
|
+
break
|
|
1355
|
+
|
|
1356
|
+
return on_chunk
|
|
1357
|
+
|
|
1358
|
+
async def session_prompt(self, prompt: list, session_id: str):
|
|
1359
|
+
"""Send prompt to agent and stream results."""
|
|
1360
|
+
if self.agent is None:
|
|
1361
|
+
debug_log("session_prompt called but self.agent is None")
|
|
1362
|
+
return {"stopReason": "error", "message": "No agent initialized"}
|
|
1363
|
+
|
|
1364
|
+
debug_log(
|
|
1365
|
+
"session_prompt start session=%s prompt_blocks=%d",
|
|
1366
|
+
session_id, len(prompt),
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
# Collect ALL content blocks from the prompt (text, resource, etc.)
|
|
1370
|
+
# instead of only extracting the text portion.
|
|
1371
|
+
user_content: list[dict] = []
|
|
1372
|
+
for block in prompt:
|
|
1373
|
+
btype = block.get("type", "text")
|
|
1374
|
+
if btype == "text":
|
|
1375
|
+
user_content.append(block)
|
|
1376
|
+
elif btype == "resource":
|
|
1377
|
+
user_content.append(block)
|
|
1378
|
+
elif btype == "image":
|
|
1379
|
+
user_content.append(block)
|
|
1380
|
+
else:
|
|
1381
|
+
# Pass through unknown types, the context layer will handle them
|
|
1382
|
+
user_content.append(block)
|
|
1383
|
+
|
|
1384
|
+
# ── Slash-command handling ─────────────────────────────
|
|
1385
|
+
if user_content and user_content[0].get("type") == "text":
|
|
1386
|
+
text = user_content[0].get("text", "").strip()
|
|
1387
|
+
if text == "/permission":
|
|
1388
|
+
return self._handle_reset_permission()
|
|
1389
|
+
if text.startswith("/permission "):
|
|
1390
|
+
key = text.split("/permission ", 1)[1].strip()
|
|
1391
|
+
return self._handle_reset_permission(key)
|
|
1392
|
+
|
|
1393
|
+
# Fresh per-turn tracking (tool_calls accumulates across one turn only)
|
|
1394
|
+
self.tool_calls = {}
|
|
1395
|
+
self.agent._cancelled = False
|
|
1396
|
+
|
|
1397
|
+
# Create chunkers for agent_message_chunk and agent_thought_chunk
|
|
1398
|
+
self._text_chunker = TextChunker(
|
|
1399
|
+
send_fn=lambda text: self.send_session_update({
|
|
1400
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1401
|
+
"content": {"type": "text", "text": text},
|
|
1402
|
+
}),
|
|
1403
|
+
mode="auto",
|
|
1404
|
+
words=5,
|
|
1405
|
+
)
|
|
1406
|
+
self._thought_chunker = TextChunker(
|
|
1407
|
+
send_fn=lambda text: self.send_session_update({
|
|
1408
|
+
"sessionUpdate": "agent_thought_chunk",
|
|
1409
|
+
"content": {"type": "text", "text": text},
|
|
1410
|
+
}),
|
|
1411
|
+
mode="auto",
|
|
1412
|
+
words=5,
|
|
1413
|
+
)
|
|
1414
|
+
self.agent.on_text_chunk = self._make_stream_callback()
|
|
1415
|
+
# Persist thinking blocks into the engine's context so they survive
|
|
1416
|
+
# session reload (otherwise the TUI only sees them during streaming).
|
|
1417
|
+
self.agent.on_thinking = lambda text: self.agent._pending_thinking_blocks.append(text)
|
|
1418
|
+
|
|
1419
|
+
# Track in-progress tool call args for incremental display
|
|
1420
|
+
_incremental_args: dict[str, str] = {}
|
|
1421
|
+
_last_sent_len: dict[str, int] = {}
|
|
1422
|
+
_ARGS_THROTTLE_CHARS = 50
|
|
1423
|
+
|
|
1424
|
+
def _on_tool_call_delta(call_id: str, name: str, args_delta: str) -> None:
|
|
1425
|
+
if args_delta:
|
|
1426
|
+
_incremental_args[call_id] = _incremental_args.get(call_id, "") + args_delta
|
|
1427
|
+
display_args = _incremental_args[call_id]
|
|
1428
|
+
# Throttle: only send when accumulated enough new chars
|
|
1429
|
+
if len(display_args) - _last_sent_len.get(call_id, 0) < _ARGS_THROTTLE_CHARS:
|
|
1430
|
+
return
|
|
1431
|
+
_last_sent_len[call_id] = len(display_args)
|
|
1432
|
+
try:
|
|
1433
|
+
parsed = json.loads(display_args)
|
|
1434
|
+
display_text = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
1435
|
+
except (json.JSONDecodeError, TypeError):
|
|
1436
|
+
display_text = display_args
|
|
1437
|
+
self.send_session_update({
|
|
1438
|
+
"sessionUpdate": "tool_call_update",
|
|
1439
|
+
"toolCallId": call_id,
|
|
1440
|
+
"title": name or "Tool call",
|
|
1441
|
+
"status": "in_progress",
|
|
1442
|
+
"content": [{
|
|
1443
|
+
"type": "content",
|
|
1444
|
+
"content": {"type": "text", "text": f"```json\n{display_text}\n```"},
|
|
1445
|
+
}] if display_text else [],
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
self.agent.on_tool_call_delta = _on_tool_call_delta
|
|
1449
|
+
try:
|
|
1450
|
+
async for event in self.agent.chat_stream(user_content):
|
|
1451
|
+
if event.type == StreamEventType.TEXT_DELTA:
|
|
1452
|
+
self._text_chunker.append(event.text)
|
|
1453
|
+
elif event.type == StreamEventType.THINKING:
|
|
1454
|
+
self._thought_chunker.append(event.thinking)
|
|
1455
|
+
elif event.type == StreamEventType.TOOL_CALL_START:
|
|
1456
|
+
self._text_chunker.flush()
|
|
1457
|
+
self._thought_chunker.flush()
|
|
1458
|
+
tool_kind = _kind_for_category(event.tool_category)
|
|
1459
|
+
existing = self.tool_calls.get(event.tool_id, {})
|
|
1460
|
+
self.tool_calls[event.tool_id] = {
|
|
1461
|
+
"sessionUpdate": "tool_call",
|
|
1462
|
+
"toolCallId": event.tool_id,
|
|
1463
|
+
"title": event.tool_name,
|
|
1464
|
+
"kind": tool_kind,
|
|
1465
|
+
"status": "in_progress",
|
|
1466
|
+
"content": existing.get("content", []),
|
|
1467
|
+
"_tool_name": event.tool_name,
|
|
1468
|
+
}
|
|
1469
|
+
self.send_session_update(self.tool_calls[event.tool_id])
|
|
1470
|
+
elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
|
|
1471
|
+
if event.tool_id in self.tool_calls:
|
|
1472
|
+
title = event.tool_name
|
|
1473
|
+
if event.tool_args:
|
|
1474
|
+
if path := event.tool_args.get("path", ""):
|
|
1475
|
+
title = f"{event.tool_name}: {_short_path(path, self.agent._project_dir)}"
|
|
1476
|
+
elif event.tool_name == "Bash":
|
|
1477
|
+
cmd = str(event.tool_args.get("command", ""))
|
|
1478
|
+
title = f"Bash: {cmd}"
|
|
1479
|
+
elif event.tool_name == "TaskCreate":
|
|
1480
|
+
subject = str(event.tool_args.get("subject", ""))
|
|
1481
|
+
if subject:
|
|
1482
|
+
title = f"TaskCreate: {subject}"
|
|
1483
|
+
elif event.tool_name == "TaskUpdate":
|
|
1484
|
+
subject = str(event.tool_args.get("subject", ""))
|
|
1485
|
+
if subject:
|
|
1486
|
+
title = f"TaskUpdate: {subject}"
|
|
1487
|
+
elif event.tool_name in ("TaskGet", "TaskStop", "TaskOutput"):
|
|
1488
|
+
tid = str(event.tool_args.get("taskId", event.tool_args.get("task_id", "")))
|
|
1489
|
+
if tid:
|
|
1490
|
+
title = f"{event.tool_name}: {tid}"
|
|
1491
|
+
elif event.tool_name == "TodoCreate":
|
|
1492
|
+
text = str(event.tool_args.get("text", ""))
|
|
1493
|
+
if text:
|
|
1494
|
+
title = f"TodoCreate: {text[:40]}"
|
|
1495
|
+
elif event.tool_name == "Glob":
|
|
1496
|
+
pattern = str(event.tool_args.get("pattern", ""))
|
|
1497
|
+
if pattern:
|
|
1498
|
+
title = f"Glob: {pattern}"
|
|
1499
|
+
content = _build_tool_call_content(
|
|
1500
|
+
event.tool_name, event.tool_args
|
|
1501
|
+
)
|
|
1502
|
+
debug_log(
|
|
1503
|
+
"TOOL_CALL_COMPLETE %s id=%s args_keys=%s title=%s content_blocks=%d",
|
|
1504
|
+
event.tool_name, event.tool_id,
|
|
1505
|
+
list(event.tool_args.keys()), title,
|
|
1506
|
+
len(content),
|
|
1507
|
+
)
|
|
1508
|
+
else:
|
|
1509
|
+
content = []
|
|
1510
|
+
debug_log(
|
|
1511
|
+
"TOOL_CALL_COMPLETE %s id=%s NO args",
|
|
1512
|
+
event.tool_name, event.tool_id,
|
|
1513
|
+
)
|
|
1514
|
+
self.tool_calls[event.tool_id].update({
|
|
1515
|
+
"sessionUpdate": "tool_call_update",
|
|
1516
|
+
"toolCallId": event.tool_id,
|
|
1517
|
+
"title": title,
|
|
1518
|
+
"status": "pending",
|
|
1519
|
+
"content": content,
|
|
1520
|
+
"_tool_name": event.tool_name,
|
|
1521
|
+
"_tool_args": event.tool_args,
|
|
1522
|
+
})
|
|
1523
|
+
self.send_session_update(self.tool_calls[event.tool_id])
|
|
1524
|
+
elif event.type == StreamEventType.TOOL_RESULT:
|
|
1525
|
+
status = "failed" if event.result_is_error else "completed"
|
|
1526
|
+
tname = self.tool_calls.get(event.tool_id, {}).get("_tool_name", "")
|
|
1527
|
+
display_text = _format_tui_display_text(
|
|
1528
|
+
event.result_content or "", tname,
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
# Update title with path/subject from result if not already set
|
|
1532
|
+
if event.result_content and event.tool_id in self.tool_calls:
|
|
1533
|
+
try:
|
|
1534
|
+
parsed = json.loads(event.result_content)
|
|
1535
|
+
except json.JSONDecodeError:
|
|
1536
|
+
pass
|
|
1537
|
+
else:
|
|
1538
|
+
if isinstance(parsed, dict) and parsed.get("success") is True:
|
|
1539
|
+
current_title = self.tool_calls[event.tool_id].get("title", "")
|
|
1540
|
+
if ":" not in current_title:
|
|
1541
|
+
tname = self.tool_calls[event.tool_id].get("_tool_name", "")
|
|
1542
|
+
if tname in ("TaskCreate", "TaskUpdate"):
|
|
1543
|
+
task_info = parsed.get("task", {})
|
|
1544
|
+
if isinstance(task_info, dict):
|
|
1545
|
+
if subject := task_info.get("subject", ""):
|
|
1546
|
+
self.tool_calls[event.tool_id]["title"] = f"{current_title}: {subject}"
|
|
1547
|
+
elif path := parsed.get("path"):
|
|
1548
|
+
self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
|
|
1549
|
+
elif path := parsed.get("path"):
|
|
1550
|
+
self.tool_calls[event.tool_id]["title"] = f"{current_title}: {_short_path(path, self.agent._project_dir)}"
|
|
1551
|
+
|
|
1552
|
+
# Add "denied" hint for reject-always overrides
|
|
1553
|
+
if event.result_is_error and display_text and "denied" in display_text:
|
|
1554
|
+
display_text += "\n\n💡 Use `/permission` to clear a 'reject always' override."
|
|
1555
|
+
|
|
1556
|
+
# TUI now renders all text content as Markdown.
|
|
1557
|
+
# Wrap multi-line results in a fenced code block so they
|
|
1558
|
+
# display as a code block rather than a raw paragraph.
|
|
1559
|
+
# For Read results, detect language from the file path.
|
|
1560
|
+
# For Bash/Glob, use bash-style fence.
|
|
1561
|
+
# Skip if already contains a code fence to avoid nesting.
|
|
1562
|
+
if display_text and "\n" in display_text and "```" not in display_text:
|
|
1563
|
+
lang = ""
|
|
1564
|
+
tool_info = self.tool_calls.get(event.tool_id, {})
|
|
1565
|
+
tname = tool_info.get("_tool_name", "")
|
|
1566
|
+
targs = tool_info.get("_tool_args", {})
|
|
1567
|
+
if tname in ("Bash", "Glob"):
|
|
1568
|
+
lang = "bash"
|
|
1569
|
+
elif tname == "Read" and isinstance(targs, dict):
|
|
1570
|
+
path = str(targs.get("path", ""))
|
|
1571
|
+
if path:
|
|
1572
|
+
lang = _language_for_path(path)
|
|
1573
|
+
if lang:
|
|
1574
|
+
display_text = f"```{lang}\n{display_text}\n```"
|
|
1575
|
+
else:
|
|
1576
|
+
display_text = f"```\n{display_text}\n```"
|
|
1577
|
+
content_block = [{
|
|
1578
|
+
"type": "content",
|
|
1579
|
+
"content": {"type": "text", "text": display_text},
|
|
1580
|
+
}] if display_text else []
|
|
1581
|
+
if event.tool_id in self.tool_calls:
|
|
1582
|
+
existing_content = self.tool_calls[event.tool_id].get("content", [])
|
|
1583
|
+
else:
|
|
1584
|
+
existing_content = []
|
|
1585
|
+
debug_log(
|
|
1586
|
+
"TOOL_RESULT id=%s status=%s result_len=%d display_len=%d existing_blocks=%d new_blocks=%d",
|
|
1587
|
+
event.tool_id, status,
|
|
1588
|
+
len(event.result_content or ""), len(display_text),
|
|
1589
|
+
len(existing_content), len(content_block),
|
|
1590
|
+
)
|
|
1591
|
+
update = {
|
|
1592
|
+
"sessionUpdate": "tool_call_update",
|
|
1593
|
+
"toolCallId": event.tool_id,
|
|
1594
|
+
"status": status,
|
|
1595
|
+
"content": existing_content + content_block,
|
|
1596
|
+
}
|
|
1597
|
+
if event.tool_id in self.tool_calls:
|
|
1598
|
+
self.tool_calls[event.tool_id].update(update)
|
|
1599
|
+
else:
|
|
1600
|
+
self.tool_calls[event.tool_id] = {
|
|
1601
|
+
"sessionUpdate": "tool_call",
|
|
1602
|
+
"toolCallId": event.tool_id,
|
|
1603
|
+
"title": "Tool call",
|
|
1604
|
+
"kind": "other",
|
|
1605
|
+
"status": status,
|
|
1606
|
+
**update,
|
|
1607
|
+
}
|
|
1608
|
+
self.send_session_update(self.tool_calls[event.tool_id])
|
|
1609
|
+
try:
|
|
1610
|
+
self.agent.save_session()
|
|
1611
|
+
except Exception:
|
|
1612
|
+
debug_log("Failed to save session after tool result", exc_info=True)
|
|
1613
|
+
elif event.type == StreamEventType.ERROR:
|
|
1614
|
+
self._text_chunker.flush()
|
|
1615
|
+
self._thought_chunker.flush()
|
|
1616
|
+
self.send_session_update({
|
|
1617
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1618
|
+
"content": {"type": "text", "text": f"Error: {event.error_message}"},
|
|
1619
|
+
})
|
|
1620
|
+
try:
|
|
1621
|
+
self.agent.save_session()
|
|
1622
|
+
except Exception:
|
|
1623
|
+
debug_log("Failed to save session on error", exc_info=True)
|
|
1624
|
+
return {"stopReason": "error", "message": event.error_message}
|
|
1625
|
+
elif event.type == StreamEventType.SUBAGENT_START:
|
|
1626
|
+
self._text_chunker.flush()
|
|
1627
|
+
self._thought_chunker.flush()
|
|
1628
|
+
self.send_session_update({
|
|
1629
|
+
"sessionUpdate": "subagent_start",
|
|
1630
|
+
"subagentId": event.subagent_id,
|
|
1631
|
+
"agentType": event.subagent_type,
|
|
1632
|
+
})
|
|
1633
|
+
elif event.type == StreamEventType.SUBAGENT_CHUNK:
|
|
1634
|
+
self.send_session_update({
|
|
1635
|
+
"sessionUpdate": "subagent_chunk",
|
|
1636
|
+
"subagentId": event.subagent_id,
|
|
1637
|
+
"text": event.subagent_text,
|
|
1638
|
+
})
|
|
1639
|
+
elif event.type == StreamEventType.SUBAGENT_END:
|
|
1640
|
+
self.send_session_update({
|
|
1641
|
+
"sessionUpdate": "subagent_end",
|
|
1642
|
+
"subagentId": event.subagent_id,
|
|
1643
|
+
"agentType": event.subagent_type,
|
|
1644
|
+
})
|
|
1645
|
+
elif event.type == StreamEventType.PLAN:
|
|
1646
|
+
self.send_session_update({
|
|
1647
|
+
"sessionUpdate": "plan",
|
|
1648
|
+
"entries": event.plan_entries,
|
|
1649
|
+
})
|
|
1650
|
+
elif event.type == StreamEventType.ASK_USER:
|
|
1651
|
+
self._text_chunker.flush()
|
|
1652
|
+
self._thought_chunker.flush()
|
|
1653
|
+
|
|
1654
|
+
# Handle AskUser tool — show question inline via session/update notification
|
|
1655
|
+
if event.ask_action == "AskUser":
|
|
1656
|
+
if event.ask_questions:
|
|
1657
|
+
self.send_session_update({
|
|
1658
|
+
"sessionUpdate": "ask_user",
|
|
1659
|
+
"questions": event.ask_questions,
|
|
1660
|
+
"context": event.ask_context or "",
|
|
1661
|
+
"toolId": event.tool_id,
|
|
1662
|
+
})
|
|
1663
|
+
else:
|
|
1664
|
+
self.send_session_update({
|
|
1665
|
+
"sessionUpdate": "ask_user",
|
|
1666
|
+
"question": event.ask_question,
|
|
1667
|
+
"context": event.ask_context or "",
|
|
1668
|
+
"options": event.ask_options,
|
|
1669
|
+
"toolId": event.tool_id,
|
|
1670
|
+
})
|
|
1671
|
+
answer = ""
|
|
1672
|
+
cancelled = False
|
|
1673
|
+
# AskUser re-ask flow:
|
|
1674
|
+
# 1) send full question, wait up to 60s for response
|
|
1675
|
+
# 2) on timeout: send brief "请确认" reminder, wait 60s
|
|
1676
|
+
# 3) on second timeout: use the default/best option
|
|
1677
|
+
# instead of cancelling, so the agent can keep going
|
|
1678
|
+
# with a sensible default choice.
|
|
1679
|
+
_ASK_USER_REASK_TIMEOUT = 60
|
|
1680
|
+
_no_response = False
|
|
1681
|
+
for _attempt in range(2):
|
|
1682
|
+
try:
|
|
1683
|
+
self._ask_user_future = asyncio.get_event_loop().create_future()
|
|
1684
|
+
ask_response = await asyncio.wait_for(
|
|
1685
|
+
self._ask_user_future,
|
|
1686
|
+
timeout=_ASK_USER_REASK_TIMEOUT,
|
|
1687
|
+
)
|
|
1688
|
+
answer = ask_response.get("answer", "")
|
|
1689
|
+
cancelled = ask_response.get("cancelled", False)
|
|
1690
|
+
_no_response = False
|
|
1691
|
+
break
|
|
1692
|
+
except (asyncio.TimeoutError, Exception):
|
|
1693
|
+
if _attempt == 0:
|
|
1694
|
+
# Brief re-ask: do NOT resend the full question
|
|
1695
|
+
self.send_session_update({
|
|
1696
|
+
"sessionUpdate": "ask_user_remind",
|
|
1697
|
+
"text": "请确认?",
|
|
1698
|
+
"toolId": event.tool_id,
|
|
1699
|
+
})
|
|
1700
|
+
_no_response = True
|
|
1701
|
+
continue
|
|
1702
|
+
# Second timeout: pick the default option
|
|
1703
|
+
answer = self._pick_default_ask_user_answer(event)
|
|
1704
|
+
cancelled = False
|
|
1705
|
+
_no_response = True
|
|
1706
|
+
finally:
|
|
1707
|
+
self._ask_user_future = None
|
|
1708
|
+
if _no_response and not cancelled and answer:
|
|
1709
|
+
self.send_session_update({
|
|
1710
|
+
"sessionUpdate": "ask_user_default_used",
|
|
1711
|
+
"answer": answer,
|
|
1712
|
+
"toolId": event.tool_id,
|
|
1713
|
+
})
|
|
1714
|
+
result = await self.agent.resolve_approval(
|
|
1715
|
+
approved=not cancelled, answer=answer,
|
|
1716
|
+
)
|
|
1717
|
+
if result:
|
|
1718
|
+
tid = result.get("tool_use_id", "") or event.tool_id
|
|
1719
|
+
rstr = result.get("content", "") or ""
|
|
1720
|
+
ierr = result.get("is_error", False)
|
|
1721
|
+
self.agent.context.add_message(
|
|
1722
|
+
"tool",
|
|
1723
|
+
[{"type": "tool_result", "tool_use_id": tid,
|
|
1724
|
+
"content": rstr, "is_error": ierr}],
|
|
1725
|
+
name=tid,
|
|
1726
|
+
)
|
|
1727
|
+
# Update TUI tool call status
|
|
1728
|
+
status = "failed" if ierr else "completed"
|
|
1729
|
+
content_block = [{
|
|
1730
|
+
"type": "content",
|
|
1731
|
+
"content": {"type": "text", "text": rstr},
|
|
1732
|
+
}] if rstr else []
|
|
1733
|
+
self.send_session_update({
|
|
1734
|
+
"sessionUpdate": "tool_call_update",
|
|
1735
|
+
"toolCallId": tid,
|
|
1736
|
+
"status": status,
|
|
1737
|
+
"content": content_block,
|
|
1738
|
+
})
|
|
1739
|
+
continue
|
|
1740
|
+
|
|
1741
|
+
# Build tool call content for the permission request
|
|
1742
|
+
tool_kind = _kind_for_category(get_tool_category(event.ask_action))
|
|
1743
|
+
pending = (self.agent._pending_approval or {}).get("tool_call", {})
|
|
1744
|
+
tool_args = pending.get("input", {}) if pending else {}
|
|
1745
|
+
tool_content = _build_tool_call_content(
|
|
1746
|
+
event.ask_action, tool_args
|
|
1747
|
+
) if tool_args else []
|
|
1748
|
+
|
|
1749
|
+
# Prepend the question as a visible content block so the TUI
|
|
1750
|
+
# permission dialog shows *what* the agent wants to do instead
|
|
1751
|
+
# of an empty ``_meta`` that the TUI ignores.
|
|
1752
|
+
question_block = {
|
|
1753
|
+
"type": "content",
|
|
1754
|
+
"content": {"type": "text", "text": f"❓ {event.ask_question}"},
|
|
1755
|
+
}
|
|
1756
|
+
tool_content = [question_block] + tool_content
|
|
1757
|
+
|
|
1758
|
+
# Guard against orphan permission requests: if the engine
|
|
1759
|
+
# didn't supply a tool id, fall back to the pending approval
|
|
1760
|
+
# id; if that's also empty, log and skip the request so the
|
|
1761
|
+
# TUI doesn't render a dialog with no associated tool widget.
|
|
1762
|
+
tool_call_id = event.tool_id or pending.get("id", "")
|
|
1763
|
+
if not tool_call_id:
|
|
1764
|
+
debug_log(
|
|
1765
|
+
"ASK_USER skipped: no tool_id (action=%s question=%s)",
|
|
1766
|
+
event.ask_action, event.ask_question,
|
|
1767
|
+
)
|
|
1768
|
+
continue
|
|
1769
|
+
|
|
1770
|
+
permission_params = {
|
|
1771
|
+
"sessionId": session_id,
|
|
1772
|
+
"options": [
|
|
1773
|
+
{"name": "Allow once", "optionId": "allow_once", "kind": "allow_once"},
|
|
1774
|
+
{"name": "Allow always", "optionId": "allow_always", "kind": "allow_always"},
|
|
1775
|
+
{"name": "Reject once", "optionId": "reject_once", "kind": "reject_once"},
|
|
1776
|
+
{"name": "Reject always", "optionId": "reject_always", "kind": "reject_always"},
|
|
1777
|
+
],
|
|
1778
|
+
"toolCall": {
|
|
1779
|
+
"toolCallId": tool_call_id,
|
|
1780
|
+
"title": event.ask_action,
|
|
1781
|
+
"kind": tool_kind,
|
|
1782
|
+
"content": tool_content,
|
|
1783
|
+
"status": "pending",
|
|
1784
|
+
},
|
|
1785
|
+
}
|
|
1786
|
+
try:
|
|
1787
|
+
perm_future = self.send_request(
|
|
1788
|
+
"session/request_permission", permission_params
|
|
1789
|
+
)
|
|
1790
|
+
perm_response = await perm_future
|
|
1791
|
+
outcome = perm_response.get("outcome", {})
|
|
1792
|
+
option_id = outcome.get("optionId", "reject_once")
|
|
1793
|
+
approved = option_id in ("allow_once", "allow_always")
|
|
1794
|
+
|
|
1795
|
+
if option_id == "allow_always":
|
|
1796
|
+
from onecode.agent.agents.types import AgentPermission
|
|
1797
|
+
perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
|
|
1798
|
+
if perm_key:
|
|
1799
|
+
attr_name = f"permission_{perm_key}"
|
|
1800
|
+
setattr(self.agent.current_agent, attr_name, AgentPermission.ALLOW)
|
|
1801
|
+
self._perm_store.set_override(perm_key, AgentPermission.ALLOW)
|
|
1802
|
+
self._perm_store_save()
|
|
1803
|
+
elif option_id == "reject_always":
|
|
1804
|
+
from onecode.agent.agents.types import AgentPermission
|
|
1805
|
+
perm_key = self.agent._TOOL_NAME_TO_PERM_KEY.get(event.ask_action)
|
|
1806
|
+
if perm_key:
|
|
1807
|
+
attr_name = f"permission_{perm_key}"
|
|
1808
|
+
setattr(self.agent.current_agent, attr_name, AgentPermission.DENY)
|
|
1809
|
+
self._perm_store.set_override(perm_key, AgentPermission.DENY)
|
|
1810
|
+
self._perm_store_save()
|
|
1811
|
+
except Exception:
|
|
1812
|
+
approved = False
|
|
1813
|
+
|
|
1814
|
+
result = await self.agent.resolve_approval(approved)
|
|
1815
|
+
if result:
|
|
1816
|
+
result_str = result.get("content", "") or ""
|
|
1817
|
+
is_error = result.get("is_error", False)
|
|
1818
|
+
tid = result.get("tool_use_id", "") or tool_call_id
|
|
1819
|
+
status = "failed" if is_error else "completed"
|
|
1820
|
+
content_block = [{
|
|
1821
|
+
"type": "content",
|
|
1822
|
+
"content": {"type": "text", "text": result_str},
|
|
1823
|
+
}] if result_str else []
|
|
1824
|
+
self.send_session_update({
|
|
1825
|
+
"sessionUpdate": "tool_call_update",
|
|
1826
|
+
"toolCallId": tid,
|
|
1827
|
+
"status": status,
|
|
1828
|
+
"content": content_block,
|
|
1829
|
+
})
|
|
1830
|
+
# Add result to LLM context
|
|
1831
|
+
self.agent.context.add_message(
|
|
1832
|
+
"tool",
|
|
1833
|
+
[{"type": "tool_result", "tool_use_id": tid,
|
|
1834
|
+
"content": result_str, "is_error": is_error}],
|
|
1835
|
+
name=tid,
|
|
1836
|
+
)
|
|
1837
|
+
verb = "Approved" if approved else "Rejected"
|
|
1838
|
+
self.send_session_update({
|
|
1839
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1840
|
+
"content": {
|
|
1841
|
+
"type": "text",
|
|
1842
|
+
"text": f"⚡ {verb}: {event.ask_action} — {event.ask_question}",
|
|
1843
|
+
},
|
|
1844
|
+
})
|
|
1845
|
+
|
|
1846
|
+
except Exception:
|
|
1847
|
+
import traceback
|
|
1848
|
+
tb = traceback.format_exc()
|
|
1849
|
+
debug_log("Unhandled exception in chat_stream:\n%s", tb, exc_info=True)
|
|
1850
|
+
print(f"[cdh-agent-acp] ERROR in chat_stream:\n{tb}", file=sys.stderr, flush=True)
|
|
1851
|
+
self._text_chunker.flush()
|
|
1852
|
+
self._thought_chunker.flush()
|
|
1853
|
+
self.send_session_update({
|
|
1854
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1855
|
+
"content": {"type": "text", "text": f"Error: internal error"},
|
|
1856
|
+
})
|
|
1857
|
+
try:
|
|
1858
|
+
self.agent.save_session()
|
|
1859
|
+
except Exception:
|
|
1860
|
+
pass
|
|
1861
|
+
try:
|
|
1862
|
+
self.agent.save_tasks_to_project()
|
|
1863
|
+
except Exception:
|
|
1864
|
+
pass
|
|
1865
|
+
return {"stopReason": "error", "message": "Internal agent error"}
|
|
1866
|
+
|
|
1867
|
+
self._text_chunker.flush()
|
|
1868
|
+
self._thought_chunker.flush()
|
|
1869
|
+
|
|
1870
|
+
try:
|
|
1871
|
+
self.agent.save_session()
|
|
1872
|
+
except Exception:
|
|
1873
|
+
debug_log("Failed to save session at turn end", exc_info=True)
|
|
1874
|
+
|
|
1875
|
+
# Persist tasks to project .cdh/ so they survive Ctrl+C
|
|
1876
|
+
try:
|
|
1877
|
+
self.agent.save_tasks_to_project()
|
|
1878
|
+
except Exception:
|
|
1879
|
+
debug_log("Failed to save tasks to .cdh at turn end", exc_info=True)
|
|
1880
|
+
|
|
1881
|
+
# Send context usage stats to TUI sidebar
|
|
1882
|
+
ctx = self.agent.context
|
|
1883
|
+
used = ctx._token_count if ctx else 0
|
|
1884
|
+
size = ctx.config.max_tokens if ctx else 0
|
|
1885
|
+
self.send_session_update({
|
|
1886
|
+
"sessionUpdate": "usage_update",
|
|
1887
|
+
"used": used,
|
|
1888
|
+
"size": size,
|
|
1889
|
+
})
|
|
1890
|
+
|
|
1891
|
+
# Send final action summary if tool calls were made this turn
|
|
1892
|
+
if self.tool_calls:
|
|
1893
|
+
summaries: list[str] = []
|
|
1894
|
+
for tid, tc in self.tool_calls.items():
|
|
1895
|
+
action = tc.get("title", "")
|
|
1896
|
+
status = tc.get("status", "")
|
|
1897
|
+
if action:
|
|
1898
|
+
emoji = "✅" if status == "completed" else "❌" if status == "failed" else "⏳"
|
|
1899
|
+
summaries.append(f"{emoji} {action}")
|
|
1900
|
+
if summaries:
|
|
1901
|
+
summary_text = "\n".join(summaries)
|
|
1902
|
+
self.send_session_update({
|
|
1903
|
+
"sessionUpdate": "agent_message_chunk",
|
|
1904
|
+
"content": {"type": "text", "text": f"```output\n{summary_text}\n```"},
|
|
1905
|
+
})
|
|
1906
|
+
|
|
1907
|
+
stop_reason = "cancelled" if self.agent._cancelled else "end_turn"
|
|
1908
|
+
usage = self._build_session_usage()
|
|
1909
|
+
return {
|
|
1910
|
+
"sessionId": self.session_id,
|
|
1911
|
+
"stopReason": stop_reason,
|
|
1912
|
+
"usage": usage,
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
def _pick_default_ask_user_answer(self, event) -> str:
|
|
1916
|
+
"""Pick the default answer for a timed-out AskUser prompt.
|
|
1917
|
+
|
|
1918
|
+
Heuristic:
|
|
1919
|
+
- If a question has an option with ``default: true``, use it.
|
|
1920
|
+
- Otherwise pick the first option.
|
|
1921
|
+
- Multi-question prompts return a JSON string of ``{idx: value}``.
|
|
1922
|
+
- Single-question prompts return the option value as a plain string.
|
|
1923
|
+
- If the question has no options (free-text), return an empty string
|
|
1924
|
+
so the agent treats it as a no-op.
|
|
1925
|
+
"""
|
|
1926
|
+
questions = getattr(event, "ask_questions", None) or []
|
|
1927
|
+
options = getattr(event, "ask_options", None) or []
|
|
1928
|
+
|
|
1929
|
+
def _pick(opt_list: list) -> str:
|
|
1930
|
+
if not opt_list:
|
|
1931
|
+
return ""
|
|
1932
|
+
for opt in opt_list:
|
|
1933
|
+
if opt.get("default"):
|
|
1934
|
+
return str(opt.get("value", ""))
|
|
1935
|
+
return str(opt_list[0].get("value", ""))
|
|
1936
|
+
|
|
1937
|
+
if questions:
|
|
1938
|
+
answers = {str(i): _pick(q.get("options", [])) for i, q in enumerate(questions)}
|
|
1939
|
+
return json.dumps(answers)
|
|
1940
|
+
if options:
|
|
1941
|
+
return _pick(options)
|
|
1942
|
+
return ""
|
|
1943
|
+
|
|
1944
|
+
def _build_session_usage(self) -> dict:
|
|
1945
|
+
"""Aggregate per-turn usage from the engine into a single Usage dict.
|
|
1946
|
+
|
|
1947
|
+
The TUI's ``tui/acp/protocol.py:SessionPromptResponse.usage`` is a
|
|
1948
|
+
free-form ``Usage`` object. We sum the per-turn input/output/total
|
|
1949
|
+
tokens tracked by the engine; missing fields fall back to 0.
|
|
1950
|
+
"""
|
|
1951
|
+
turn_usages = getattr(self.agent, "_turn_usages", None) or []
|
|
1952
|
+
if not turn_usages:
|
|
1953
|
+
return {
|
|
1954
|
+
"total_tokens": int(getattr(self.agent, "total_tokens", 0) or 0),
|
|
1955
|
+
"input_tokens": 0,
|
|
1956
|
+
"output_tokens": 0,
|
|
1957
|
+
}
|
|
1958
|
+
total_in = sum(int(u.get("input_tokens", 0) or 0) for u in turn_usages)
|
|
1959
|
+
total_out = sum(int(u.get("output_tokens", 0) or 0) for u in turn_usages)
|
|
1960
|
+
total_all = sum(int(u.get("total_tokens", 0) or 0) for u in turn_usages)
|
|
1961
|
+
if total_all == 0:
|
|
1962
|
+
total_all = total_in + total_out
|
|
1963
|
+
return {
|
|
1964
|
+
"total_tokens": total_all,
|
|
1965
|
+
"input_tokens": total_in,
|
|
1966
|
+
"output_tokens": total_out,
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
async def session_cancel(self, session_id: str, _meta: dict):
|
|
1970
|
+
"""Cancel current session."""
|
|
1971
|
+
if self.agent:
|
|
1972
|
+
await self.agent.cancel()
|
|
1973
|
+
try:
|
|
1974
|
+
self.agent.save_session()
|
|
1975
|
+
except Exception:
|
|
1976
|
+
debug_log("Failed to save session on cancel", exc_info=True)
|
|
1977
|
+
# Persist tasks to .cdh so they survive Ctrl+C and re-entry
|
|
1978
|
+
try:
|
|
1979
|
+
self.agent.save_tasks_to_project()
|
|
1980
|
+
except Exception:
|
|
1981
|
+
debug_log("Failed to save tasks to .cdh on cancel", exc_info=True)
|
|
1982
|
+
return {}
|
|
1983
|
+
|
|
1984
|
+
async def session_set_mode(self, session_id: str, mode_id: str):
|
|
1985
|
+
"""Set session mode — propagates to engine and notifies TUI."""
|
|
1986
|
+
if self.agent:
|
|
1987
|
+
self.agent.set_agent(mode_id)
|
|
1988
|
+
self._perm_store.apply_to(self.agent.current_agent)
|
|
1989
|
+
self.send_session_update({
|
|
1990
|
+
"sessionUpdate": "current_mode_update",
|
|
1991
|
+
"currentModeId": mode_id,
|
|
1992
|
+
})
|
|
1993
|
+
self._send_available_commands()
|
|
1994
|
+
return {"modeId": mode_id}
|
|
1995
|
+
|
|
1996
|
+
# ── Terminal RPC stubs ──────────────────────────────────────────
|
|
1997
|
+
async def terminal_create(
|
|
1998
|
+
self, session_id: str, terminal_id: str, command: str, args: list[str] | None = None,
|
|
1999
|
+
cwd: str | None = None, env: dict | None = None, output_byte_limit: int | None = None,
|
|
2000
|
+
) -> dict:
|
|
2001
|
+
return {"terminalId": terminal_id}
|
|
2002
|
+
|
|
2003
|
+
async def terminal_kill(self, session_id: str, terminal_id: str) -> dict:
|
|
2004
|
+
return {}
|
|
2005
|
+
|
|
2006
|
+
async def terminal_output(self, session_id: str, terminal_id: str) -> dict:
|
|
2007
|
+
return {"output": "", "truncated": False, "exitStatus": None}
|
|
2008
|
+
|
|
2009
|
+
async def terminal_release(self, session_id: str, terminal_id: str) -> dict:
|
|
2010
|
+
return {}
|
|
2011
|
+
|
|
2012
|
+
async def terminal_wait_for_exit(self, session_id: str, terminal_id: str) -> dict:
|
|
2013
|
+
return {"exitCode": 0, "signal": None}
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
class JSONRPCServer:
|
|
2017
|
+
def __init__(self, adapter: CDHACPAdapter):
|
|
2018
|
+
self.adapter = adapter
|
|
2019
|
+
self.methods = {
|
|
2020
|
+
"initialize": self._handle_initialize,
|
|
2021
|
+
"session/new": self._handle_session_new,
|
|
2022
|
+
"session/load": self._handle_session_load,
|
|
2023
|
+
"session/prompt": self._handle_session_prompt,
|
|
2024
|
+
"session/cancel": self._handle_session_cancel,
|
|
2025
|
+
"session/set_mode": self._handle_session_set_mode,
|
|
2026
|
+
"session/ask_user_answer": self._handle_ask_user_answer,
|
|
2027
|
+
"terminal/create": self._handle_terminal_create,
|
|
2028
|
+
"terminal/kill": self._handle_terminal_kill,
|
|
2029
|
+
"terminal/output": self._handle_terminal_output,
|
|
2030
|
+
"terminal/release": self._handle_terminal_release,
|
|
2031
|
+
"terminal/wait_for_exit": self._handle_terminal_wait_for_exit,
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
async def handle_request(self, request: dict):
|
|
2035
|
+
"""Handle a JSONRPC request."""
|
|
2036
|
+
method = request.get("method")
|
|
2037
|
+
params = request.get("params", {})
|
|
2038
|
+
req_id = request.get("id")
|
|
2039
|
+
|
|
2040
|
+
handler = self.methods.get(method)
|
|
2041
|
+
if handler is None:
|
|
2042
|
+
return {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Method not found: {method}"}, "id": req_id}
|
|
2043
|
+
|
|
2044
|
+
try:
|
|
2045
|
+
result = await handler(params)
|
|
2046
|
+
return {"jsonrpc": "2.0", "result": result, "id": req_id}
|
|
2047
|
+
except Exception as e:
|
|
2048
|
+
return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_id}
|
|
2049
|
+
|
|
2050
|
+
async def _handle_initialize(self, params: dict):
|
|
2051
|
+
return await self.adapter.initialize(
|
|
2052
|
+
params.get("protocolVersion"),
|
|
2053
|
+
params.get("clientCapabilities", {}),
|
|
2054
|
+
params.get("clientInfo", {}),
|
|
2055
|
+
)
|
|
2056
|
+
|
|
2057
|
+
async def _handle_session_new(self, params: dict):
|
|
2058
|
+
return await self.adapter.session_new(
|
|
2059
|
+
params.get("cwd", "."),
|
|
2060
|
+
params.get("mcpServers", []),
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
async def _handle_session_load(self, params: dict):
|
|
2064
|
+
return await self.adapter.session_load(
|
|
2065
|
+
params.get("cwd", "."),
|
|
2066
|
+
params.get("mcpServers", []),
|
|
2067
|
+
params.get("sessionId"),
|
|
2068
|
+
)
|
|
2069
|
+
|
|
2070
|
+
async def _handle_session_prompt(self, params: dict):
|
|
2071
|
+
return await self.adapter.session_prompt(
|
|
2072
|
+
params.get("prompt", []),
|
|
2073
|
+
params.get("sessionId"),
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2076
|
+
async def _handle_session_cancel(self, params: dict):
|
|
2077
|
+
return await self.adapter.session_cancel(
|
|
2078
|
+
params.get("sessionId"),
|
|
2079
|
+
params.get("_meta", {}),
|
|
2080
|
+
)
|
|
2081
|
+
|
|
2082
|
+
async def _handle_ask_user_answer(self, params: dict):
|
|
2083
|
+
return self.adapter.resolve_ask_user(
|
|
2084
|
+
params.get("answer", ""),
|
|
2085
|
+
params.get("cancelled", True),
|
|
2086
|
+
)
|
|
2087
|
+
|
|
2088
|
+
async def _handle_session_set_mode(self, params: dict):
|
|
2089
|
+
return await self.adapter.session_set_mode(
|
|
2090
|
+
params.get("sessionId"),
|
|
2091
|
+
params.get("modeId"),
|
|
2092
|
+
)
|
|
2093
|
+
|
|
2094
|
+
async def _handle_terminal_create(self, params: dict):
|
|
2095
|
+
return await self.adapter.terminal_create(
|
|
2096
|
+
params.get("sessionId"),
|
|
2097
|
+
params.get("terminalId"),
|
|
2098
|
+
params.get("command"),
|
|
2099
|
+
params.get("args"),
|
|
2100
|
+
params.get("cwd"),
|
|
2101
|
+
params.get("env"),
|
|
2102
|
+
params.get("outputByteLimit"),
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
async def _handle_terminal_kill(self, params: dict):
|
|
2106
|
+
return await self.adapter.terminal_kill(
|
|
2107
|
+
params.get("sessionId"),
|
|
2108
|
+
params.get("terminalId"),
|
|
2109
|
+
)
|
|
2110
|
+
|
|
2111
|
+
async def _handle_terminal_output(self, params: dict):
|
|
2112
|
+
return await self.adapter.terminal_output(
|
|
2113
|
+
params.get("sessionId"),
|
|
2114
|
+
params.get("terminalId"),
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
async def _handle_terminal_release(self, params: dict):
|
|
2118
|
+
return await self.adapter.terminal_release(
|
|
2119
|
+
params.get("sessionId"),
|
|
2120
|
+
params.get("terminalId"),
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
async def _handle_terminal_wait_for_exit(self, params: dict):
|
|
2124
|
+
return await self.adapter.terminal_wait_for_exit(
|
|
2125
|
+
params.get("sessionId"),
|
|
2126
|
+
params.get("terminalId"),
|
|
2127
|
+
)
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
async def _main():
|
|
2131
|
+
adapter = CDHACPAdapter()
|
|
2132
|
+
server = JSONRPCServer(adapter)
|
|
2133
|
+
prompt_task: asyncio.Task | None = None
|
|
2134
|
+
|
|
2135
|
+
async def _run_prompt(req: dict):
|
|
2136
|
+
try:
|
|
2137
|
+
result = await server._handle_session_prompt(req.get("params", {}))
|
|
2138
|
+
return {"jsonrpc": "2.0", "result": result, "id": req.get("id")}
|
|
2139
|
+
except Exception as e:
|
|
2140
|
+
import traceback
|
|
2141
|
+
tb = traceback.format_exc()
|
|
2142
|
+
print(f"[cdh-agent-acp] ERROR in _run_prompt:\n{tb}", file=sys.stderr, flush=True)
|
|
2143
|
+
debug_log("_run_prompt failed: %s\n%s", e, tb, exc_info=True)
|
|
2144
|
+
return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req.get("id")}
|
|
2145
|
+
|
|
2146
|
+
while True:
|
|
2147
|
+
line = await asyncio.to_thread(sys.stdin.readline)
|
|
2148
|
+
if not line:
|
|
2149
|
+
break
|
|
2150
|
+
|
|
2151
|
+
line = line.strip()
|
|
2152
|
+
if not line:
|
|
2153
|
+
continue
|
|
2154
|
+
|
|
2155
|
+
try:
|
|
2156
|
+
request = json.loads(line)
|
|
2157
|
+
except json.JSONDecodeError:
|
|
2158
|
+
continue
|
|
2159
|
+
|
|
2160
|
+
requests = request if isinstance(request, list) else [request]
|
|
2161
|
+
|
|
2162
|
+
for req in requests:
|
|
2163
|
+
if not isinstance(req, dict):
|
|
2164
|
+
continue
|
|
2165
|
+
|
|
2166
|
+
# Check if this is a response to a pending server→client request
|
|
2167
|
+
if "id" in req and ("result" in req or "error" in req):
|
|
2168
|
+
req_id = req.get("id")
|
|
2169
|
+
if isinstance(req_id, str) and req_id in adapter._pending_requests:
|
|
2170
|
+
adapter.resolve_pending_request(req_id, req.get("result", {}))
|
|
2171
|
+
continue
|
|
2172
|
+
|
|
2173
|
+
method = req.get("method", "")
|
|
2174
|
+
req_id = req.get("id")
|
|
2175
|
+
|
|
2176
|
+
if method == "session/prompt":
|
|
2177
|
+
# Run prompt in background so main loop stays responsive
|
|
2178
|
+
# to cancel notifications on stdin
|
|
2179
|
+
prompt_task = asyncio.create_task(_run_prompt(req))
|
|
2180
|
+
def _on_prompt_done(t, req=req):
|
|
2181
|
+
try:
|
|
2182
|
+
resp = t.result()
|
|
2183
|
+
except Exception as exc:
|
|
2184
|
+
debug_log(f"Prompt task raised unhandled exception: {exc}", exc_info=True)
|
|
2185
|
+
resp = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": req.get("id")}
|
|
2186
|
+
if resp.get("id") is not None:
|
|
2187
|
+
print(json.dumps(resp), flush=True)
|
|
2188
|
+
prompt_task.add_done_callback(_on_prompt_done)
|
|
2189
|
+
elif method == "session/cancel":
|
|
2190
|
+
if prompt_task and not prompt_task.done():
|
|
2191
|
+
adapter.cancel_prompt()
|
|
2192
|
+
# Notification (no id) — nothing to respond with
|
|
2193
|
+
else:
|
|
2194
|
+
response = await server.handle_request(req)
|
|
2195
|
+
if response.get("id") is not None:
|
|
2196
|
+
print(json.dumps(response), flush=True)
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
def main():
|
|
2200
|
+
asyncio.run(_main())
|
|
2201
|
+
|
|
2202
|
+
if __name__ == "__main__":
|
|
2203
|
+
main()
|