comate-cli 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (94) hide show
  1. {comate_cli-0.2.0 → comate_cli-0.2.2}/.gitignore +5 -1
  2. {comate_cli-0.2.0 → comate_cli-0.2.2}/PKG-INFO +1 -1
  3. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/mcp_cli.py +22 -15
  4. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/app.py +10 -6
  5. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/logging_adapter.py +94 -15
  6. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rpc_protocol.py +12 -5
  7. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rpc_stdio.py +104 -9
  8. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui.py +9 -0
  9. {comate_cli-0.2.0 → comate_cli-0.2.2}/pyproject.toml +1 -1
  10. comate_cli-0.2.2/tests/test_app_mcp_preload.py +48 -0
  11. comate_cli-0.2.2/tests/test_logging_adapter.py +68 -0
  12. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mcp_cli.py +40 -0
  13. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rpc_protocol.py +7 -0
  14. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rpc_stdio_bridge.py +43 -0
  15. {comate_cli-0.2.0 → comate_cli-0.2.2}/uv.lock +74 -91
  16. {comate_cli-0.2.0 → comate_cli-0.2.2}/README.md +0 -0
  17. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/__init__.py +0 -0
  18. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/__main__.py +0 -0
  19. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/main.py +0 -0
  20. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/__init__.py +0 -0
  21. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/animations.py +0 -0
  22. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/assistant_render.py +0 -0
  23. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  24. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/env_utils.py +0 -0
  25. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/error_display.py +0 -0
  26. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/event_renderer.py +0 -0
  27. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  28. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/history_printer.py +0 -0
  29. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/input_geometry.py +0 -0
  30. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  31. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/logo.py +0 -0
  32. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/markdown_render.py +0 -0
  33. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/mention_completer.py +0 -0
  34. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/message_style.py +0 -0
  35. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/models.py +0 -0
  36. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/preflight.py +0 -0
  37. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/question_view.py +0 -0
  38. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/resume_selector.py +0 -0
  39. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/rewind_store.py +0 -0
  40. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/selection_menu.py +0 -0
  41. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/session_view.py +0 -0
  42. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/slash_commands.py +0 -0
  43. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/startup.py +0 -0
  44. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/status_bar.py +0 -0
  45. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/text_effects.py +0 -0
  46. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tips.py +0 -0
  47. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tool_view.py +0 -0
  48. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  49. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  50. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  51. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  52. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  53. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/render_panels.py +0 -0
  54. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  55. {comate_cli-0.2.0 → comate_cli-0.2.2}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  56. {comate_cli-0.2.0 → comate_cli-0.2.2}/test_memory.md +0 -0
  57. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/conftest.py +0 -0
  58. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_preflight_gate.py +0 -0
  59. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_shutdown.py +0 -0
  60. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_app_usage_line.py +0 -0
  61. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_cli_project_root.py +0 -0
  62. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_compact_command_semantics.py +0 -0
  63. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_completion_context_activation.py +0 -0
  64. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_completion_status_panel.py +0 -0
  65. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_context_command.py +0 -0
  66. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_custom_slash_commands.py +0 -0
  67. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_event_renderer.py +0 -0
  68. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_history_sync.py +0 -0
  69. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_input_behavior.py +0 -0
  70. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_interrupt_exit_semantics.py +0 -0
  71. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_layout_coordinator.py +0 -0
  72. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_logo.py +0 -0
  73. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_main_args.py +0 -0
  74. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mcp_slash_command.py +0 -0
  75. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_mention_completer.py +0 -0
  76. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_preflight.py +0 -0
  77. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_preflight_copilot.py +0 -0
  78. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_question_key_bindings.py +0 -0
  79. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_question_view.py +0 -0
  80. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_resume_selector.py +0 -0
  81. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rewind_command_semantics.py +0 -0
  82. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_rewind_store.py +0 -0
  83. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_selection_menu.py +0 -0
  84. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_skills_slash_command.py +0 -0
  85. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_argument_hint.py +0 -0
  86. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_completer.py +0 -0
  87. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_slash_registry.py +0 -0
  88. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_status_bar.py +0 -0
  89. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_task_panel_key_bindings.py +0 -0
  90. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tool_view.py +0 -0
  91. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_elapsed_status.py +0 -0
  92. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_mcp_init_gate.py +0 -0
  93. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_paste_placeholder.py +0 -0
  94. {comate_cli-0.2.0 → comate_cli-0.2.2}/tests/test_tui_split_invariance.py +0 -0
