iac-code 0.1.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 (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. iac_code-0.1.0.dist-info/top_level.txt +1 -0
iac_code/acp/server.py ADDED
@@ -0,0 +1,662 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import acp
11
+
12
+ from iac_code import __version__
13
+ from iac_code.acp.metrics import ACPMetrics
14
+ from iac_code.acp.session import ACPSession, Message, _is_auth_error
15
+ from iac_code.acp.slash_registry import ACP_SUPPORTED_COMMANDS
16
+ from iac_code.acp.tools import replace_bash_with_acp_terminal
17
+ from iac_code.acp.types import ACPContentBlock, MCPServer
18
+ from iac_code.acp.version import negotiate_version
19
+ from iac_code.commands import LocalCommand, create_default_registry
20
+ from iac_code.config import DEFAULT_MODEL, get_active_provider_key, load_saved_model
21
+ from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime
22
+ from iac_code.services.session_storage import SessionStorage
23
+
24
+ SESSION_IDLE_TIMEOUT = 3600 # 1 hour
25
+ CLEANUP_INTERVAL = 300 # 5 minutes
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ACPServer:
31
+ def __init__(self) -> None:
32
+ self.conn: acp.Client | None = None
33
+ self.client_capabilities: acp.schema.ClientCapabilities | None = None
34
+ self.sessions: dict[str, ACPSession] = {}
35
+ self._cleanup_task: asyncio.Task | None = None
36
+ self.metrics: ACPMetrics = ACPMetrics()
37
+
38
+ def on_connect(self, conn: acp.Client) -> None:
39
+ self.conn = conn
40
+
41
+ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.schema.AuthenticateResponse | None:
42
+ """Handle ACP ``authenticate`` requests.
43
+
44
+ iac-code performs authentication out-of-band (env vars / credentials
45
+ file), so this is a no-op acknowledgement that satisfies the
46
+ :class:`acp.Agent` protocol contract.
47
+ """
48
+ return None
49
+
50
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
51
+ """Handle ACP extension method calls.
52
+
53
+ iac-code does not implement any custom extension methods; this stub
54
+ exists solely to satisfy the :class:`acp.Agent` protocol contract.
55
+ """
56
+ raise acp.RequestError.method_not_found(method)
57
+
58
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
59
+ """Handle ACP extension notifications.
60
+
61
+ iac-code does not act on any extension notifications; the body is a
62
+ no-op for protocol-conformance purposes only.
63
+ """
64
+ return None
65
+
66
+ async def set_session_mode(
67
+ self,
68
+ mode_id: str,
69
+ session_id: str,
70
+ **kwargs: Any,
71
+ ) -> acp.schema.SetSessionModeResponse | None:
72
+ """Handle ACP ``session/set_mode`` requests.
73
+
74
+ iac-code does not currently expose user-selectable session modes;
75
+ the request is acknowledged but otherwise has no effect. The stub
76
+ exists to satisfy the :class:`acp.Agent` protocol contract.
77
+ """
78
+ return None
79
+
80
+ async def set_session_model(
81
+ self,
82
+ model_id: str,
83
+ session_id: str,
84
+ **kwargs: Any,
85
+ ) -> acp.schema.SetSessionModelResponse | None:
86
+ """Handle ACP ``session/set_model`` requests.
87
+
88
+ Models are configured via :func:`load_saved_model` / the auth flow,
89
+ so dynamic per-session model switching is a no-op for now. The
90
+ stub exists to satisfy the :class:`acp.Agent` protocol contract.
91
+ """
92
+ return None
93
+
94
+ def _get_session(self, session_id: str) -> ACPSession:
95
+ session = self.sessions.get(session_id)
96
+ if session is None:
97
+ raise acp.RequestError.invalid_params({"session_id": "Session not found"})
98
+ return session
99
+
100
+ async def initialize(
101
+ self,
102
+ protocol_version: int,
103
+ client_capabilities: acp.schema.ClientCapabilities | None = None,
104
+ client_info: acp.schema.Implementation | None = None,
105
+ **kwargs: Any,
106
+ ) -> acp.InitializeResponse:
107
+ negotiated = negotiate_version(protocol_version)
108
+ self.client_capabilities = client_capabilities
109
+ await self._start_cleanup_loop()
110
+ logger.info(
111
+ "ACP server initialized, protocol_version=%d, client=%s",
112
+ negotiated.protocol_version,
113
+ client_info.name if client_info else "unknown",
114
+ )
115
+ return acp.InitializeResponse(
116
+ protocol_version=negotiated.protocol_version,
117
+ agent_capabilities=acp.schema.AgentCapabilities(
118
+ load_session=True,
119
+ prompt_capabilities=acp.schema.PromptCapabilities(
120
+ embedded_context=True,
121
+ image=False,
122
+ audio=False,
123
+ ),
124
+ mcp_capabilities=acp.schema.McpCapabilities(http=False, sse=False),
125
+ session_capabilities=acp.schema.SessionCapabilities(
126
+ close=acp.schema.SessionCloseCapabilities(),
127
+ list=acp.schema.SessionListCapabilities(),
128
+ ),
129
+ ),
130
+ auth_methods=_build_auth_methods(),
131
+ agent_info=acp.schema.Implementation(name="iac-code", version=__version__),
132
+ )
133
+
134
+ async def new_session(
135
+ self,
136
+ cwd: str,
137
+ mcp_servers: list[MCPServer] | None = None,
138
+ **kwargs: Any,
139
+ ) -> acp.NewSessionResponse:
140
+ if self.conn is None:
141
+ raise acp.RequestError.internal_error({"error": "ACP client not connected"})
142
+
143
+ # Convert MCP server configs from ACP protocol types to internal dicts
144
+ mcp_configs = _convert_mcp_servers(mcp_servers)
145
+
146
+ model = load_saved_model() or DEFAULT_MODEL
147
+ runtime = self._create_runtime_with_auth_check(model=model, cwd=cwd)
148
+ replace_bash_with_acp_terminal(
149
+ runtime.tool_registry,
150
+ self.client_capabilities,
151
+ self.conn,
152
+ runtime.session_id,
153
+ )
154
+ session = ACPSession(
155
+ runtime.session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
156
+ )
157
+ self.sessions[session.id] = session
158
+ self.metrics.record_session_created()
159
+ logger.info("Session created, session_id=%s, model=%s", session.id, model)
160
+
161
+ # Build model state for the response
162
+ model_state = self._build_model_state(model)
163
+
164
+ response = acp.NewSessionResponse(
165
+ session_id=session.id,
166
+ models=model_state,
167
+ )
168
+
169
+ # Push available commands to the client
170
+ await self._push_available_commands(session.id)
171
+
172
+ return response
173
+
174
+ async def prompt(
175
+ self,
176
+ prompt: list[ACPContentBlock],
177
+ session_id: str,
178
+ message_id: str | None = None,
179
+ **kwargs: Any,
180
+ ) -> acp.PromptResponse:
181
+ session = self._get_session(session_id)
182
+ session.touch()
183
+ return await session.prompt(prompt)
184
+
185
+ async def close_session(self, session_id: str, **kwargs: Any) -> acp.schema.CloseSessionResponse:
186
+ """Close a session, releasing all associated resources.
187
+
188
+ Idempotent: closing an already-removed session returns success.
189
+ """
190
+ session = self.sessions.get(session_id)
191
+ if session is None:
192
+ # Already gone (cleaned up or previously closed) — return success.
193
+ return acp.schema.CloseSessionResponse()
194
+
195
+ # Cancel any running prompt, then release resources.
196
+ await session.close()
197
+
198
+ # Remove from active sessions.
199
+ self.sessions.pop(session_id, None)
200
+ self.metrics.record_session_closed()
201
+ logger.info("Session %s closed and removed via close_session", session_id)
202
+ return acp.schema.CloseSessionResponse()
203
+
204
+ async def set_config_option(
205
+ self,
206
+ config_id: str,
207
+ session_id: str,
208
+ value: str | bool,
209
+ **kwargs: Any,
210
+ ) -> acp.schema.SetSessionConfigOptionResponse | None:
211
+ """Handle dynamic config updates from the client.
212
+
213
+ Stores the *config_id* / *value* pair in the session's dynamic config
214
+ and returns the full list of current config options.
215
+ """
216
+ session = self._get_session(session_id)
217
+ session.update_config({config_id: value})
218
+ logger.info("Session %s config updated: %s=%r", session_id, config_id, value)
219
+ return None
220
+
221
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
222
+ session = self._get_session(session_id)
223
+ await session.cancel()
224
+
225
+ async def list_sessions(
226
+ self,
227
+ cursor: str | None = None,
228
+ cwd: str | None = None,
229
+ **kwargs: Any,
230
+ ) -> acp.schema.ListSessionsResponse:
231
+ from iac_code.utils.project_paths import get_project_dir, get_projects_dir
232
+
233
+ session_ids: list[str] = []
234
+ if cwd:
235
+ project_dir = get_project_dir(cwd)
236
+ if project_dir.exists():
237
+ session_ids = [p.stem for p in project_dir.glob("*.jsonl")]
238
+ else:
239
+ projects_root = get_projects_dir()
240
+ if projects_root.exists():
241
+ session_ids = [p.stem for p in projects_root.glob("*/*.jsonl")]
242
+ return acp.schema.ListSessionsResponse(
243
+ sessions=[
244
+ acp.schema.SessionInfo(
245
+ session_id=session_id,
246
+ cwd=cwd or "",
247
+ title=session_id,
248
+ )
249
+ for session_id in session_ids
250
+ ],
251
+ next_cursor=None,
252
+ )
253
+
254
+ async def load_session(
255
+ self,
256
+ cwd: str,
257
+ session_id: str,
258
+ mcp_servers: list[MCPServer] | None = None,
259
+ **kwargs: Any,
260
+ ) -> acp.LoadSessionResponse | None:
261
+ """Load a persisted session and replay its history to the client.
262
+
263
+ If the session is already active in memory it is returned directly.
264
+ Otherwise the history is read from :class:`SessionStorage`, a fresh
265
+ agent runtime is created, and history events are replayed as ACP
266
+ ``session_update`` notifications so the client can rebuild its UI.
267
+ """
268
+ if self.conn is None:
269
+ raise acp.RequestError.internal_error({"error": "ACP client not connected"})
270
+
271
+ # 1. Already active in memory — return immediately
272
+ if session_id in self.sessions:
273
+ model = load_saved_model() or DEFAULT_MODEL
274
+ return acp.LoadSessionResponse(models=self._build_model_state(model))
275
+
276
+ # 2. Try to load from persistent storage
277
+ storage = SessionStorage()
278
+ if not storage.exists(cwd, session_id):
279
+ raise acp.RequestError.invalid_params({"session_id": "Session not found"})
280
+
281
+ history = storage.load(cwd, session_id)
282
+ history = SessionStorage.repair_interrupted(history)
283
+
284
+ mcp_configs = _convert_mcp_servers(mcp_servers)
285
+
286
+ # 3. Rebuild agent runtime with restored history
287
+ model = load_saved_model() or DEFAULT_MODEL
288
+ runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd)
289
+ replace_bash_with_acp_terminal(
290
+ runtime.tool_registry,
291
+ self.client_capabilities,
292
+ self.conn,
293
+ runtime.session_id,
294
+ )
295
+
296
+ if history:
297
+ runtime.agent_loop.context_manager.load_messages(history)
298
+
299
+ # 4. Register session
300
+ session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
301
+ self.sessions[session_id] = session
302
+ self.metrics.record_session_created()
303
+ logger.info("Session loaded, session_id=%s, history_messages=%d", session_id, len(history))
304
+
305
+ # 5. Replay history events asynchronously so the client can rebuild UI
306
+ if history:
307
+ session._replay_task = asyncio.create_task(self._replay_session_history(session, history))
308
+
309
+ # 6. Push available commands
310
+ await self._push_available_commands(session_id)
311
+
312
+ return acp.LoadSessionResponse(models=self._build_model_state(model))
313
+
314
+ async def fork_session(
315
+ self,
316
+ cwd: str,
317
+ session_id: str,
318
+ mcp_servers: list[MCPServer] | None = None,
319
+ **kwargs: Any,
320
+ ) -> acp.schema.ForkSessionResponse:
321
+ """Create a new session forked from an existing one.
322
+
323
+ The full history of the source session is copied into a brand-new
324
+ session with a fresh ``session_id``. The client can then continue
325
+ the conversation on the fork without affecting the original.
326
+ """
327
+ if self.conn is None:
328
+ raise acp.RequestError.internal_error({"error": "ACP client not connected"})
329
+
330
+ # 1. Collect history from the source session
331
+ history: list[Message] = []
332
+ if session_id in self.sessions:
333
+ source = self.sessions[session_id]
334
+ ctx = getattr(source.agent_loop, "context_manager", None)
335
+ if ctx is not None:
336
+ history = list(ctx.get_messages())
337
+ else:
338
+ storage = SessionStorage()
339
+ if not storage.exists(cwd, session_id):
340
+ raise acp.RequestError.invalid_params({"session_id": "Source session not found"})
341
+ history = storage.load(cwd, session_id)
342
+ history = SessionStorage.repair_interrupted(history)
343
+
344
+ mcp_configs = _convert_mcp_servers(mcp_servers)
345
+
346
+ # 2. Create a new runtime for the fork
347
+ new_session_id = str(uuid.uuid4())
348
+ model = load_saved_model() or DEFAULT_MODEL
349
+ runtime = self._create_runtime_with_auth_check(model=model, session_id=new_session_id, cwd=cwd)
350
+ replace_bash_with_acp_terminal(
351
+ runtime.tool_registry,
352
+ self.client_capabilities,
353
+ self.conn,
354
+ runtime.session_id,
355
+ )
356
+
357
+ # 3. Inject history into the new runtime
358
+ if history:
359
+ runtime.agent_loop.context_manager.load_messages(history)
360
+
361
+ # 4. Register the forked session
362
+ session = ACPSession(
363
+ new_session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics
364
+ )
365
+ self.sessions[new_session_id] = session
366
+ self.metrics.record_session_created()
367
+ logger.info("Session forked, source_session_id=%s, new_session_id=%s", session_id, new_session_id)
368
+
369
+ # 5. Replay history so the client can show it
370
+ if history:
371
+ session._replay_task = asyncio.create_task(self._replay_session_history(session, history))
372
+
373
+ await self._push_available_commands(new_session_id)
374
+
375
+ return acp.schema.ForkSessionResponse(
376
+ session_id=new_session_id,
377
+ models=self._build_model_state(model),
378
+ )
379
+
380
+ async def resume_session(
381
+ self,
382
+ cwd: str,
383
+ session_id: str,
384
+ mcp_servers: list[MCPServer] | None = None,
385
+ **kwargs: Any,
386
+ ) -> acp.schema.ResumeSessionResponse:
387
+ # 1. If session is still active in memory, return directly
388
+ if session_id in self.sessions:
389
+ await self._push_available_commands(session_id)
390
+ return acp.schema.ResumeSessionResponse()
391
+
392
+ if self.conn is None:
393
+ raise acp.RequestError.internal_error({"error": "ACP client not connected"})
394
+
395
+ # 2. Try to load persisted history from SessionStorage
396
+ storage = SessionStorage()
397
+ if not storage.exists(cwd, session_id):
398
+ raise acp.RequestError.invalid_params({"session_id": "Session not found"})
399
+
400
+ history = storage.load(cwd, session_id)
401
+ history = SessionStorage.repair_interrupted(history)
402
+
403
+ # Convert MCP server configs from ACP protocol types to internal dicts
404
+ mcp_configs = _convert_mcp_servers(mcp_servers)
405
+
406
+ # 3. Rebuild agent runtime with restored history
407
+ model = load_saved_model() or DEFAULT_MODEL
408
+ runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd)
409
+ replace_bash_with_acp_terminal(
410
+ runtime.tool_registry,
411
+ self.client_capabilities,
412
+ self.conn,
413
+ runtime.session_id,
414
+ )
415
+
416
+ # Inject restored history into the agent loop
417
+ if history:
418
+ runtime.agent_loop.context_manager.load_messages(history)
419
+
420
+ # 4. Register the resumed session
421
+ session = ACPSession(session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, metrics=self.metrics)
422
+ self.sessions[session_id] = session
423
+ self.metrics.record_session_created()
424
+ await self._push_available_commands(session_id)
425
+
426
+ return acp.schema.ResumeSessionResponse()
427
+
428
+ # ------------------------------------------------------------------
429
+ # Runtime creation helper
430
+ # ------------------------------------------------------------------
431
+
432
+ @staticmethod
433
+ def _create_runtime_with_auth_check(
434
+ *,
435
+ model: str,
436
+ cwd: str,
437
+ session_id: str | None = None,
438
+ ):
439
+ """Create an agent runtime, converting auth errors to ACP RequestError."""
440
+ try:
441
+ return create_agent_runtime(AgentFactoryOptions(model=model, session_id=session_id, cwd=cwd))
442
+ except Exception as exc:
443
+ if _is_auth_error(exc):
444
+ logger.warning("Authentication error during runtime creation: %s", exc)
445
+ raise acp.RequestError.internal_error(
446
+ {
447
+ "error": "Authentication required. Please configure your API credentials.",
448
+ "code": "auth_required",
449
+ }
450
+ ) from exc
451
+ raise
452
+
453
+ # ------------------------------------------------------------------
454
+ # Model state & available commands helpers
455
+ # ------------------------------------------------------------------
456
+
457
+ @staticmethod
458
+ def _build_model_state(model: str) -> acp.schema.SessionModelState:
459
+ """Build SessionModelState from the active model identifier."""
460
+ provider_key = get_active_provider_key() or "dashscope"
461
+ return acp.schema.SessionModelState(
462
+ available_models=[
463
+ acp.schema.ModelInfo(
464
+ model_id=model,
465
+ name=model,
466
+ description=f"Active model via {provider_key}",
467
+ ),
468
+ ],
469
+ current_model_id=model,
470
+ )
471
+
472
+ async def _replay_session_history(
473
+ self,
474
+ session: ACPSession,
475
+ history: list[Message],
476
+ ) -> None:
477
+ """Replay history events for a loaded/forked session.
478
+
479
+ Errors are logged but not propagated so that the session remains
480
+ usable even if a single replay event fails.
481
+ """
482
+ try:
483
+ await session.replay_history(history)
484
+ except Exception:
485
+ logger.exception("Failed to replay history for session %s", session.id)
486
+
487
+ async def _push_available_commands(self, session_id: str) -> None:
488
+ """Push the list of available slash commands to the client via session_update."""
489
+ if self.conn is None:
490
+ return
491
+ registry = create_default_registry()
492
+ commands = []
493
+ for cmd in registry.get_all():
494
+ if cmd.name not in ACP_SUPPORTED_COMMANDS:
495
+ continue
496
+ # Build input hint: prefer arg_hint, fall back to arg_names
497
+ hint = None
498
+ if isinstance(cmd, LocalCommand):
499
+ if cmd.arg_hint:
500
+ hint = cmd.arg_hint
501
+ elif cmd.arg_names:
502
+ hint = " ".join(f"[{name}]" for name in cmd.arg_names)
503
+
504
+ input_spec = (
505
+ acp.schema.AvailableCommandInput(root=acp.schema.UnstructuredCommandInput(hint=hint)) if hint else None
506
+ )
507
+ commands.append(
508
+ acp.schema.AvailableCommand(
509
+ name=cmd.name,
510
+ description=cmd.description,
511
+ input=input_spec,
512
+ )
513
+ )
514
+ if not commands:
515
+ return
516
+ await self.conn.session_update(
517
+ session_id=session_id,
518
+ update=acp.schema.AvailableCommandsUpdate(
519
+ session_update="available_commands_update",
520
+ available_commands=commands,
521
+ ),
522
+ )
523
+
524
+ # ------------------------------------------------------------------
525
+ # Cleanup loop
526
+ # ------------------------------------------------------------------
527
+
528
+ async def _start_cleanup_loop(self) -> None:
529
+ """Start background cleanup loop for idle sessions."""
530
+ if self._cleanup_task is None:
531
+ self._cleanup_task = asyncio.create_task(self._cleanup_idle_sessions())
532
+
533
+ async def shutdown(self) -> None:
534
+ """Gracefully shut down the server, stopping background tasks."""
535
+ await self.shutdown_all_sessions()
536
+
537
+ async def shutdown_all_sessions(self) -> None:
538
+ """Close all active sessions and stop the cleanup loop."""
539
+ await self._stop_cleanup_loop()
540
+ for session_id in list(self.sessions):
541
+ session = self.sessions.pop(session_id)
542
+ await session.close()
543
+ self.metrics.record_session_closed()
544
+ logger.info("All sessions shut down. Metrics: %s", self.metrics.snapshot())
545
+
546
+ async def _stop_cleanup_loop(self) -> None:
547
+ """Stop background cleanup loop."""
548
+ if self._cleanup_task is not None:
549
+ self._cleanup_task.cancel()
550
+ with contextlib.suppress(asyncio.CancelledError):
551
+ await self._cleanup_task
552
+ self._cleanup_task = None
553
+
554
+ async def _cleanup_idle_sessions(self) -> None:
555
+ """Periodically remove idle sessions.
556
+
557
+ A session is only considered for cleanup when:
558
+ * it has been idle longer than ``SESSION_IDLE_TIMEOUT``, and
559
+ * it has no in-flight prompt task (``_current_task is None`` or done),
560
+ and
561
+ * it has not already been closed.
562
+
563
+ This prevents the cleanup loop from terminating an actively running
564
+ prompt mid-execution.
565
+ """
566
+ while True:
567
+ await asyncio.sleep(CLEANUP_INTERVAL)
568
+ try:
569
+ now = time.monotonic()
570
+ expired: list[str] = []
571
+ for sid, session in self.sessions.items():
572
+ if session.is_closed:
573
+ expired.append(sid)
574
+ continue
575
+ if now - session.last_active <= SESSION_IDLE_TIMEOUT:
576
+ continue
577
+ task = session._current_task
578
+ if task is not None and not task.done():
579
+ # Active prompt in progress — leave it alone for now.
580
+ continue
581
+ expired.append(sid)
582
+ for sid in expired:
583
+ session = self.sessions.pop(sid, None)
584
+ if session is not None and not session.is_closed:
585
+ await session.close()
586
+ self.metrics.record_session_closed()
587
+ logger.info("Cleaned up idle session %s", sid)
588
+ except Exception:
589
+ logger.exception("Error during session cleanup")
590
+
591
+
592
+ # ---------------------------------------------------------------------------
593
+ # MCP server config helper
594
+ # ---------------------------------------------------------------------------
595
+
596
+
597
+ def _convert_mcp_servers(mcp_servers: list[MCPServer] | None) -> list[dict[str, Any]]:
598
+ """Convert ACP MCP server configs to internal dicts, filtering unsupported types.
599
+
600
+ Tolerant by design: a malformed or unsupported entry from the client must
601
+ not abort ``new_session``. Conversion failures are logged and the offending
602
+ entry is skipped so the session can still start with whatever configs are
603
+ valid.
604
+ """
605
+ if not mcp_servers:
606
+ return []
607
+ from iac_code.acp.mcp import convert_mcp_configs
608
+
609
+ try:
610
+ configs = convert_mcp_configs(mcp_servers)
611
+ except Exception:
612
+ logger.exception(
613
+ "Failed to convert MCP server configs (%d entries); proceeding with no MCP servers",
614
+ len(mcp_servers),
615
+ )
616
+ return []
617
+ if configs:
618
+ logger.info("Received %d MCP server config(s): %s", len(configs), [c["name"] for c in configs])
619
+ return configs
620
+
621
+
622
+ # ---------------------------------------------------------------------------
623
+ # Auth methods declaration
624
+ # ---------------------------------------------------------------------------
625
+
626
+ # Supported provider environment variables for credentials.
627
+ _PROVIDER_ENV_VARS: list[tuple[str, str, str]] = [
628
+ ("DASHSCOPE_API_KEY", "DashScope / Qwen API Key", "https://dashscope.console.aliyun.com/"),
629
+ ("OPENAI_API_KEY", "OpenAI API Key", "https://platform.openai.com/api-keys"),
630
+ ("ANTHROPIC_API_KEY", "Anthropic API Key", "https://console.anthropic.com/"),
631
+ ("DEEPSEEK_API_KEY", "DeepSeek API Key", "https://platform.deepseek.com/"),
632
+ ]
633
+
634
+
635
+ def _build_auth_methods() -> list[
636
+ acp.schema.EnvVarAuthMethod | acp.schema.TerminalAuthMethod | acp.schema.AuthMethodAgent
637
+ ]:
638
+ """Build the list of supported authentication methods for ACP initialize.
639
+
640
+ iac-code supports multiple LLM providers. Credentials can be provided via
641
+ environment variables or via the credentials config file
642
+ (~/.iac-code/.credentials.yml). The env-var method is the standard ACP
643
+ mechanism that clients can present to users.
644
+ """
645
+ return [
646
+ acp.schema.EnvVarAuthMethod(
647
+ type="env_var",
648
+ id=f"env_{env_name.lower()}",
649
+ name=label,
650
+ description=f"Set {env_name} to authenticate with this provider.",
651
+ link=link,
652
+ vars=[
653
+ acp.schema.AuthEnvVar(
654
+ name=env_name,
655
+ label=label,
656
+ secret=True,
657
+ optional=False,
658
+ ),
659
+ ],
660
+ )
661
+ for env_name, label, link in _PROVIDER_ENV_VARS
662
+ ]