zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,978 @@
1
+ """LangGraph server lifecycle management for the app.
2
+
3
+ Handles starting/stopping a `langgraph dev` server process and generating the
4
+ required `langgraph.json` configuration file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import signal
15
+ import subprocess # noqa: S404
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any, Self
21
+ from urllib.parse import quote
22
+
23
+ from deepagents_code._env_vars import SERVER_ENV_PREFIX
24
+ from deepagents_code.config import _INHERITED_PYTHONPATH_ENV
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable, Iterator
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _DEFAULT_HOST = "127.0.0.1"
32
+
33
+ _EPHEMERAL_PORT = 0
34
+ """Sentinel port meaning "let `start()` pick a free ephemeral port".
35
+
36
+ The server is internal and ephemeral — callers reach it via `ServerProcess.url`,
37
+ never a typed-in address — so it deliberately avoids binding the well-known
38
+ `langgraph dev` default (2024). Leaving 2024 free lets users run their own
39
+ `langgraph dev` projects alongside `deepagents-code` without a port collision.
40
+ """
41
+
42
+ _HEALTH_POLL_INTERVAL_LOCAL = 0.1
43
+
44
+ _HEALTH_POLL_INTERVAL_REMOTE = 0.3
45
+
46
+ _HEALTH_TIMEOUT = 60
47
+
48
+ _SHUTDOWN_TIMEOUT = 3
49
+ """Seconds to wait for a graceful SIGTERM exit before escalating to SIGKILL."""
50
+
51
+ _SIGKILL_TIMEOUT = 2
52
+ """Seconds to wait for the group/process to exit after SIGKILL."""
53
+
54
+ _PROCESS_GROUP_POLL_INTERVAL = 0.05
55
+
56
+ _LOG_TAIL_CHARS = 3000
57
+ """Max chars of subprocess log appended to the early-exit `RuntimeError` message.
58
+
59
+ Enough to carry a Python traceback without flooding the TUI banner when it
60
+ surfaces via `ServerStartFailed`.
61
+ """
62
+
63
+ _STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
64
+ """Machine-readable prefix emitted by the server subprocess for known startup errors."""
65
+
66
+ _SERVER_ENV_DENYLIST = frozenset(
67
+ {
68
+ "DYLD_INSERT_LIBRARIES",
69
+ "DYLD_LIBRARY_PATH",
70
+ "GIT_ASKPASS",
71
+ "LD_AUDIT",
72
+ "LD_LIBRARY_PATH",
73
+ "LD_PRELOAD",
74
+ "NODE_OPTIONS",
75
+ "PYTHONEXECUTABLE",
76
+ "PYTHONHOME",
77
+ "PYTHONPATH",
78
+ "PYTHONSTARTUP",
79
+ "SSH_ASKPASS",
80
+ }
81
+ )
82
+ """Inherited env keys that can alter subprocess startup behavior.
83
+
84
+ `PYTHONPATH` is stripped here so an inherited launch value cannot land on the
85
+ server interpreter's `sys.path` during startup, where a path inside an untrusted
86
+ project could shadow a stdlib/third-party module and run before any approval
87
+ gate. A user who launched with `PYTHONPATH` still wants it for their agent
88
+ `execute` commands, so `_build_server_env` relays the value via
89
+ `config._INHERITED_PYTHONPATH_ENV` and `agent._apply_inherited_pythonpath`
90
+ re-applies it only to the approval-gated shell backend.
91
+ """
92
+
93
+
94
+ def _port_in_use(host: str, port: int) -> bool:
95
+ """Check if a port is already in use.
96
+
97
+ Args:
98
+ host: Host to check.
99
+ port: Port to check.
100
+
101
+ Returns:
102
+ `True` if the port is in use.
103
+ """
104
+ import socket
105
+
106
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
107
+ try:
108
+ s.bind((host, port))
109
+ except OSError:
110
+ return True
111
+ else:
112
+ return False
113
+
114
+
115
+ def _find_free_port(host: str) -> int:
116
+ """Find a free port on the given host.
117
+
118
+ Args:
119
+ host: Host to bind to.
120
+
121
+ Returns:
122
+ An available port number.
123
+ """
124
+ import socket
125
+
126
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
127
+ s.bind((host, 0))
128
+ return s.getsockname()[1]
129
+
130
+
131
+ def get_server_url(host: str = _DEFAULT_HOST, port: int = _EPHEMERAL_PORT) -> str:
132
+ """Build the server base URL.
133
+
134
+ Args:
135
+ host: Server host.
136
+ port: Server port.
137
+
138
+ Returns:
139
+ Base URL string.
140
+ """
141
+ return f"http://{host}:{port}"
142
+
143
+
144
+ def _extract_startup_error_marker(output: str) -> str | None:
145
+ """Extract a marked startup error from subprocess output.
146
+
147
+ Args:
148
+ output: Combined stdout/stderr captured from the server subprocess.
149
+
150
+ Returns:
151
+ The marked startup error message, or `None` if no marker was emitted.
152
+ """
153
+ for line in reversed(output.splitlines()):
154
+ if _STARTUP_ERROR_MARKER in line:
155
+ _, summary = line.rsplit(_STARTUP_ERROR_MARKER, 1)
156
+ return summary.strip() or None
157
+ return None
158
+
159
+
160
+ def generate_langgraph_json(
161
+ output_dir: str | Path,
162
+ *,
163
+ graph_ref: str = "deepagents_code.server_graph:make_graph",
164
+ env_file: str | None = None,
165
+ checkpointer_path: str | None = None,
166
+ ) -> Path:
167
+ """Generate a `langgraph.json` config file for `langgraph dev`.
168
+
169
+ Args:
170
+ output_dir: Directory to write the config file.
171
+ graph_ref: Python "module:attribute" reference to the graph, where the
172
+ attribute is a graph factory (e.g. `make_graph`) or a graph object.
173
+ env_file: Optional path to an env file.
174
+ checkpointer_path: Import path to an async context manager that yields a
175
+ `BaseCheckpointSaver`. When set, the server persists checkpoint data
176
+ to disk instead of in-memory.
177
+
178
+ Returns:
179
+ Path to the generated config file.
180
+ """
181
+ config: dict[str, Any] = {
182
+ "dependencies": ["."],
183
+ "graphs": {
184
+ "agent": graph_ref,
185
+ },
186
+ }
187
+ if env_file:
188
+ config["env"] = env_file
189
+ if checkpointer_path:
190
+ config["checkpointer"] = {"path": checkpointer_path}
191
+
192
+ output_path = Path(output_dir) / "langgraph.json"
193
+ output_path.write_text(json.dumps(config, indent=2))
194
+ return output_path
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Scoped env-var management
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ @contextlib.contextmanager
203
+ def _scoped_env_overrides(
204
+ overrides: dict[str, str],
205
+ ) -> Iterator[None]:
206
+ """Apply env-var overrides, rolling back only on exception.
207
+
208
+ Separates the concern of temporary `os.environ` mutations from subprocess
209
+ management, making both independently testable.
210
+
211
+ On normal exit the overrides are left in place (the caller "keeps"
212
+ them). On exception the previous values are restored so the next attempt
213
+ starts from a known-good state.
214
+
215
+ Args:
216
+ overrides: Key/value pairs to set in `os.environ`.
217
+
218
+ Yields:
219
+ Control to the caller.
220
+ """
221
+ prev: dict[str, str | None] = {}
222
+ for key, val in overrides.items():
223
+ prev[key] = os.environ.get(key)
224
+ os.environ[key] = val
225
+ try:
226
+ yield
227
+ except Exception:
228
+ for key, old_val in prev.items():
229
+ if old_val is None:
230
+ os.environ.pop(key, None)
231
+ else:
232
+ os.environ[key] = old_val
233
+ raise
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Health checking
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ async def wait_for_server_healthy(
242
+ url: str,
243
+ *,
244
+ timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
245
+ process: subprocess.Popen | None = None,
246
+ read_log: Callable[[], str] | None = None,
247
+ local: bool = False,
248
+ ) -> None:
249
+ """Poll a LangGraph server health endpoint until it responds.
250
+
251
+ Args:
252
+ url: Server base URL (health endpoint is `{url}/ok`).
253
+ timeout: Max seconds to wait.
254
+ process: Optional subprocess handle; if the process exits early
255
+ we fail fast instead of waiting for the timeout.
256
+ read_log: Optional callable returning log file contents (for
257
+ error messages on early exit).
258
+ local: Use a shorter poll interval for local servers.
259
+
260
+ Raises:
261
+ RuntimeError: If the server doesn't become healthy in time.
262
+ """
263
+ import httpx
264
+
265
+ poll_interval = (
266
+ _HEALTH_POLL_INTERVAL_LOCAL if local else _HEALTH_POLL_INTERVAL_REMOTE
267
+ )
268
+ health_url = f"{url}/ok"
269
+ deadline = time.monotonic() + timeout
270
+ last_status: int | None = None
271
+ last_exc: Exception | None = None
272
+
273
+ async with httpx.AsyncClient() as client:
274
+ while time.monotonic() < deadline:
275
+ if process and process.poll() is not None:
276
+ output = read_log() if read_log else ""
277
+ msg = f"Server process exited with code {process.returncode}"
278
+ if output:
279
+ summary = _extract_startup_error_marker(output)
280
+ if summary:
281
+ msg += f": {summary}"
282
+ msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
283
+ raise RuntimeError(msg)
284
+
285
+ try:
286
+ resp = await client.get(health_url, timeout=2)
287
+ if resp.status_code == 200: # noqa: PLR2004
288
+ logger.info("Server is healthy at %s", url)
289
+ return
290
+ last_status = resp.status_code
291
+ logger.debug("Health check returned status %d", resp.status_code)
292
+ except (httpx.TransportError, OSError) as exc:
293
+ logger.debug("Health check attempt failed: %s", exc)
294
+ last_exc = exc
295
+
296
+ await asyncio.sleep(poll_interval)
297
+
298
+ msg = f"Server did not become healthy within {timeout}s"
299
+ if last_status is not None:
300
+ msg += f" (last status: {last_status})"
301
+ elif last_exc is not None:
302
+ msg += f" (last error: {last_exc})"
303
+ raise RuntimeError(msg)
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Server command / env construction
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def _build_server_cmd(config_path: Path, *, host: str, port: int) -> list[str]:
312
+ """Build the `langgraph dev` command line.
313
+
314
+ Args:
315
+ config_path: Path to the `langgraph.json` config file.
316
+ host: Host to bind.
317
+ port: Port to bind.
318
+
319
+ Returns:
320
+ Command argv list.
321
+ """
322
+ return [
323
+ sys.executable,
324
+ "-m",
325
+ "langgraph_cli",
326
+ "dev",
327
+ "--host",
328
+ host,
329
+ "--port",
330
+ str(port),
331
+ "--no-browser",
332
+ "--no-reload",
333
+ # Allow synchronous filesystem I/O on the dev server event loop. The
334
+ # agent graph factory legitimately performs blocking reads during
335
+ # construction (dotenv discovery, project-root detection, and
336
+ # `Path.resolve()`, which calls `os.getcwd()` on Windows). `langgraph
337
+ # dev` unconditionally overwrites `LANGGRAPH_ALLOW_BLOCKING` from this
338
+ # flag's value (defaulting to "false"), so setting only the env var in
339
+ # `_build_server_env` is not enough — without this flag blockbuster is
340
+ # activated and the graph readiness check fails with `BlockingError`.
341
+ "--allow-blocking",
342
+ "--config",
343
+ str(config_path),
344
+ ]
345
+
346
+
347
+ def _build_server_env() -> dict[str, str]:
348
+ """Build the environment dict for the server subprocess.
349
+
350
+ Copies `os.environ`, sets required flags, and strips variables that are not
351
+ needed or can alter subprocess startup behavior.
352
+
353
+ A launch-time `PYTHONPATH` is captured into `config._INHERITED_PYTHONPATH_ENV`
354
+ before being stripped, so the value never reaches the server interpreter's
355
+ `sys.path` but can still be re-applied to agent `execute` commands downstream.
356
+
357
+ Returns:
358
+ Environment dict for `subprocess.Popen`.
359
+ """
360
+ env = os.environ.copy()
361
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
362
+ env["LANGGRAPH_AUTH_TYPE"] = "noop"
363
+ # Allow synchronous filesystem I/O on the dev server event loop. The agent
364
+ # graph factory legitimately performs blocking reads during construction
365
+ # (dotenv discovery, project-root detection, and `Path.resolve()`, which
366
+ # calls `os.getcwd()` on Windows). Without this, langgraph dev's blockbuster
367
+ # integration raises `BlockingError` and the graph readiness check fails.
368
+ #
369
+ # This env var is set for completeness, but `langgraph dev` overwrites it
370
+ # from its own `--allow-blocking` flag on startup, so `_build_server_cmd`
371
+ # must also pass that flag for the setting to actually take effect.
372
+ env["LANGGRAPH_ALLOW_BLOCKING"] = "true"
373
+
374
+ # Capture a launch-time PYTHONPATH before stripping it. Never trust an
375
+ # inherited carrier var: pop it first, then set it only from the real value.
376
+ env.pop(_INHERITED_PYTHONPATH_ENV, None)
377
+ inherited_pythonpath = os.environ.get("PYTHONPATH")
378
+
379
+ for key in (
380
+ "LANGGRAPH_AUTH",
381
+ "LANGGRAPH_CLOUD_LICENSE_KEY",
382
+ "LANGSMITH_CONTROL_PLANE_API_KEY",
383
+ "LANGSMITH_TENANT_ID",
384
+ *_SERVER_ENV_DENYLIST,
385
+ ):
386
+ env.pop(key, None)
387
+
388
+ if inherited_pythonpath is not None:
389
+ env[_INHERITED_PYTHONPATH_ENV] = inherited_pythonpath
390
+ return env
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Process-group teardown
395
+ # ---------------------------------------------------------------------------
396
+
397
+
398
+ def _server_process_group(pid: int) -> int | None:
399
+ """Return the server's own process group id to signal, or `None`.
400
+
401
+ The server is spawned with `start_new_session=True` on POSIX, so it leads
402
+ its own session and process group (its pgid equals its pid). Signaling that
403
+ group reaches the whole `langgraph dev` process tree, so descendants receive
404
+ the same shutdown signals as the root rather than being left running when
405
+ only the root is signaled.
406
+
407
+ Returns `None` — meaning "signal only the root process" — on Windows (no
408
+ POSIX process groups) and whenever the server is not the leader of its own
409
+ dedicated group. As a defensive check, the `pgid == os.getpgid(0)` clause
410
+ also refuses to return dcode's own group, so the group handed back can never
411
+ be the one whose termination would take down the TUI.
412
+
413
+ Args:
414
+ pid: Process id of the server subprocess.
415
+
416
+ Returns:
417
+ The server's dedicated process group id, or `None` to fall back to
418
+ signaling just the root process.
419
+ """
420
+ if sys.platform == "win32":
421
+ return None
422
+ try:
423
+ pgid = os.getpgid(pid)
424
+ own_pgid = os.getpgid(0)
425
+ except ProcessLookupError:
426
+ # The process already exited; there is no group left to signal.
427
+ return None
428
+ except OSError:
429
+ # Resolving the group failed unexpectedly (getpgid on an owned child
430
+ # should not). Fall back to root-only signaling, but surface it so a
431
+ # silently orphaned descendant tree is diagnosable rather than invisible.
432
+ logger.warning(
433
+ "Could not resolve process group for pid=%d; "
434
+ "falling back to root-only signaling",
435
+ pid,
436
+ exc_info=True,
437
+ )
438
+ return None
439
+ if pgid != pid or pgid == own_pgid:
440
+ return None
441
+ return pgid
442
+
443
+
444
+ def _wait_for_process_group_exit(
445
+ process: subprocess.Popen[Any], pgid: int, timeout: float
446
+ ) -> bool:
447
+ """Wait until every process in a POSIX process group has exited.
448
+
449
+ `Popen.wait()` only observes the group leader. Poll it on every pass so an
450
+ exited leader does not remain a zombie and keep the group probe alive, then
451
+ continue probing because descendants may remain after the leader exits.
452
+
453
+ Args:
454
+ process: The group leader, reaped as soon as it exits.
455
+ pgid: Process group id to probe.
456
+ timeout: Maximum seconds to wait for the whole group.
457
+
458
+ Returns:
459
+ `True` when the group is gone, or `False` on timeout.
460
+ """
461
+ deadline = time.monotonic() + timeout
462
+ while True:
463
+ # `poll()` reaps an exited leader without blocking. Until that happens,
464
+ # its zombie entry keeps `killpg(..., 0)` reporting the group as alive.
465
+ process.poll()
466
+ try:
467
+ os.killpg(pgid, 0)
468
+ except ProcessLookupError:
469
+ process.wait()
470
+ return True
471
+ except PermissionError:
472
+ # The group still exists even if the probe is not permitted.
473
+ pass
474
+
475
+ remaining = deadline - time.monotonic()
476
+ if remaining <= 0:
477
+ return False
478
+ time.sleep(min(_PROCESS_GROUP_POLL_INTERVAL, remaining))
479
+
480
+
481
+ def _terminate_server_process(process: subprocess.Popen[Any]) -> None:
482
+ """Terminate the `langgraph dev` server and its descendants.
483
+
484
+ Sends SIGTERM, waits `_SHUTDOWN_TIMEOUT` for a graceful exit, then escalates
485
+ to SIGKILL. On POSIX the whole detached process group is signaled via
486
+ `os.killpg`, and teardown waits for the entire group to exit — not just the
487
+ root — so a child that outlives the `langgraph dev` root is still escalated
488
+ to SIGKILL rather than orphaned. On Windows (or if the server is not its own
489
+ group leader) only the root process is signaled. `_server_process_group`
490
+ guarantees dcode's own process group is never targeted.
491
+
492
+ Args:
493
+ process: The running server subprocess to terminate.
494
+ """
495
+ pid = process.pid
496
+ pgid = _server_process_group(pid)
497
+ scope = "process group" if pgid is not None else "process"
498
+
499
+ logger.info("Stopping langgraph dev server (pid=%d)", pid)
500
+ try:
501
+ if pgid is not None:
502
+ os.killpg(pgid, signal.SIGTERM)
503
+ stopped = _wait_for_process_group_exit(process, pgid, _SHUTDOWN_TIMEOUT)
504
+ else:
505
+ process.send_signal(signal.SIGTERM)
506
+ try:
507
+ process.wait(timeout=_SHUTDOWN_TIMEOUT)
508
+ except subprocess.TimeoutExpired:
509
+ stopped = False
510
+ else:
511
+ stopped = True
512
+ except ProcessLookupError:
513
+ # The server exited before the SIGTERM landed; nothing left to reap.
514
+ logger.debug("Server %s pid=%d already exited before SIGTERM", scope, pid)
515
+ return
516
+ except OSError:
517
+ # SIGTERM could not be delivered (e.g. EPERM). We never reach the SIGKILL
518
+ # escalation, so the server is left running — report it with the same
519
+ # fidelity as a failed SIGKILL rather than a bare "error stopping".
520
+ logger.exception(
521
+ "Failed to signal server %s pid=%d; it may be orphaned", scope, pid
522
+ )
523
+ return
524
+
525
+ if stopped:
526
+ return
527
+
528
+ logger.warning("Server did not stop gracefully, killing %s", scope)
529
+ # Guard escalation explicitly: `ProcessLookupError` means the group exited
530
+ # just before SIGKILL, while any other `OSError` means it may be orphaned.
531
+ try:
532
+ if pgid is not None:
533
+ os.killpg(pgid, signal.SIGKILL)
534
+ if not _wait_for_process_group_exit(process, pgid, _SIGKILL_TIMEOUT):
535
+ logger.warning(
536
+ "Server %s pid=%d did not exit after SIGKILL", scope, pid
537
+ )
538
+ else:
539
+ process.kill()
540
+ try:
541
+ process.wait(timeout=_SIGKILL_TIMEOUT)
542
+ except subprocess.TimeoutExpired:
543
+ logger.warning(
544
+ "Server %s pid=%d did not exit after SIGKILL", scope, pid
545
+ )
546
+ except ProcessLookupError:
547
+ logger.debug("Server %s pid=%d already exited before SIGKILL", scope, pid)
548
+ except OSError:
549
+ logger.exception(
550
+ "Failed to SIGKILL server %s pid=%d; it may be orphaned",
551
+ scope,
552
+ pid,
553
+ )
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # ServerProcess
558
+ # ---------------------------------------------------------------------------
559
+
560
+
561
+ class ServerProcess:
562
+ """Manages a `langgraph dev` server subprocess.
563
+
564
+ Focuses on subprocess lifecycle (start, stop, restart) and health checking.
565
+ Env-var management for restarts (e.g. configuration changes requiring a full
566
+ restart) is handled by `_scoped_env_overrides`, keeping this class focused
567
+ on process management.
568
+ """
569
+
570
+ def __init__(
571
+ self,
572
+ *,
573
+ host: str = _DEFAULT_HOST,
574
+ port: int = _EPHEMERAL_PORT,
575
+ config_dir: str | Path | None = None,
576
+ owns_config_dir: bool = False,
577
+ scaffold: Callable[[Path], None] | None = None,
578
+ ) -> None:
579
+ """Initialize server process manager.
580
+
581
+ Args:
582
+ host: Host to bind the server to.
583
+ port: Initial port to bind the server to. Defaults to
584
+ `_EPHEMERAL_PORT` (0), so `start()` picks a free port and avoids
585
+ squatting the well-known `langgraph dev` default (2024).
586
+
587
+ An explicit port is honored, but `start()` still falls back to a
588
+ free port if it is already in use.
589
+ config_dir: Directory containing `langgraph.json`.
590
+ owns_config_dir: When `True`, the server will delete `config_dir`
591
+ on `stop()`.
592
+ scaffold: Optional callable that (re)generates the working
593
+ directory's `langgraph.json` and supporting files. When the
594
+ config is missing at `start()` (e.g. the temp dir was purged
595
+ between the initial boot and a later `/restart`), it is invoked
596
+ to rebuild the workspace instead of failing.
597
+ """
598
+ self.host = host
599
+ self.port = port
600
+ self.config_dir = Path(config_dir) if config_dir else None
601
+ self._owns_config_dir = owns_config_dir
602
+ self._scaffold = scaffold
603
+ self._process: subprocess.Popen | None = None
604
+ self._temp_dir: tempfile.TemporaryDirectory | None = None
605
+ self._log_file: tempfile.NamedTemporaryFile | None = None # ty: ignore[invalid-type-form]
606
+ self._env_overrides: dict[str, str] = {}
607
+ self._persistent_env_overrides: dict[str, str] = {}
608
+
609
+ @property
610
+ def url(self) -> str:
611
+ """Server base URL."""
612
+ return get_server_url(self.host, self.port)
613
+
614
+ @property
615
+ def running(self) -> bool:
616
+ """Whether the server process is running."""
617
+ return self._process is not None and self._process.poll() is None
618
+
619
+ def _read_log_file(self) -> str:
620
+ """Read the server log file contents.
621
+
622
+ Returns:
623
+ Log file contents as a string (may be empty).
624
+ """
625
+ if self._log_file is None:
626
+ return ""
627
+ try:
628
+ self._log_file.flush()
629
+ return Path(self._log_file.name).read_text(
630
+ encoding="utf-8", errors="replace"
631
+ )
632
+ except OSError:
633
+ logger.warning(
634
+ "Failed to read server log file %s",
635
+ self._log_file.name,
636
+ exc_info=True,
637
+ )
638
+ return ""
639
+
640
+ async def start(
641
+ self,
642
+ *,
643
+ timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
644
+ ) -> None:
645
+ """Start the `langgraph dev` server and wait for it to be healthy.
646
+
647
+ Args:
648
+ timeout: Max seconds to wait for the server to become healthy.
649
+
650
+ Raises:
651
+ RuntimeError: If the server fails to start or become healthy.
652
+ """
653
+ if self.running:
654
+ return
655
+
656
+ work_dir = self.config_dir
657
+ if work_dir is None:
658
+ self._temp_dir = tempfile.TemporaryDirectory(prefix="deepagents_server_")
659
+ work_dir = Path(self._temp_dir.name)
660
+
661
+ config_path = work_dir / "langgraph.json"
662
+ if not config_path.exists() and self._scaffold is not None:
663
+ # The config can vanish between the initial boot and a later
664
+ # `/restart` (e.g. the OS tmp reaper purging the temp work dir).
665
+ # Rebuild it rather than failing the restart.
666
+ logger.info("langgraph.json missing in %s; rescaffolding", work_dir)
667
+ try:
668
+ work_dir.mkdir(parents=True, exist_ok=True)
669
+ self._scaffold(work_dir)
670
+ except OSError as exc:
671
+ # Surface the failure with restart context instead of letting a
672
+ # bare OSError (e.g. ENOSPC/EACCES on a degraded temp fs) escape
673
+ # stripped of the recovery framing. Chained so the root cause
674
+ # stays in the traceback.
675
+ msg = f"Failed to rescaffold server workspace at {work_dir}: {exc}"
676
+ raise RuntimeError(msg) from exc
677
+ if not config_path.exists():
678
+ if self._scaffold is not None:
679
+ # The scaffold hook ran but produced no langgraph.json (a silent
680
+ # no-op or a write to the wrong path). The "call
681
+ # generate_langgraph_json() first" advice below would misdirect,
682
+ # since the scaffold is exactly that call run internally.
683
+ contents = sorted(p.name for p in work_dir.iterdir())
684
+ msg = (
685
+ f"Rescaffolding {work_dir} did not produce langgraph.json "
686
+ f"(directory contents: {contents})."
687
+ )
688
+ else:
689
+ msg = (
690
+ f"langgraph.json not found in {work_dir}. "
691
+ "Call generate_langgraph_json() first."
692
+ )
693
+ raise RuntimeError(msg)
694
+
695
+ if self.port == _EPHEMERAL_PORT:
696
+ self.port = _find_free_port(self.host)
697
+ logger.info("Using ephemeral port %d for langgraph dev server", self.port)
698
+ elif _port_in_use(self.host, self.port):
699
+ self.port = _find_free_port(self.host)
700
+ logger.info("Requested port in use, using port %d instead", self.port)
701
+
702
+ cmd = _build_server_cmd(config_path, host=self.host, port=self.port)
703
+ env = _build_server_env()
704
+ # Persisted overrides are defaults; a one-shot override staged via
705
+ # `update_env()` for THIS restart must win over them. `_env_overrides`
706
+ # is already reflected in the `os.environ` copy above (applied by
707
+ # `_scoped_env_overrides`), but persisted values would otherwise shadow
708
+ # a freshly staged value, so re-apply the one-shot set last.
709
+ env.update(self._persistent_env_overrides)
710
+ env.update(self._env_overrides)
711
+
712
+ logger.info("Starting langgraph dev server: %s", " ".join(cmd))
713
+ self._log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
714
+ prefix="deepagents_server_log_",
715
+ suffix=".txt",
716
+ delete=False,
717
+ mode="w",
718
+ encoding="utf-8",
719
+ )
720
+ self._process = subprocess.Popen( # noqa: S603, ASYNC220
721
+ cmd,
722
+ cwd=str(work_dir),
723
+ env=env,
724
+ stdout=self._log_file,
725
+ stderr=subprocess.STDOUT,
726
+ # Detach the server from dcode's controlling terminal and process
727
+ # group on POSIX. Without a new session the server inherits dcode's
728
+ # session/process group and receives the same terminal-generated
729
+ # signals as the TUI: the SIGTSTP job-control stop when the user
730
+ # suspends dcode (Ctrl+Z), and the SIGHUP hangup when the host
731
+ # terminal is closed — suspending or hanging up the server alongside
732
+ # dcode. `start_new_session=True` runs
733
+ # `setsid()` so the server leads its own session/process group
734
+ # instead. `start_new_session` is ignored on Windows, so it is left
735
+ # off there.
736
+ start_new_session=(sys.platform != "win32"),
737
+ )
738
+
739
+ started = False
740
+ try:
741
+ await wait_for_server_healthy(
742
+ self.url,
743
+ timeout=timeout,
744
+ process=self._process,
745
+ read_log=self._read_log_file,
746
+ local=True,
747
+ )
748
+ started = True
749
+ finally:
750
+ if not started:
751
+ # Reap the subprocess we just spawned if startup did not
752
+ # complete — including cancellation (e.g. Ctrl+D / SIGINT before
753
+ # the health check returns). A `finally` rather than `except
754
+ # Exception` is deliberate: `asyncio.CancelledError` is a
755
+ # `BaseException`, so an `except Exception` guard would skip this
756
+ # and orphan the process. The inner guard stops a `stop()` error
757
+ # from masking the exception already propagating; `stop()` is
758
+ # effectively non-raising today, so if it does fire it signals an
759
+ # unexpected leak — hence `error`, not `warning`.
760
+ try:
761
+ self.stop()
762
+ except Exception:
763
+ logger.exception(
764
+ "Error stopping server during startup cleanup",
765
+ )
766
+
767
+ async def wait_for_graph_ready(
768
+ self,
769
+ graph_name: str = "agent",
770
+ *,
771
+ timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
772
+ ) -> None:
773
+ """Resolve the served graph once so lazy startup failures surface early.
774
+
775
+ Args:
776
+ graph_name: Registered graph name from `langgraph.json`.
777
+ timeout: Max seconds to wait for the graph readiness request.
778
+
779
+ Raises:
780
+ RuntimeError: If the server process exits or the graph endpoint
781
+ does not return a successful response.
782
+ """
783
+ import httpx
784
+
785
+ if self._process is None:
786
+ msg = "Server process is not running"
787
+ raise RuntimeError(msg)
788
+
789
+ graph_url = f"{self.url}/assistants/{quote(graph_name, safe='')}/graph"
790
+ deadline = time.monotonic() + timeout
791
+
792
+ async with httpx.AsyncClient() as client:
793
+ while time.monotonic() < deadline:
794
+ if self._process.poll() is not None:
795
+ msg = f"Server process exited with code {self._process.returncode}"
796
+ output = self._read_log_file()
797
+ if output:
798
+ summary = _extract_startup_error_marker(output)
799
+ if summary:
800
+ msg += f": {summary}"
801
+ msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
802
+ raise RuntimeError(msg)
803
+
804
+ remaining = max(0.1, deadline - time.monotonic())
805
+ try:
806
+ resp = await client.get(graph_url, timeout=remaining)
807
+ except (httpx.TransportError, httpx.TimeoutException, OSError) as exc:
808
+ output = self._read_log_file()
809
+ summary = _extract_startup_error_marker(output)
810
+ if self._process.poll() is not None:
811
+ msg = (
812
+ f"Server process exited with code "
813
+ f"{self._process.returncode}"
814
+ )
815
+ else:
816
+ msg = (
817
+ f"Server graph '{graph_name}' did not initialize within "
818
+ f"{timeout}s"
819
+ )
820
+ if summary:
821
+ msg += f": {summary}"
822
+ if output:
823
+ msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
824
+ raise RuntimeError(msg) from exc
825
+
826
+ if resp.status_code == 200: # noqa: PLR2004
827
+ logger.info("Server graph %s is ready at %s", graph_name, self.url)
828
+ return
829
+
830
+ output = self._read_log_file()
831
+ msg = (
832
+ f"Server graph '{graph_name}' failed readiness check "
833
+ f"(status: {resp.status_code})"
834
+ )
835
+ summary = _extract_startup_error_marker(output)
836
+ if summary:
837
+ msg += f": {summary}"
838
+ if output:
839
+ msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
840
+ raise RuntimeError(msg)
841
+
842
+ msg = f"Server graph '{graph_name}' did not initialize within {timeout}s"
843
+ raise RuntimeError(msg)
844
+
845
+ def _stop_process(self) -> None:
846
+ """Stop only the server subprocess and its log file.
847
+
848
+ Unlike `stop()`, this does NOT clean up the config directory or temp
849
+ directory, so the server can be restarted with the same config.
850
+ """
851
+ if self._process is None:
852
+ return
853
+
854
+ if self._process.poll() is None:
855
+ _terminate_server_process(self._process)
856
+ # `_terminate_server_process` is best-effort. If the process is still
857
+ # alive here (e.g. SIGKILL failed with EPERM), then once we drop the
858
+ # handle below we can no longer observe or reap this pid, so surface
859
+ # the still-running process rather than clearing state as if shutdown
860
+ # succeeded.
861
+ if self._process.poll() is None:
862
+ logger.warning(
863
+ "Dropping handle to server pid=%d that is still running; "
864
+ "it may be orphaned",
865
+ self._process.pid,
866
+ )
867
+
868
+ self._process = None
869
+
870
+ if self._log_file is not None:
871
+ log_path = Path(self._log_file.name)
872
+ try:
873
+ self._log_file.close()
874
+ except OSError:
875
+ logger.debug("Failed to close log file", exc_info=True)
876
+
877
+ from deepagents_code._env_vars import DEBUG, is_env_truthy
878
+
879
+ if is_env_truthy(DEBUG):
880
+ print( # noqa: T201
881
+ f"Server log preserved at: {log_path}",
882
+ file=sys.stderr,
883
+ )
884
+ else:
885
+ try:
886
+ log_path.unlink()
887
+ except OSError:
888
+ logger.debug("Failed to clean up log file", exc_info=True)
889
+ self._log_file = None
890
+
891
+ def stop(self) -> None:
892
+ """Stop the server process and clean up all resources."""
893
+ self._stop_process()
894
+
895
+ if self._temp_dir is not None:
896
+ try:
897
+ self._temp_dir.cleanup()
898
+ except OSError:
899
+ logger.debug("Failed to clean up temp dir", exc_info=True)
900
+ self._temp_dir = None
901
+
902
+ if self._owns_config_dir and self.config_dir is not None:
903
+ import shutil
904
+
905
+ try:
906
+ shutil.rmtree(self.config_dir)
907
+ except OSError:
908
+ logger.debug(
909
+ "Failed to clean up config dir %s", self.config_dir, exc_info=True
910
+ )
911
+ self._owns_config_dir = False
912
+
913
+ def update_env(self, **overrides: str) -> None:
914
+ """Stage env var overrides to apply on the next `restart()`.
915
+
916
+ These are applied to `os.environ` immediately before the subprocess
917
+ starts, keeping mutation scoped to the restart call.
918
+
919
+ Args:
920
+ **overrides: Key/value env var pairs
921
+ (e.g., `DEEPAGENTS_CODE_SERVER_MODEL="anthropic:claude-sonnet-4-6"`).
922
+ """
923
+ self._env_overrides.update(overrides)
924
+
925
+ def persist_env(self, **overrides: str) -> None:
926
+ """Persist env var overrides for every future subprocess start.
927
+
928
+ Args:
929
+ **overrides: Key/value env var pairs that should be passed to all
930
+ future server subprocesses.
931
+
932
+ Raises:
933
+ ValueError: If an override is not an app-owned server env var.
934
+ """
935
+ invalid = [key for key in overrides if not key.startswith(SERVER_ENV_PREFIX)]
936
+ if invalid:
937
+ msg = (
938
+ "persistent server env overrides must use the "
939
+ f"{SERVER_ENV_PREFIX!r} prefix"
940
+ )
941
+ raise ValueError(msg)
942
+ self._persistent_env_overrides.update(overrides)
943
+
944
+ async def restart(self, *, timeout: float = _HEALTH_TIMEOUT) -> None: # noqa: ASYNC109
945
+ """Restart the server process, reusing the existing config directory.
946
+
947
+ Stops the subprocess, then starts a new one. Any env overrides staged
948
+ via `update_env()` are applied within a `_scoped_env_overrides` context
949
+ manager so that failures automatically roll back the environment to the
950
+ last known-good state.
951
+
952
+ Args:
953
+ timeout: Max seconds to wait for the server to become healthy.
954
+ """
955
+ logger.info("Restarting langgraph dev server")
956
+ # Offload the synchronous subprocess shutdown (it blocks up to
957
+ # `_SHUTDOWN_TIMEOUT` + SIGKILL grace waiting on `process.wait`) so the
958
+ # caller's event loop — the Textual reactor for `/restart` — keeps
959
+ # processing input instead of freezing the TUI.
960
+ await asyncio.to_thread(self._stop_process)
961
+
962
+ with _scoped_env_overrides(self._env_overrides):
963
+ await self.start(timeout=timeout)
964
+
965
+ self._env_overrides.clear()
966
+
967
+ async def __aenter__(self) -> Self:
968
+ """Async context manager entry.
969
+
970
+ Returns:
971
+ The server process instance.
972
+ """
973
+ await self.start()
974
+ return self
975
+
976
+ async def __aexit__(self, *args: object) -> None:
977
+ """Async context manager exit."""
978
+ self.stop()