@@ -8,6 +8,7 @@ set_env.sh
8
8
  # C extensions
9
9
  *.so
10
10
 
11
+ .claude/
11
12
  # Distribution / packaging
12
13
  .Python
13
14
  build/
@@ -218,4 +219,7 @@ __marimo__/
218
219
  .streamlit/secrets.toml
219
220
 
220
221
  .env
221
- ./tmp/
222
+ ./tmp/
223
+
224
+ # OS generated files
225
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comate-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Comate terminal CLI built on comate-agent-sdk
5
5
  Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
6
  Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
@@ -23,8 +23,13 @@ class McpCliError(ValueError):
23
23
  self.exit_code = int(exit_code)
24
24
 
25
25
 
26
+ class _McpArgumentParser(argparse.ArgumentParser):
27
+ def error(self, message: str) -> None:
28
+ raise McpCliError(message)
29
+
30
+
26
31
  def _build_parser() -> argparse.ArgumentParser:
27
- parser = argparse.ArgumentParser(
32
+ parser = _McpArgumentParser(
28
33
  prog="comate mcp",
29
34
  description="Configure and manage MCP servers",
30
35
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -143,11 +148,11 @@ def _parse_env_entries(entries: list[str]) -> dict[str, str]:
143
148
  return env
144
149
 
145
150
 
146
- def _clean_remainder_args(raw_args: list[str]) -> list[str]:
147
- args = list(raw_args)
148
- if args and args[0] == "--":
149
- return args[1:]
150
- return args
151
+ def _split_trailing_command_args(argv: list[str]) -> tuple[list[str], list[str]]:
152
+ if "--" not in argv:
153
+ return list(argv), []
154
+ separator_index = argv.index("--")
155
+ return list(argv[:separator_index]), list(argv[separator_index + 1 :])
151
156
 
152
157
 
153
158
  def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
@@ -155,9 +160,7 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
155
160
  header_entries = [str(item) for item in list(args.header or [])]
156
161
  env_entries = [str(item) for item in list(args.env or [])]
157
162
  command_or_url = str(args.command_or_url or "").strip()
158
- remainder = _clean_remainder_args(
159
- [str(item) for item in list(getattr(args, "extra_args", []) or [])]
160
- )
163
+ remainder = [str(item) for item in list(getattr(args, "extra_args", []) or [])]
161
164
 
162
165
  if transport == "http":
163
166
  if env_entries:
@@ -177,6 +180,9 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
177
180
 
178
181
  if header_entries:
179
182
  raise McpCliError("--header is only supported for http transport")
183
+ stdio_args = list(remainder)
184
+ if not command_or_url and stdio_args:
185
+ command_or_url = stdio_args.pop(0)
180
186
  if not command_or_url:
181
187
  raise McpCliError("stdio transport requires command after <name>")
182
188
 
@@ -184,8 +190,8 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
184
190
  cfg = {
185
191
  "command": command_or_url,
186
192
  }
187
- if remainder:
188
- cfg["args"] = remainder
193
+ if stdio_args:
194
+ cfg["args"] = stdio_args
189
195
  if env:
190
196
  cfg["env"] = env
191
197
  return cfg # type: ignore[return-value]
@@ -365,16 +371,17 @@ def _cmd_get(args: argparse.Namespace, *, project_root: PathInput | None) -> Non
365
371
 
366
372
  def run_mcp_command(argv: list[str], *, project_root: PathInput | None = None) -> None:
367
373
  parser = _build_parser()
368
- parsed, extra_args = parser.parse_known_args(argv)
374
+ parser_argv, trailing_args = _split_trailing_command_args(argv)
375
+ parsed = parser.parse_args(parser_argv)
369
376
  command = str(getattr(parsed, "command", "") or "").strip()
370
377
  if not command:
371
378
  parser.print_help(sys.stdout)
372
379
  return
373
- if command != "add" and extra_args:
374
- joined = " ".join(extra_args)
380
+ if command != "add" and trailing_args:
381
+ joined = " ".join(["--", *trailing_args])
375
382
  raise McpCliError(f"Unrecognized arguments: {joined}")
376
383
  if command == "add":
377
- setattr(parsed, "extra_args", list(extra_args))
384
+ setattr(parsed, "extra_args", list(trailing_args))
378
385
 
379
386
  handlers = {
380
387
  "add": _cmd_add,
@@ -15,8 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from rich.console import Console
17
17
 
18
- from comate_agent_sdk import Agent
19
- from comate_agent_sdk.agent import AgentConfig, ChatSession
18
+ from comate_agent_sdk.agent import Agent, AgentConfig, ChatSession
20
19
  from comate_agent_sdk.context import EnvOptions
21
20
  from comate_agent_sdk.tools import tool
22
21
 
@@ -230,16 +229,20 @@ def _format_resume_hint(session_id: str | None) -> str | None:
230
229
 
231
230
  async def _preload_mcp_in_tui(session: ChatSession) -> None:
232
231
  """在 TUI 内异步加载 MCP,初始化阶段不输出 scrollback 文案。"""
233
- if not bool(session._agent.options.mcp_enabled):
232
+ runtime = session.runtime
233
+ if not bool(runtime.options.mcp_enabled):
234
234
  return
235
235
 
236
236
  try:
237
- await session._agent.ensure_mcp_tools_loaded()
237
+ preload_task = runtime.start_mcp_preload()
238
+ if preload_task is None:
239
+ return
240
+ await preload_task
238
241
  except Exception as e:
239
242
  logger.warning(f"MCP init failed: {e}", exc_info=True)
240
243
  return
241
244
 
242
- mgr = session._agent._mcp_manager
245
+ mgr = runtime._mcp_manager
243
246
  if mgr is None:
244
247
  return
245
248
 
@@ -311,7 +314,7 @@ async def run(
311
314
 
312
315
  # 配置 TUI logging handler(将 SDK 日志输出到 TUI)
313
316
  from comate_cli.terminal_agent.logging_adapter import setup_tui_logging
314
- setup_tui_logging(renderer)
317
+ logging_session = setup_tui_logging(renderer)
315
318
 
316
319
  tui = TerminalAgentTUI(session, status_bar, renderer)
317
320
  tui.add_resume_history(mode)
@@ -333,6 +336,7 @@ async def run(
333
336
  exc_info=True,
334
337
  )
335
338
  finally:
339
+ logging_session.close()
336
340
  if active_session is session:
337
341
  await _graceful_shutdown(active_session)
338
342
  else:
@@ -2,6 +2,8 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import logging
5
+ import os
6
+ import sys
5
7
  from typing import TYPE_CHECKING
6
8
 
7
9
  from prompt_toolkit.application import run_in_terminal
@@ -106,42 +108,119 @@ class TUILoggingHandler(logging.Handler):
106
108
  # DummyApplication,直接调用
107
109
  _append()
108
110
  else:
109
- # 真实 app,使用 run_in_terminal
110
- run_in_terminal(_append, in_executor=False)
111
+ app.create_background_task(
112
+ run_in_terminal(_append, in_executor=False)
113
+ )
111
114
  except Exception:
112
115
  # 没有 app 或导入失败,直接调用
113
116
  _append()
114
117
 
115
118
 
116
- def setup_tui_logging(renderer: EventRenderer) -> None:
117
- """统一日志初始化:文件 + TUI 双通道。
118
-
119
- - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log(RotatingFileHandler)
120
- - WARNING/ERROR 以用户友好格式显示在 TUI scrollback
121
- - 清除 root logger 默认的 stderr handler,阻止 traceback 泄漏到终端
122
- """
123
- import os
119
+ class _DropTerminalStreamFilter(logging.Filter):
120
+ def filter(self, record: logging.LogRecord) -> bool:
121
+ del record
122
+ return False
123
+
124
+
125
+ class TUILoggingSession:
126
+ def __init__(
127
+ self,
128
+ *,
129
+ root_logger: logging.Logger,
130
+ added_handlers: list[logging.Handler],
131
+ muted_handlers: list[tuple[logging.Handler, logging.Filter]],
132
+ previous_level: int,
133
+ ) -> None:
134
+ self._root_logger = root_logger
135
+ self._added_handlers = list(added_handlers)
136
+ self._muted_handlers = list(muted_handlers)
137
+ self._previous_level = previous_level
138
+ self._closed = False
139
+
140
+ def close(self) -> None:
141
+ if self._closed:
142
+ return
143
+ self._closed = True
144
+
145
+ for handler, log_filter in self._muted_handlers:
146
+ try:
147
+ handler.removeFilter(log_filter)
148
+ except Exception:
149
+ continue
150
+
151
+ for handler in self._added_handlers:
152
+ try:
153
+ self._root_logger.removeHandler(handler)
154
+ except Exception:
155
+ continue
156
+ try:
157
+ handler.close()
158
+ except Exception:
159
+ continue
160
+
161
+ self._root_logger.setLevel(self._previous_level)
162
+
163
+
164
+ def _build_file_handler() -> logging.Handler:
124
165
  from logging.handlers import RotatingFileHandler
125
166
 
126
- # 1. 日志文件 handler(完整调试信息,含 traceback)
127
167
  log_dir = os.path.join(os.path.expanduser("~"), ".comate", "logs")
128
168
  os.makedirs(log_dir, exist_ok=True)
129
169
  log_path = os.path.join(log_dir, "agent.log")
130
170
  file_handler = RotatingFileHandler(
131
- log_path, maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8",
171
+ log_path,
172
+ maxBytes=10 * 1024 * 1024,
173
+ backupCount=3,
174
+ encoding="utf-8",
132
175
  )
133
176
  file_handler.setLevel(logging.DEBUG)
134
177
  file_handler.setFormatter(
135
178
  logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
136
179
  )
180
+ return file_handler
181
+
182
+
183
+ def _should_mute_terminal_stream(handler: logging.Handler) -> bool:
184
+ if not isinstance(handler, logging.StreamHandler):
185
+ return False
186
+ stream = getattr(handler, "stream", None)
187
+ return stream in {sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__}
188
+
137
189
 
138
- # 2. 清除 root logger 的所有 handler,阻止 stderr 泄漏
190
+ def setup_tui_logging(renderer: EventRenderer) -> TUILoggingSession:
191
+ """统一日志初始化:文件 + TUI 双通道。
192
+
193
+ - 所有日志(含 traceback)写入 ~/.comate/logs/agent.log(RotatingFileHandler)
194
+ - WARNING/ERROR 以用户友好格式显示在 TUI scrollback
195
+ - 临时静默 root logger 上写往 stdout/stderr 的 stream handler,避免污染 TUI
196
+ """
139
197
  root = logging.getLogger()
140
- root.handlers.clear()
198
+ previous_level = root.level
199
+
200
+ # 1. 日志文件 handler(完整调试信息,含 traceback)
201
+ file_handler = _build_file_handler()
141
202
  root.addHandler(file_handler)
142
203
  root.setLevel(logging.DEBUG)
143
204
 
144
- # 3. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
205
+ # 2. TUI handler 挂到 root(覆盖所有命名空间的 WARNING/ERROR)
145
206
  tui_handler = TUILoggingHandler(renderer)
146
207
  tui_handler.setLevel(logging.WARNING)
147
208
  root.addHandler(tui_handler)
209
+
210
+ # 3. 临时静默直写终端的 handler,避免污染 prompt_toolkit UI
211
+ muted_handlers: list[tuple[logging.Handler, logging.Filter]] = []
212
+ for handler in list(root.handlers):
213
+ if handler in {file_handler, tui_handler}:
214
+ continue
215
+ if not _should_mute_terminal_stream(handler):
216
+ continue
217
+ log_filter = _DropTerminalStreamFilter()
218
+ handler.addFilter(log_filter)
219
+ muted_handlers.append((handler, log_filter))
220
+
221
+ return TUILoggingSession(
222
+ root_logger=root,
223
+ added_handlers=[file_handler, tui_handler],
224
+ muted_handlers=muted_handlers,
225
+ previous_level=previous_level,
226
+ )
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from dataclasses import asdict, is_dataclass
5
- from typing import Any
5
+ from typing import Any, TypeGuard
6
6
 
7
7
 
8
8
  class ErrorCodes:
@@ -30,6 +30,13 @@ class JSONRPCProtocolError(Exception):
30
30
  self.data = data
31
31
 
32
32
 
33
+ JSONRPCRequestId = str | int
34
+
35
+
36
+ def is_jsonrpc_request_id(raw_value: Any) -> TypeGuard[JSONRPCRequestId]:
37
+ return isinstance(raw_value, (str, int)) and not isinstance(raw_value, bool)
38
+
39
+
33
40
  def parse_jsonrpc_message(raw_line: str) -> dict[str, Any]:
34
41
  try:
35
42
  parsed = json.loads(raw_line)
@@ -52,13 +59,13 @@ def parse_jsonrpc_message(raw_line: str) -> dict[str, Any]:
52
59
  return parsed
53
60
 
54
61
 
55
- def build_success_response(request_id: str | int, result: Any) -> dict[str, Any]:
62
+ def build_success_response(request_id: JSONRPCRequestId, result: Any) -> dict[str, Any]:
56
63
  return {"jsonrpc": "2.0", "id": request_id, "result": _to_jsonable(result)}
57
64
 
58
65
 
59
66
  def build_error_response(
60
67
  *,
61
- request_id: str | int | None,
68
+ request_id: JSONRPCRequestId | None,
62
69
  code: int,
63
70
  message: str,
64
71
  data: Any = None,
@@ -82,8 +89,8 @@ def build_event_notification(event: Any) -> dict[str, Any]:
82
89
  return {"jsonrpc": "2.0", "method": "event", "params": params}
83
90
 
84
91
 
85
- def _coerce_request_id(raw_value: Any) -> str | int | None:
86
- if isinstance(raw_value, str | int):
92
+ def _coerce_request_id(raw_value: Any) -> JSONRPCRequestId | None:
93
+ if is_jsonrpc_request_id(raw_value):
87
94
  return raw_value
88
95
  return None
89
96
 
@@ -14,6 +14,7 @@ from comate_cli.terminal_agent.rpc_protocol import (
14
14
  build_error_response,
15
15
  build_event_notification,
16
16
  build_success_response,
17
+ is_jsonrpc_request_id,
17
18
  parse_jsonrpc_message,
18
19
  )
19
20
 
@@ -33,8 +34,13 @@ class StdioRPCBridge:
33
34
  self._init_error: Exception | None = None
34
35
  self._active_prompt_request_id: str | int | None = None
35
36
  self._active_prompt_result: asyncio.Future[dict[str, Any]] | None = None
37
+ self._finalize_prompt_task: asyncio.Task[None] | None = None
38
+ self._stdin_queue: asyncio.Queue[str | None] | None = None
39
+ self._stdin_reader_fd: int | None = None
40
+ self._stdin_error: Exception | None = None
36
41
 
37
42
  async def run(self) -> None:
43
+ self._install_stdin_reader()
38
44
  self._event_pump_task = asyncio.create_task(
39
45
  self._run_event_pump(),
40
46
  name="rpc-event-pump",
@@ -42,10 +48,29 @@ class StdioRPCBridge:
42
48
  try:
43
49
  while not self._closing:
44
50
  pump_task = self._event_pump_task
45
- if pump_task is not None and pump_task.done():
51
+ input_task = asyncio.create_task(
52
+ self._read_next_input_line(),
53
+ name="rpc-stdin-read",
54
+ )
55
+ wait_tasks: set[asyncio.Task[Any]] = {input_task}
56
+ if pump_task is not None:
57
+ wait_tasks.add(pump_task)
58
+
59
+ done, _pending = await asyncio.wait(
60
+ wait_tasks,
61
+ return_when=asyncio.FIRST_COMPLETED,
62
+ )
63
+
64
+ if pump_task is not None and pump_task in done:
65
+ input_task.cancel()
66
+ try:
67
+ await input_task
68
+ except asyncio.CancelledError:
69
+ pass
46
70
  await pump_task
71
+ break
47
72
 
48
- raw_line = await asyncio.to_thread(sys.stdin.readline)
73
+ raw_line = input_task.result()
49
74
  if raw_line == "":
50
75
  break
51
76
 
@@ -56,12 +81,14 @@ class StdioRPCBridge:
56
81
  await self._handle_incoming_line(line)
57
82
  finally:
58
83
  self._closing = True
84
+ self._remove_stdin_reader()
59
85
  await self._cancel_active_prompt()
60
86
  active_future = self._active_prompt_result
61
87
  if active_future is not None and not active_future.done():
62
88
  active_future.set_result(
63
89
  {"status": "cancelled", "stop_reason": "cancelled"}
64
90
  )
91
+ await self._await_finalize_prompt_task()
65
92
  pump_task = self._event_pump_task
66
93
  if pump_task is not None:
67
94
  pump_task.cancel()
@@ -92,7 +119,7 @@ class StdioRPCBridge:
92
119
  if method is None:
93
120
  await self._send(
94
121
  build_error_response(
95
- request_id=request_id if isinstance(request_id, str | int) else None,
122
+ request_id=request_id if is_jsonrpc_request_id(request_id) else None,
96
123
  code=ErrorCodes.INVALID_REQUEST,
97
124
  message="response payload is not supported on this endpoint",
98
125
  )
@@ -114,14 +141,14 @@ class StdioRPCBridge:
114
141
 
115
142
  await self._send(
116
143
  build_error_response(
117
- request_id=request_id if isinstance(request_id, str | int) else None,
144
+ request_id=request_id if is_jsonrpc_request_id(request_id) else None,
118
145
  code=ErrorCodes.METHOD_NOT_FOUND,
119
146
  message=f"method not found: {method}",
120
147
  )
121
148
  )
122
149
 
123
150
  async def _handle_initialize(self, request_id: Any) -> None:
124
- if not isinstance(request_id, str | int):
151
+ if not is_jsonrpc_request_id(request_id):
125
152
  await self._send(
126
153
  build_error_response(
127
154
  request_id=None,
@@ -155,7 +182,7 @@ class StdioRPCBridge:
155
182
  )
156
183
 
157
184
  async def _handle_prompt(self, request_id: Any, params: Any) -> None:
158
- if not isinstance(request_id, str | int):
185
+ if not is_jsonrpc_request_id(request_id):
159
186
  await self._send(
160
187
  build_error_response(
161
188
  request_id=None,
@@ -213,13 +240,13 @@ class StdioRPCBridge:
213
240
  )
214
241
  return
215
242
 
216
- asyncio.create_task(
243
+ self._finalize_prompt_task = asyncio.create_task(
217
244
  self._finalize_prompt_result(result_future=result_future, request_id=request_id),
218
245
  name=f"rpc-prompt-finalize-{request_id}",
219
246
  )
220
247
 
221
248
  async def _handle_cancel(self, request_id: Any) -> None:
222
- if not isinstance(request_id, str | int):
249
+ if not is_jsonrpc_request_id(request_id):
223
250
  await self._send(
224
251
  build_error_response(
225
252
  request_id=None,
@@ -233,7 +260,7 @@ class StdioRPCBridge:
233
260
  await self._send(build_success_response(request_id, {"cancelled": cancelled}))
234
261
 
235
262
  async def _handle_replay(self, request_id: Any) -> None:
236
- if not isinstance(request_id, str | int):
263
+ if not is_jsonrpc_request_id(request_id):
237
264
  await self._send(
238
265
  build_error_response(
239
266
  request_id=None,
@@ -313,6 +340,8 @@ class StdioRPCBridge:
313
340
  )
314
341
  finally:
315
342
  self._clear_active_prompt(result_future)
343
+ if self._finalize_prompt_task is asyncio.current_task():
344
+ self._finalize_prompt_task = None
316
345
 
317
346
  async def _cancel_active_prompt(self) -> bool:
318
347
  if not self._has_active_prompt():
@@ -347,6 +376,72 @@ class StdioRPCBridge:
347
376
  self._active_prompt_result = None
348
377
  self._active_prompt_request_id = None
349
378
 
379
+ def _install_stdin_reader(self) -> None:
380
+ if self._stdin_queue is not None:
381
+ return
382
+
383
+ loop = asyncio.get_running_loop()
384
+ self._stdin_queue = asyncio.Queue()
385
+ try:
386
+ stdin_fd = sys.stdin.fileno()
387
+ loop.add_reader(stdin_fd, self._on_stdin_ready)
388
+ except (AttributeError, NotImplementedError, OSError, ValueError) as exc:
389
+ self._stdin_queue = None
390
+ raise RuntimeError(f"failed to install stdin reader: {exc}") from exc
391
+
392
+ self._stdin_reader_fd = stdin_fd
393
+ self._stdin_error = None
394
+
395
+ def _remove_stdin_reader(self) -> None:
396
+ stdin_fd = self._stdin_reader_fd
397
+ if stdin_fd is None:
398
+ return
399
+
400
+ try:
401
+ asyncio.get_running_loop().remove_reader(stdin_fd)
402
+ except RuntimeError:
403
+ pass
404
+ self._stdin_reader_fd = None
405
+ self._stdin_queue = None
406
+
407
+ def _on_stdin_ready(self) -> None:
408
+ queue = self._stdin_queue
409
+ if queue is None:
410
+ return
411
+
412
+ try:
413
+ raw_line = sys.stdin.readline()
414
+ except Exception as exc:
415
+ logger.exception("rpc stdin read failed")
416
+ self._stdin_error = RuntimeError(f"stdin read failed: {exc}")
417
+ self._remove_stdin_reader()
418
+ queue.put_nowait(None)
419
+ return
420
+
421
+ if raw_line == "":
422
+ self._remove_stdin_reader()
423
+ queue.put_nowait(None)
424
+ return
425
+ queue.put_nowait(raw_line)
426
+
427
+ async def _read_next_input_line(self) -> str:
428
+ queue = self._stdin_queue
429
+ if queue is None:
430
+ raise RuntimeError("stdin reader is not installed")
431
+
432
+ raw_line = await queue.get()
433
+ if raw_line is None:
434
+ if self._stdin_error is not None:
435
+ raise self._stdin_error
436
+ return ""
437
+ return raw_line
438
+
439
+ async def _await_finalize_prompt_task(self) -> None:
440
+ finalize_task = self._finalize_prompt_task
441
+ if finalize_task is None:
442
+ return
443
+ await finalize_task
444
+
350
445
  async def _send(self, payload: dict[str, Any]) -> None:
351
446
  encoded = f"{self._dump_json(payload)}\n"
352
447
  async with self._write_lock:
@@ -741,6 +741,15 @@ class TerminalAgentTUI(
741
741
  }
742
742
  if isinstance(event, StopEvent):
743
743
  stop_reason = str(event.reason or "")
744
+ if event.reason == "shutdown_request":
745
+ shutdown_recipients = event.metadata.get("shutdown_recipients", [])
746
+ if shutdown_recipients:
747
+ from comate_agent_sdk.agent.runner_engine.query_stream import (
748
+ _send_team_shutdown_response,
749
+ )
750
+ _send_team_shutdown_response(
751
+ self._session.runtime, recipients=shutdown_recipients
752
+ )
744
753
  is_waiting, new_questions = self._renderer.handle_event(event)
745
754
  self._maybe_flash_tool_result(event)
746
755
  if is_waiting:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import types
5
+ import unittest
6
+
7
+ from comate_cli.terminal_agent.app import _preload_mcp_in_tui
8
+
9
+
10
+ class _FakeRuntime:
11
+ def __init__(self) -> None:
12
+ self.options = types.SimpleNamespace(mcp_enabled=True)
13
+ self._mcp_manager = types.SimpleNamespace(
14
+ failed_servers=[],
15
+ tool_infos=[types.SimpleNamespace(server_alias="ctx7")],
16
+ )
17
+ self.start_calls = 0
18
+ self.awaited = asyncio.Event()
19
+
20
+ def start_mcp_preload(self) -> asyncio.Task[None]:
21
+ self.start_calls += 1
22
+
23
+ async def _worker() -> None:
24
+ await asyncio.sleep(0)
25
+ self.awaited.set()
26
+
27
+ return asyncio.create_task(_worker())
28
+
29
+
30
+ class _FakeSession:
31
+ def __init__(self, runtime: _FakeRuntime) -> None:
32
+ self.runtime = runtime
33
+ self._agent = None
34
+
35
+
36
+ class TestAppMcpPreload(unittest.IsolatedAsyncioTestCase):
37
+ async def test_preload_mcp_in_tui_uses_runtime_public_entry(self) -> None:
38
+ runtime = _FakeRuntime()
39
+ session = _FakeSession(runtime)
40
+
41
+ await _preload_mcp_in_tui(session) # type: ignore[arg-type]
42
+
43
+ self.assertEqual(runtime.start_calls, 1)
44
+ self.assertTrue(runtime.awaited.is_set())
45
+
46
+
47
+ if __name__ == "__main__":
48
+ unittest.main(verbosity=2)