devcopilot 0.2.0__py3-none-any.whl

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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,186 @@
1
+ """Build Codex model catalogs from the FCC model-list route."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from api.gateway_model_ids import (
13
+ GATEWAY_MODEL_ID_PREFIX,
14
+ NO_THINKING_GATEWAY_MODEL_ID_PREFIX,
15
+ )
16
+ from config.provider_ids import SUPPORTED_PROVIDER_IDS
17
+
18
+ SUPPORTED_REASONING_LEVELS = [
19
+ {"effort": "low", "description": "Fast responses with lighter reasoning"},
20
+ {
21
+ "effort": "medium",
22
+ "description": "Balances speed and reasoning depth for everyday tasks",
23
+ },
24
+ {"effort": "high", "description": "Greater reasoning depth for complex problems"},
25
+ {
26
+ "effort": "xhigh",
27
+ "description": "Extra high reasoning depth for complex problems",
28
+ },
29
+ ]
30
+
31
+ CODEX_BASE_INSTRUCTIONS = (
32
+ "You are Codex, a coding agent. Help the user understand, modify, test, "
33
+ "and review code in their workspace. Follow the user's instructions, use "
34
+ "tools when needed, and communicate concise progress and verification."
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class _CatalogCandidate:
40
+ slug: str
41
+ provider_model_ref: str
42
+ display_name: str
43
+ force_no_thinking: bool
44
+
45
+
46
+ def build_codex_model_catalog(models_response: Mapping[str, Any]) -> dict[str, Any]:
47
+ """Convert FCC `/v1/models` data into Codex `model_catalog_json` payload."""
48
+
49
+ candidates = list(_catalog_candidates(models_response))
50
+ normal_provider_refs = {
51
+ candidate.provider_model_ref
52
+ for candidate in candidates
53
+ if not candidate.force_no_thinking
54
+ }
55
+ models: list[dict[str, Any]] = []
56
+ seen_slugs: set[str] = set()
57
+
58
+ for candidate in candidates:
59
+ if (
60
+ candidate.force_no_thinking
61
+ and candidate.provider_model_ref in normal_provider_refs
62
+ ):
63
+ continue
64
+ if candidate.slug in seen_slugs:
65
+ continue
66
+ seen_slugs.add(candidate.slug)
67
+ models.append(_codex_catalog_entry(candidate, priority=len(models)))
68
+
69
+ return {"models": models}
70
+
71
+
72
+ def write_codex_model_catalog(catalog_path: Path, catalog: Mapping[str, Any]) -> None:
73
+ """Atomically write a Codex model catalog JSON file."""
74
+
75
+ catalog_path.parent.mkdir(parents=True, exist_ok=True)
76
+ temp_path = catalog_path.with_name(f".{catalog_path.name}.{uuid.uuid4().hex}.tmp")
77
+ temp_path.write_text(
78
+ json.dumps(catalog, ensure_ascii=True, indent=2) + "\n",
79
+ encoding="utf-8",
80
+ )
81
+ temp_path.replace(catalog_path)
82
+
83
+
84
+ def _catalog_candidates(
85
+ models_response: Mapping[str, Any],
86
+ ) -> list[_CatalogCandidate]:
87
+ data = models_response.get("data")
88
+ if not isinstance(data, list):
89
+ return []
90
+
91
+ candidates: list[_CatalogCandidate] = []
92
+ for item in data:
93
+ if not isinstance(item, Mapping):
94
+ continue
95
+ model_id = _string_value(item.get("id"))
96
+ if model_id is None:
97
+ continue
98
+ candidate = _candidate_from_model_id(
99
+ model_id,
100
+ display_name=_string_value(item.get("display_name")) or model_id,
101
+ )
102
+ if candidate is not None:
103
+ candidates.append(candidate)
104
+ return candidates
105
+
106
+
107
+ def _candidate_from_model_id(
108
+ model_id: str, *, display_name: str
109
+ ) -> _CatalogCandidate | None:
110
+ prefix, separator, remainder = model_id.partition("/")
111
+ if not separator:
112
+ return None
113
+
114
+ if prefix == GATEWAY_MODEL_ID_PREFIX:
115
+ if not _is_provider_model_ref(remainder):
116
+ return None
117
+ return _CatalogCandidate(
118
+ slug=remainder,
119
+ provider_model_ref=remainder,
120
+ display_name=display_name,
121
+ force_no_thinking=False,
122
+ )
123
+
124
+ if prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX:
125
+ if not _is_provider_model_ref(remainder):
126
+ return None
127
+ return _CatalogCandidate(
128
+ slug=model_id,
129
+ provider_model_ref=remainder,
130
+ display_name=display_name,
131
+ force_no_thinking=True,
132
+ )
133
+
134
+ if prefix in SUPPORTED_PROVIDER_IDS and remainder:
135
+ return _CatalogCandidate(
136
+ slug=model_id,
137
+ provider_model_ref=model_id,
138
+ display_name=display_name,
139
+ force_no_thinking=False,
140
+ )
141
+
142
+ return None
143
+
144
+
145
+ def _codex_catalog_entry(
146
+ candidate: _CatalogCandidate, *, priority: int
147
+ ) -> dict[str, Any]:
148
+ return {
149
+ "slug": candidate.slug,
150
+ "display_name": candidate.display_name,
151
+ "description": "DevCopilot provider model",
152
+ "default_reasoning_level": "medium",
153
+ "supported_reasoning_levels": SUPPORTED_REASONING_LEVELS,
154
+ "shell_type": "shell_command",
155
+ "visibility": "list",
156
+ "supported_in_api": True,
157
+ "priority": priority,
158
+ "additional_speed_tiers": [],
159
+ "service_tiers": [],
160
+ "base_instructions": CODEX_BASE_INSTRUCTIONS,
161
+ "supports_reasoning_summaries": True,
162
+ "default_reasoning_summary": "none",
163
+ "support_verbosity": True,
164
+ "default_verbosity": "low",
165
+ "apply_patch_tool_type": "freeform",
166
+ "web_search_tool_type": "text_and_image",
167
+ "truncation_policy": {"mode": "tokens", "limit": 10000},
168
+ "supports_parallel_tool_calls": True,
169
+ "supports_image_detail_original": True,
170
+ "context_window": 200000,
171
+ "max_context_window": 200000,
172
+ "effective_context_window_percent": 95,
173
+ "experimental_supported_tools": [],
174
+ "input_modalities": ["text"],
175
+ "supports_search_tool": True,
176
+ "use_responses_lite": False,
177
+ }
178
+
179
+
180
+ def _is_provider_model_ref(value: str) -> bool:
181
+ provider_id, separator, provider_model = value.partition("/")
182
+ return bool(separator and provider_model and provider_id in SUPPORTED_PROVIDER_IDS)
183
+
184
+
185
+ def _string_value(value: Any) -> str | None:
186
+ return value if isinstance(value, str) else None
@@ -0,0 +1,93 @@
1
+ """Shared process helpers for installed client CLI launchers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from cli.process_registry import (
13
+ kill_pid_tree_best_effort,
14
+ register_pid,
15
+ unregister_pid,
16
+ )
17
+
18
+ PROXY_PREFLIGHT_PATH = "/health"
19
+ PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5
20
+
21
+
22
+ def preflight_proxy(proxy_root_url: str) -> str | None:
23
+ """Return an error message when the local proxy health check is unreachable."""
24
+
25
+ url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}"
26
+ request = Request(url, method="GET")
27
+ try:
28
+ with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
29
+ status_code = response.getcode()
30
+ except HTTPError as exc:
31
+ return f"returned HTTP {exc.code}"
32
+ except URLError as exc:
33
+ return str(exc.reason)
34
+ except OSError as exc:
35
+ return str(exc)
36
+
37
+ if not 200 <= status_code < 300:
38
+ return f"returned HTTP {status_code}"
39
+ return None
40
+
41
+
42
+ def resolve_client_binary(
43
+ *,
44
+ binary_name: str,
45
+ display_name: str,
46
+ install_hint: str,
47
+ ) -> str:
48
+ """Resolve an installed client binary or exit with a user-facing hint."""
49
+
50
+ client_command = shutil.which(binary_name)
51
+ if client_command is None:
52
+ print(
53
+ f"Could not find {display_name} command: {binary_name}",
54
+ file=sys.stderr,
55
+ )
56
+ print(install_hint, file=sys.stderr)
57
+ raise SystemExit(127)
58
+ return client_command
59
+
60
+
61
+ def run_client_process(
62
+ *,
63
+ command: list[str],
64
+ env: Mapping[str, str],
65
+ binary_name: str,
66
+ display_name: str,
67
+ install_hint: str,
68
+ ) -> None:
69
+ """Run a client CLI command and mirror its exit code."""
70
+
71
+ process: subprocess.Popen[bytes] | None = None
72
+ try:
73
+ process = subprocess.Popen(command, env=dict(env))
74
+ if process.pid:
75
+ register_pid(process.pid)
76
+ return_code = process.wait()
77
+ except FileNotFoundError:
78
+ print(
79
+ f"Could not find {display_name} command: {binary_name}",
80
+ file=sys.stderr,
81
+ )
82
+ print(install_hint, file=sys.stderr)
83
+ raise SystemExit(127) from None
84
+ except KeyboardInterrupt:
85
+ if process is not None and process.pid:
86
+ kill_pid_tree_best_effort(process.pid)
87
+ process.wait()
88
+ raise
89
+ finally:
90
+ if process is not None and process.pid:
91
+ unregister_pid(process.pid)
92
+
93
+ raise SystemExit(return_code)
@@ -0,0 +1,6 @@
1
+ """Managed Claude Code sessions used by messaging."""
2
+
3
+ from .manager import ManagedClaudeSessionManager
4
+ from .session import ManagedClaudeSession
5
+
6
+ __all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"]
cli/managed/claude.py ADDED
@@ -0,0 +1,215 @@
1
+ """Managed Claude Code task command, environment, and stdout parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+ from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ManagedClaudeTaskRequest:
17
+ """One prompt execution request for a managed Claude Code subprocess."""
18
+
19
+ prompt: str
20
+ session_id: str | None = None
21
+ fork_session: bool = False
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ManagedClaudeInvocation:
26
+ """Concrete subprocess invocation assembled for a managed Claude task."""
27
+
28
+ argv: tuple[str, ...]
29
+ env: dict[str, str]
30
+ cwd: str
31
+ trace_metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ManagedClaudeConfig:
36
+ """Configuration for a managed Claude Code subprocess."""
37
+
38
+ workspace_path: str
39
+ api_url: str
40
+ allowed_dirs: list[str] = field(default_factory=list)
41
+ plans_directory: str | None = None
42
+ claude_bin: str = "claude"
43
+ auth_token: str = ""
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class ManagedClaudeParseState:
48
+ """Mutable stdout parser state for one managed Claude Code task run."""
49
+
50
+ log_raw_cli_diagnostics: bool = False
51
+ session_id_extracted: bool = False
52
+
53
+
54
+ def build_managed_claude_invocation(
55
+ *,
56
+ config: ManagedClaudeConfig,
57
+ request: ManagedClaudeTaskRequest,
58
+ base_env: Mapping[str, str],
59
+ ) -> ManagedClaudeInvocation:
60
+ """Build a Claude Code stream-json subprocess invocation."""
61
+
62
+ cmd = build_managed_claude_command(
63
+ claude_bin=config.claude_bin,
64
+ prompt=request.prompt,
65
+ session_id=request.session_id,
66
+ fork_session=request.fork_session,
67
+ allowed_dirs=config.allowed_dirs,
68
+ plans_directory=config.plans_directory,
69
+ )
70
+ resume_session_id = (
71
+ request.session_id
72
+ if request.session_id and not request.session_id.startswith("pending_")
73
+ else None
74
+ )
75
+ return ManagedClaudeInvocation(
76
+ argv=tuple(cmd),
77
+ env=build_managed_claude_env(
78
+ api_url=config.api_url,
79
+ auth_token=config.auth_token,
80
+ base_env=base_env,
81
+ ),
82
+ cwd=config.workspace_path,
83
+ trace_metadata={
84
+ "client_cli_id": "claude",
85
+ "resume_session_id": resume_session_id,
86
+ "fork_session": request.fork_session,
87
+ "prompt": request.prompt,
88
+ "cwd": config.workspace_path,
89
+ "claude_binary": config.claude_bin,
90
+ "cli_argv": cmd,
91
+ },
92
+ )
93
+
94
+
95
+ def build_managed_claude_env(
96
+ *,
97
+ api_url: str,
98
+ auth_token: str,
99
+ base_env: Mapping[str, str],
100
+ ) -> dict[str, str]:
101
+ """Return a Claude Code task environment that targets the local proxy."""
102
+
103
+ env = dict(base_env)
104
+ env["ANTHROPIC_API_URL"] = api_url
105
+ env["ANTHROPIC_BASE_URL"] = api_url[:-3] if api_url.endswith("/v1") else api_url
106
+ env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
107
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW
108
+ env.pop("ANTHROPIC_API_KEY", None)
109
+ env["ANTHROPIC_AUTH_TOKEN"] = claude_auth_token(auth_token)
110
+ env["TERM"] = "dumb"
111
+ env["PYTHONIOENCODING"] = "utf-8"
112
+ return env
113
+
114
+
115
+ def build_managed_claude_command(
116
+ *,
117
+ claude_bin: str,
118
+ prompt: str,
119
+ session_id: str | None,
120
+ fork_session: bool,
121
+ allowed_dirs: list[str],
122
+ plans_directory: str | None,
123
+ ) -> list[str]:
124
+ """Return the Claude Code stream-json command for a managed task."""
125
+
126
+ if session_id and not session_id.startswith("pending_"):
127
+ cmd = [
128
+ claude_bin,
129
+ "--resume",
130
+ session_id,
131
+ ]
132
+ if fork_session:
133
+ cmd.append("--fork-session")
134
+ cmd += [
135
+ "-p",
136
+ prompt,
137
+ "--output-format",
138
+ "stream-json",
139
+ "--dangerously-skip-permissions",
140
+ "--verbose",
141
+ ]
142
+ else:
143
+ cmd = [
144
+ claude_bin,
145
+ "-p",
146
+ prompt,
147
+ "--output-format",
148
+ "stream-json",
149
+ "--dangerously-skip-permissions",
150
+ "--verbose",
151
+ ]
152
+
153
+ for directory in allowed_dirs:
154
+ cmd.extend(["--add-dir", directory])
155
+
156
+ if plans_directory is not None:
157
+ cmd.extend(["--settings", json.dumps({"plansDirectory": plans_directory})])
158
+
159
+ return cmd
160
+
161
+
162
+ def parse_managed_claude_stdout_line(
163
+ line: str, state: ManagedClaudeParseState
164
+ ) -> Iterable[Any]:
165
+ """Parse one Claude Code stream-json stdout line."""
166
+
167
+ try:
168
+ event = json.loads(line)
169
+ except json.JSONDecodeError:
170
+ if state.log_raw_cli_diagnostics:
171
+ logger.debug("Non-JSON output: {}", line)
172
+ else:
173
+ logger.debug("Non-JSON CLI line: char_len={}", len(line))
174
+ yield {"type": "raw", "content": line}
175
+ return
176
+
177
+ if not state.session_id_extracted:
178
+ extracted_id = extract_managed_claude_session_id(event)
179
+ if extracted_id:
180
+ state.session_id_extracted = True
181
+ logger.info("Extracted session ID: {}", extracted_id)
182
+ yield {"type": "session_info", "session_id": extracted_id}
183
+
184
+ yield event
185
+
186
+
187
+ def extract_managed_claude_session_id(event: Any) -> str | None:
188
+ """Extract a Claude Code session ID from supported stream-json event shapes."""
189
+
190
+ if not isinstance(event, dict):
191
+ return None
192
+
193
+ if session_id := _string_value(event.get("session_id")):
194
+ return session_id
195
+ if session_id := _string_value(event.get("sessionId")):
196
+ return session_id
197
+
198
+ for key in ("init", "system", "result", "metadata"):
199
+ nested = event.get(key)
200
+ if not isinstance(nested, dict):
201
+ continue
202
+ if session_id := _string_value(nested.get("session_id")):
203
+ return session_id
204
+ if session_id := _string_value(nested.get("sessionId")):
205
+ return session_id
206
+
207
+ conversation = event.get("conversation")
208
+ if isinstance(conversation, dict):
209
+ return _string_value(conversation.get("id"))
210
+
211
+ return None
212
+
213
+
214
+ def _string_value(value: Any) -> str | None:
215
+ return value if isinstance(value, str) else None
cli/managed/manager.py ADDED
@@ -0,0 +1,157 @@
1
+ """Managed Claude Code session pool for messaging."""
2
+
3
+ import asyncio
4
+ import uuid
5
+
6
+ from loguru import logger
7
+
8
+ from .session import ManagedClaudeSession
9
+
10
+
11
+ class ManagedClaudeSessionManager:
12
+ """
13
+ Manages multiple Claude Code sessions for parallel conversation processing.
14
+
15
+ Each new conversation gets its own subprocess. Replies to existing
16
+ conversations reuse the same session instance.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ workspace_path: str,
22
+ api_url: str,
23
+ allowed_dirs: list[str] | None = None,
24
+ plans_directory: str | None = None,
25
+ claude_bin: str = "claude",
26
+ auth_token: str = "",
27
+ *,
28
+ log_raw_cli_diagnostics: bool = False,
29
+ log_messaging_error_details: bool = False,
30
+ ):
31
+ """
32
+ Initialize the session manager.
33
+
34
+ Args:
35
+ workspace_path: Working directory for CLI processes
36
+ api_url: API URL for the proxy
37
+ allowed_dirs: Directories the CLI is allowed to access
38
+ plans_directory: Directory for Claude Code CLI plan files (passed via --settings)
39
+ """
40
+ self.workspace = workspace_path
41
+ self.api_url = api_url
42
+ self.allowed_dirs = allowed_dirs or []
43
+ self.plans_directory = plans_directory
44
+ self.claude_bin = claude_bin
45
+ self.auth_token = auth_token
46
+ self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
47
+ self._log_messaging_error_details = log_messaging_error_details
48
+
49
+ self._sessions: dict[str, ManagedClaudeSession] = {}
50
+ self._pending_sessions: dict[str, ManagedClaudeSession] = {}
51
+ self._temp_to_real: dict[str, str] = {}
52
+ self._real_to_temp: dict[str, str] = {}
53
+ self._lock = asyncio.Lock()
54
+
55
+ async def get_or_create_session(
56
+ self, session_id: str | None = None
57
+ ) -> tuple[ManagedClaudeSession, str, bool]:
58
+ """
59
+ Get an existing session or create a new one.
60
+
61
+ Returns:
62
+ Tuple of (session instance, session_id, is_new_session)
63
+ """
64
+ async with self._lock:
65
+ if session_id:
66
+ lookup_id = self._temp_to_real.get(session_id, session_id)
67
+
68
+ if lookup_id in self._sessions:
69
+ return self._sessions[lookup_id], lookup_id, False
70
+ if lookup_id in self._pending_sessions:
71
+ return self._pending_sessions[lookup_id], lookup_id, False
72
+
73
+ temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
74
+
75
+ new_session = ManagedClaudeSession(
76
+ workspace_path=self.workspace,
77
+ api_url=self.api_url,
78
+ allowed_dirs=self.allowed_dirs,
79
+ plans_directory=self.plans_directory,
80
+ claude_bin=self.claude_bin,
81
+ auth_token=self.auth_token,
82
+ log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
83
+ )
84
+ self._pending_sessions[temp_id] = new_session
85
+
86
+ return new_session, temp_id, True
87
+
88
+ async def register_real_session_id(
89
+ self, temp_id: str, real_session_id: str
90
+ ) -> bool:
91
+ """Register the real session ID from CLI output."""
92
+ async with self._lock:
93
+ if temp_id not in self._pending_sessions:
94
+ logger.warning(f"Temp session {temp_id} not found")
95
+ return False
96
+
97
+ session = self._pending_sessions.pop(temp_id)
98
+ self._sessions[real_session_id] = session
99
+ self._temp_to_real[temp_id] = real_session_id
100
+ self._real_to_temp[real_session_id] = temp_id
101
+
102
+ logger.info(f"Registered session: {temp_id} -> {real_session_id}")
103
+ return True
104
+
105
+ async def remove_session(self, session_id: str) -> bool:
106
+ """Remove a session from the manager."""
107
+ async with self._lock:
108
+ if session_id in self._pending_sessions:
109
+ session = self._pending_sessions.pop(session_id)
110
+ await session.stop()
111
+ return True
112
+
113
+ if session_id in self._sessions:
114
+ session = self._sessions.pop(session_id)
115
+ await session.stop()
116
+ temp_id = self._real_to_temp.pop(session_id, None)
117
+ if temp_id is not None:
118
+ self._temp_to_real.pop(temp_id, None)
119
+ return True
120
+
121
+ return False
122
+
123
+ async def stop_all(self):
124
+ """Stop all sessions."""
125
+ async with self._lock:
126
+ all_sessions = list(self._sessions.values()) + list(
127
+ self._pending_sessions.values()
128
+ )
129
+ for session in all_sessions:
130
+ try:
131
+ await session.stop()
132
+ except Exception as e:
133
+ if self._log_messaging_error_details:
134
+ logger.error(
135
+ "Error stopping session: {}: {}",
136
+ type(e).__name__,
137
+ e,
138
+ )
139
+ else:
140
+ logger.error(
141
+ "Error stopping session: exc_type={}",
142
+ type(e).__name__,
143
+ )
144
+
145
+ self._sessions.clear()
146
+ self._pending_sessions.clear()
147
+ self._temp_to_real.clear()
148
+ self._real_to_temp.clear()
149
+ logger.info("All sessions stopped")
150
+
151
+ def get_stats(self) -> dict:
152
+ """Get session statistics."""
153
+ return {
154
+ "active_sessions": len(self._sessions),
155
+ "pending_sessions": len(self._pending_sessions),
156
+ "busy_count": sum(1 for s in self._sessions.values() if s.is_busy),
157
+ }