deepagents-code 0.2.0__py3-none-any.whl → 0.2.2__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 (74) hide show
  1. deepagents_code/_build_info.py +1 -1
  2. deepagents_code/_checkpointer_adapter.py +86 -0
  3. deepagents_code/_env_vars.py +29 -3
  4. deepagents_code/_git.py +8 -2
  5. deepagents_code/_js_cost.py +15 -0
  6. deepagents_code/_langsmith_runtime.py +91 -0
  7. deepagents_code/_run_queue_wakeup.py +156 -0
  8. deepagents_code/_session_lock.py +184 -0
  9. deepagents_code/_textual_patches.py +5 -0
  10. deepagents_code/_trace_redaction.py +145 -0
  11. deepagents_code/_version.py +1 -1
  12. deepagents_code/agent.py +40 -5
  13. deepagents_code/app.py +779 -176
  14. deepagents_code/app.tcss +1 -1
  15. deepagents_code/auto_mode.py +192 -16
  16. deepagents_code/btw.py +176 -37
  17. deepagents_code/btw_api.py +80 -1
  18. deepagents_code/built_in_skills/deepagents-plugin-discovery/SKILL.md +3 -1
  19. deepagents_code/client/launch/_server_bootstrap.py +11 -1
  20. deepagents_code/client/launch/server.py +62 -15
  21. deepagents_code/client/launch/server_manager.py +97 -4
  22. deepagents_code/client/non_interactive.py +39 -30
  23. deepagents_code/client/remote_client.py +44 -0
  24. deepagents_code/config.py +137 -11
  25. deepagents_code/config_manifest.py +13 -1
  26. deepagents_code/goal_tools.py +25 -9
  27. deepagents_code/hooks/models/domain.py +6 -1
  28. deepagents_code/hooks/transcript.py +24 -5
  29. deepagents_code/integrations/sandbox_factory.py +69 -2
  30. deepagents_code/local_context.py +85 -20
  31. deepagents_code/main.py +165 -24
  32. deepagents_code/mcp_tools.py +11 -0
  33. deepagents_code/memory_guard.py +4 -0
  34. deepagents_code/model_config.py +88 -14
  35. deepagents_code/offload_api.py +84 -6
  36. deepagents_code/plugins/__init__.py +4 -0
  37. deepagents_code/plugins/adapters/mcp.py +2 -4
  38. deepagents_code/plugins/adapters/skills.py +13 -9
  39. deepagents_code/plugins/adapters/skills_middleware.py +4 -0
  40. deepagents_code/plugins/commands_cli.py +127 -4
  41. deepagents_code/plugins/discovery.py +431 -141
  42. deepagents_code/plugins/models.py +55 -1
  43. deepagents_code/plugins/store.py +40 -1
  44. deepagents_code/server_graph.py +2 -0
  45. deepagents_code/sessions.py +8 -2
  46. deepagents_code/thread_ownership.py +59 -15
  47. deepagents_code/tui/modals/plugin_manager/__init__.py +57 -0
  48. deepagents_code/tui/modals/plugin_manager/content.py +41 -0
  49. deepagents_code/tui/modals/plugin_manager/models.py +8 -1
  50. deepagents_code/tui/modals/plugin_manager/state.py +25 -2
  51. deepagents_code/tui/widgets/_links.py +43 -2
  52. deepagents_code/tui/widgets/_static_update.py +35 -0
  53. deepagents_code/tui/widgets/autocomplete.py +185 -57
  54. deepagents_code/tui/widgets/chat_input.py +43 -1
  55. deepagents_code/tui/widgets/final_markdown/__init__.py +18 -0
  56. deepagents_code/tui/widgets/final_markdown/_layout.py +287 -0
  57. deepagents_code/tui/widgets/final_markdown/_parse.py +158 -0
  58. deepagents_code/tui/widgets/final_markdown/_rows.py +126 -0
  59. deepagents_code/tui/widgets/final_markdown/_table.py +136 -0
  60. deepagents_code/tui/widgets/final_markdown/_widget.py +329 -0
  61. deepagents_code/tui/widgets/loading.py +5 -3
  62. deepagents_code/tui/widgets/message_store.py +93 -9
  63. deepagents_code/tui/widgets/messages.py +179 -67
  64. deepagents_code/tui/widgets/selection_action.py +131 -0
  65. deepagents_code/tui/widgets/thread_selector.py +218 -49
  66. deepagents_code/ui.py +3 -0
  67. deepagents_code/update_check.py +30 -5
  68. deepagents_code/workspace_diagnostics.py +7 -2
  69. {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/METADATA +22 -22
  70. {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/RECORD +74 -61
  71. {deepagents_code-0.2.0.data → deepagents_code-0.2.2.data}/data/deepagents_code/default_agent_prompt.md +0 -0
  72. {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/WHEEL +0 -0
  73. {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/entry_points.txt +0 -0
  74. {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,3 @@
1
1
  """Generated at build time. Do not edit or commit."""
2
2
 
3
- BUILD_COMMIT = "981a87a"
3
+ BUILD_COMMIT = "42e4f08"
@@ -0,0 +1,86 @@
1
+ """Forward delta-channel history reads through langgraph-api's saver adapter.
2
+
3
+ The LangGraph dev server wraps a custom checkpointer in
4
+ `langgraph_api._checkpointer._adapter._CustomCheckpointerAdapter`. The adapter
5
+ forwards unknown attributes to the inner saver through `__getattr__`, but
6
+ `BaseCheckpointSaver` already defines `aget_delta_channel_history`, so the
7
+ forwarding never fires. The adapter then runs the base ancestor walk: one
8
+ `aget_tuple` and one full checkpoint decode per ancestor. The SQLite saver
9
+ overrides the method with two indexed queries, and long threads pay the
10
+ difference on every state read and every run start.
11
+
12
+ This module is a best-effort patch over a private langgraph-api class. It only
13
+ patches when the adapter still inherits the base method, and it falls back to
14
+ the base walk when the inner saver has no override of its own. Remove it when
15
+ the adapter forwards these methods itself.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import TYPE_CHECKING
22
+
23
+ from langgraph.checkpoint.base import BaseCheckpointSaver
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping, Sequence
27
+
28
+ from langchain_core.runnables import RunnableConfig
29
+ from langgraph.checkpoint.base import DeltaChannelHistory
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _BASE_ASYNC = BaseCheckpointSaver.aget_delta_channel_history
34
+ _BASE_SYNC = BaseCheckpointSaver.get_delta_channel_history
35
+
36
+
37
+ def _overriding_inner(
38
+ adapter: BaseCheckpointSaver, name: str
39
+ ) -> BaseCheckpointSaver | None:
40
+ """Return the adapter's inner saver if it overrides the base method `name`."""
41
+ inner = adapter.__dict__.get("_inner")
42
+ if not isinstance(inner, BaseCheckpointSaver):
43
+ return None
44
+ if getattr(type(inner), name) is getattr(BaseCheckpointSaver, name):
45
+ return None
46
+ return inner
47
+
48
+
49
+ async def _aget_delta_channel_history(
50
+ self: BaseCheckpointSaver, *, config: RunnableConfig, channels: Sequence[str]
51
+ ) -> Mapping[str, DeltaChannelHistory]:
52
+ inner = _overriding_inner(self, "aget_delta_channel_history")
53
+ if inner is None:
54
+ return await _BASE_ASYNC(self, config=config, channels=channels)
55
+ return await inner.aget_delta_channel_history(config=config, channels=channels)
56
+
57
+
58
+ def _get_delta_channel_history(
59
+ self: BaseCheckpointSaver, *, config: RunnableConfig, channels: Sequence[str]
60
+ ) -> Mapping[str, DeltaChannelHistory]:
61
+ inner = _overriding_inner(self, "get_delta_channel_history")
62
+ if inner is None:
63
+ return _BASE_SYNC(self, config=config, channels=channels)
64
+ return inner.get_delta_channel_history(config=config, channels=channels)
65
+
66
+
67
+ def install_delta_history_forwarding() -> None:
68
+ """Make langgraph-api's checkpointer adapter use the inner saver's fast path.
69
+
70
+ Call this in the server process before the adapter serves requests. It is
71
+ idempotent and a no-op when the adapter is missing or already overrides
72
+ the methods.
73
+ """
74
+ try:
75
+ # The adapter has no public seam; this patch exists to reach it.
76
+ from langgraph_api._checkpointer._adapter import ( # noqa: PLC2701
77
+ _CustomCheckpointerAdapter,
78
+ )
79
+ except ImportError:
80
+ logger.warning("langgraph-api checkpointer adapter not found; skipping patch")
81
+ return
82
+ adapter = _CustomCheckpointerAdapter
83
+ if adapter.aget_delta_channel_history is _BASE_ASYNC:
84
+ adapter.aget_delta_channel_history = _aget_delta_channel_history
85
+ if adapter.get_delta_channel_history is _BASE_SYNC:
86
+ adapter.get_delta_channel_history = _get_delta_channel_history
@@ -77,6 +77,15 @@ Values outside 1-900 seconds are ignored in favor of the next config source.
77
77
  Also settable via `[mcp].tool_timeout` in config.toml.
78
78
  """
79
79
 
80
+ ASK_ABOUT_SELECTION = "DEEPAGENTS_CODE_ASK_ABOUT_SELECTION"
81
+ """Show "Ask about this" when conversation text is selected.
82
+
83
+ Enabled by default; set to a falsy value (`0`, `false`, `no`, `off`) to hide
84
+ it. Parsed by `classify_env_bool` (an unrecognized or empty value falls through
85
+ to the config value rather than forcing the default). Also settable via
86
+ `[ui].ask_about_selection` in config.toml. `/reload` applies a change.
87
+ """
88
+
80
89
  AUTO_UPDATE = "DEEPAGENTS_CODE_AUTO_UPDATE"
81
90
  """Toggle automatic app updates. Enabled by default; set to a falsy value
82
91
  ('0', 'false', 'no', 'off', or empty) to opt out."""
@@ -277,13 +286,20 @@ EXPERIMENTAL = "DEEPAGENTS_CODE_EXPERIMENTAL"
277
286
  Off by default; parsed by `is_env_truthy` (see there for the accepted truthy
278
287
  values). Marks experimental runs in UI/trace metadata. Behavior behind this
279
288
  flag may change or be removed without notice.
289
+
290
+ Set only through the launching shell or global `~/.deepagents/.env`; a project
291
+ `.env` cannot set it.
280
292
  """
281
293
 
282
294
  EXTENSIONS = "DEEPAGENTS_CODE_EXTENSIONS"
283
295
  """Enable loading installed-plugin and trusted-project Python extensions."""
284
296
 
285
297
  EXTENSIONS_TRUST = "DEEPAGENTS_CODE_EXTENSIONS_TRUST"
286
- """Default project extension trust policy: `ask`, `always`, or `never`."""
298
+ """Default project extension trust policy: `ask`, `always`, or `never`.
299
+
300
+ Set only through the launching shell or global `~/.deepagents/.env`; a project
301
+ `.env` cannot set it.
302
+ """
287
303
 
288
304
  EXTERNAL_EVENT_SOCKET = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET"
289
305
  """Enable the local Unix-socket external event listener.
@@ -296,7 +312,11 @@ EXTERNAL_EVENT_SOCKET_PATH = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET_PATH"
296
312
  """Override the default Unix-socket path for the external event listener."""
297
313
 
298
314
  EXTRA_SKILLS_DIRS = "DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS"
299
- """Paths added to the skill containment allowlist, split with `os.pathsep`."""
315
+ """Paths added to the skill containment allowlist, split with `os.pathsep`.
316
+
317
+ Set only through the launching shell or global `~/.deepagents/.env`; a project
318
+ `.env` cannot set it.
319
+ """
300
320
 
301
321
  GOAL_AUTO_ACCEPT_CRITERIA = "DEEPAGENTS_CODE_GOAL_AUTO_ACCEPT_CRITERIA"
302
322
  """Apply generated goal criteria automatically in Auto mode.
@@ -485,6 +505,8 @@ PLUGIN_CACHE_DIR = "DEEPAGENTS_CODE_PLUGIN_CACHE_DIR"
485
505
  """Override the plugin install/marketplace cache root.
486
506
 
487
507
  When unset, plugins are stored under `DEFAULT_CONFIG_DIR / "plugins"`.
508
+ Set only through the launching shell or global `~/.deepagents/.env`; a project
509
+ `.env` cannot set it.
488
510
  """
489
511
 
490
512
  PRICES_AUTO_UPDATE = "DEEPAGENTS_CODE_PRICES_AUTO_UPDATE"
@@ -564,7 +586,11 @@ SERVER_ENV_PREFIX = "DEEPAGENTS_CODE_SERVER_"
564
586
  """Environment variable prefix used to pass CLI config to the server subprocess."""
565
587
 
566
588
  SHELL_ALLOW_LIST = "DEEPAGENTS_CODE_SHELL_ALLOW_LIST"
567
- """Comma-separated shell commands to allow (or 'recommended'/'all')."""
589
+ """Comma-separated shell commands to allow (or 'recommended'/'all').
590
+
591
+ Set only through the launching shell or global `~/.deepagents/.env`; a project
592
+ `.env` cannot set it.
593
+ """
568
594
 
569
595
  SHOW_HEADER = "DEEPAGENTS_CODE_SHOW_HEADER"
570
596
  """Show Textual's native header bar at the top of the TUI when enabled."""
deepagents_code/_git.py CHANGED
@@ -17,6 +17,9 @@ _GIT_DIR_POINTER_PREFIX = "gitdir: "
17
17
  _GIT_HEAD_REF_PREFIX = "ref: "
18
18
  """Prefix used by `HEAD` when it points at a named ref instead of a commit."""
19
19
 
20
+ _REFTABLE_HEAD_PLACEHOLDER = "refs/heads/.invalid"
21
+ """Ref a reftable repository writes to `HEAD`; the real refs live in `reftable/`."""
22
+
20
23
  _GIT_REF_PREFIXES = ("refs/heads/", "refs/remotes/", "refs/tags/", "refs/")
21
24
  """Known git ref prefixes stripped when formatting a branch-like display name."""
22
25
 
@@ -411,7 +414,8 @@ def read_git_branch_from_filesystem(path: str | Path) -> str | None:
411
414
  Returns:
412
415
  The abbreviated branch name, `HEAD` for detached HEAD, an empty string
413
416
  when `path` is not inside a git repository, or `None` when metadata
414
- exists but cannot be parsed confidently.
417
+ exists but cannot be parsed confidently (including reftable
418
+ repositories, whose `HEAD` file only holds a placeholder).
415
419
  """
416
420
  git_dir = find_git_dir(path)
417
421
  if git_dir is None:
@@ -431,7 +435,9 @@ def read_git_branch_from_filesystem(path: str | Path) -> str | None:
431
435
  return ""
432
436
  if head.startswith(_GIT_HEAD_REF_PREFIX):
433
437
  ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
434
- return _abbreviate_git_ref(ref) if ref else None
438
+ if not ref or ref == _REFTABLE_HEAD_PLACEHOLDER:
439
+ return None
440
+ return _abbreviate_git_ref(ref)
435
441
  return "HEAD"
436
442
 
437
443
 
@@ -7,6 +7,7 @@ import dataclasses
7
7
  import hashlib
8
8
  import itertools
9
9
  import json
10
+ import logging
10
11
  import math
11
12
  from collections.abc import Mapping
12
13
  from typing import TYPE_CHECKING, override
@@ -19,6 +20,7 @@ from langgraph.checkpoint.base import (
19
20
  empty_checkpoint,
20
21
  )
21
22
  from langgraph.config import get_config
23
+ from langgraph.errors import GraphBubbleUp, NodeCancelledError
22
24
  from langgraph.types import Command
23
25
 
24
26
  from deepagents_code.cost_tracking import (
@@ -37,6 +39,8 @@ if TYPE_CHECKING:
37
39
 
38
40
  from deepagents_code.cost_tracking import CostBreakdown
39
41
 
42
+ logger = logging.getLogger(__name__)
43
+
40
44
  _ACCOUNTING_KEY = "__deepagents_js_cost_owner"
41
45
  _RESPONSE_FORMAT_KEY = "__deepagents_subagent_response_format"
42
46
 
@@ -172,6 +176,17 @@ def _cost_task(
172
176
  config=config,
173
177
  tool_call_id=runtime.tool_call_id,
174
178
  )
179
+ except asyncio.CancelledError, GraphBubbleUp, NodeCancelledError:
180
+ raise
181
+ except Exception as exc:
182
+ logger.debug("Subagent %r failed", subagent_type, exc_info=exc)
183
+ return {
184
+ "error": (
185
+ f"Subagent {subagent_type!r} failed: "
186
+ f"{type(exc).__name__}: {str(exc)[:300]}. You may retry this task."
187
+ ),
188
+ "failed": True,
189
+ }
175
190
  finally:
176
191
  if invocation is not None:
177
192
  active.discard(invocation)
@@ -0,0 +1,91 @@
1
+ """Run untraced `@traceable` bookkeeping inline on the server loop.
2
+
3
+ LangChain wraps every middleware `awrap_tool_call` and `awrap_model_call` hook
4
+ in `langsmith.traceable`. Its async wrapper sends two calls to a worker thread
5
+ per hook: one to set up the run and one to end it. It does this even when
6
+ tracing is off and both calls only build an empty container. About 20 traced
7
+ hooks run per tool round trip, so each round trip pays for about 40 thread
8
+ hops that do no work.
9
+
10
+ `install_untraced_inline_override` installs a LangSmith runtime override. The
11
+ override runs those two calls inline when they cannot trace, and gives every
12
+ other call to LangSmith's default thread hop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import logging
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ import contextvars
23
+ from collections.abc import Awaitable, Callable
24
+
25
+ DefaultAioToThread = Callable[..., Awaitable[Any]]
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _target(func: Callable[..., Any]) -> Callable[..., Any]:
31
+ """Return the function a `functools.partial` wraps, else `func` itself.
32
+
33
+ Returns:
34
+ The underlying callable.
35
+ """
36
+ return func.func if isinstance(func, functools.partial) else func
37
+
38
+
39
+ def _runs_inline(
40
+ ctx: contextvars.Context,
41
+ func: Callable[..., Any],
42
+ args: tuple[Any, ...],
43
+ ) -> bool:
44
+ """Whether a `traceable` bookkeeping call cannot trace, so it is cheap.
45
+
46
+ Only run setup and run end qualify. Any other call (for example an async
47
+ `Client` API method) can do network I/O, so it keeps the thread hop.
48
+
49
+ Returns:
50
+ `True` when the call is safe to run on the event loop.
51
+ """
52
+ from langsmith import run_helpers
53
+ from langsmith.utils import tracing_is_enabled
54
+
55
+ target = _target(func)
56
+ if target is getattr(run_helpers, "_setup_run", None):
57
+ return not ctx.run(tracing_is_enabled)
58
+ if target is getattr(run_helpers, "_handle_container_end", None):
59
+ container = args[0] if args else None
60
+ return isinstance(container, dict) and container.get("new_run") is None
61
+ return False
62
+
63
+
64
+ async def _untraced_inline_aio_to_thread(
65
+ default: DefaultAioToThread,
66
+ ctx: contextvars.Context,
67
+ func: Callable[..., Any],
68
+ /,
69
+ *args: Any, # forwards arbitrary LangSmith call arguments
70
+ **kwargs: Any, # forwards arbitrary LangSmith call arguments
71
+ ) -> Any: # noqa: ANN401 # returns whatever `func` returns
72
+ """Run untraced bookkeeping inline; hop to a thread for everything else.
73
+
74
+ Returns:
75
+ The result of `func`.
76
+ """
77
+ if _runs_inline(ctx, func, args):
78
+ return ctx.run(func, *args, **kwargs)
79
+ return await default(ctx, func, *args, **kwargs)
80
+
81
+
82
+ def install_untraced_inline_override() -> None:
83
+ """Install the override for this process.
84
+
85
+ The override is process-global. Call it only in the server process, which
86
+ owns its event loop and pins one tracing configuration for its lifetime.
87
+ """
88
+ import langsmith
89
+
90
+ langsmith.set_runtime_overrides(aio_to_thread=_untraced_inline_aio_to_thread)
91
+ logger.debug("Installed inline LangSmith runtime override for untraced runs")
@@ -0,0 +1,156 @@
1
+ """Wake the in-memory run queue when a run is created.
2
+
3
+ The server runs on `langgraph_runtime_inmem`. Its background scheduler polls
4
+ for pending runs: `Runs.next(wait=True)` sleeps 0.5 s, then claims only runs
5
+ created before that sleep began. Every user turn and every interrupt resume is
6
+ a new run, so each one waits 0.5-1.0 s before a worker starts it.
7
+
8
+ This patch makes `Runs.put` set an event and makes a waiting `Runs.next` wait
9
+ on that event instead of the fixed sleep. The stock `Runs.next` still claims
10
+ the runs, so queue order and per-thread locking do not change. The 0.5 s
11
+ timeout stays as a fallback, so a missed wakeup costs at most the stock delay.
12
+
13
+ `Runs.next` and `Runs.put` are private APIs. If their shape is not what this
14
+ module expects, the patch logs at debug level and does nothing. Remove this
15
+ module when `langgraph_runtime_inmem` wakes the scheduler on put.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import functools
23
+ import inspect
24
+ import logging
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import AsyncIterator, Awaitable, Callable
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _POLL_INTERVAL = 0.5
33
+ """Fallback wait in seconds; matches the stock `Runs.next` sleep."""
34
+
35
+ _PATCHED_MARKER = "_dcode_run_queue_wakeup"
36
+ """Attribute set on each wrapper so a second install is a no-op."""
37
+
38
+
39
+ class _Wakeup:
40
+ """Edge-triggered signal from `Runs.put` to a waiting `Runs.next`.
41
+
42
+ The event binds to the scheduler loop on the first wait. A put before
43
+ that first wait is not lost: the scheduler then waits the fallback
44
+ interval, as stock does.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._event: asyncio.Event | None = None
49
+ self._loop: asyncio.AbstractEventLoop | None = None
50
+
51
+ async def wait(self) -> None:
52
+ """Wait until a run is put, or until the fallback interval passes."""
53
+ if self._event is None:
54
+ self._event = asyncio.Event()
55
+ self._loop = asyncio.get_running_loop()
56
+ with contextlib.suppress(TimeoutError):
57
+ async with asyncio.timeout(_POLL_INTERVAL):
58
+ await self._event.wait()
59
+ self._event.clear()
60
+
61
+ def notify(self) -> None:
62
+ """Wake the scheduler; safe from any thread and after loop close."""
63
+ event, loop = self._event, self._loop
64
+ if event is None or loop is None:
65
+ return
66
+ # The loop can close during shutdown; the wakeup is then moot.
67
+ with contextlib.suppress(RuntimeError):
68
+ loop.call_soon_threadsafe(event.set)
69
+
70
+
71
+ _WAKEUP = _Wakeup()
72
+
73
+
74
+ def _wrap_next(
75
+ original: Callable[[bool, int], AsyncIterator[object]],
76
+ ) -> Callable[[bool, int], AsyncIterator[object]]:
77
+ """Replace the fixed poll sleep with a wait on `_WAKEUP`.
78
+
79
+ Returns:
80
+ A drop-in replacement for the upstream `Runs.next`.
81
+ """
82
+
83
+ @functools.wraps(original)
84
+ async def next_runs(wait: bool, limit: int = 1) -> AsyncIterator[object]:
85
+ if wait:
86
+ await _WAKEUP.wait()
87
+ # `wait=False` skips the sleep and claims runs created before now,
88
+ # which includes the run that woke us.
89
+ async for item in original(False, limit):
90
+ yield item
91
+
92
+ setattr(next_runs, _PATCHED_MARKER, True)
93
+ return next_runs
94
+
95
+
96
+ def _wrap_put(
97
+ original: Callable[..., Awaitable[object]],
98
+ ) -> Callable[..., Awaitable[object]]:
99
+ """Notify `_WAKEUP` after each run insert.
100
+
101
+ Returns:
102
+ A drop-in replacement for the upstream `Runs.put`.
103
+ """
104
+
105
+ @functools.wraps(original)
106
+ async def put(*args: object, **kwargs: object) -> object:
107
+ result = await original(*args, **kwargs)
108
+ _WAKEUP.notify()
109
+ return result
110
+
111
+ setattr(put, _PATCHED_MARKER, True)
112
+ return put
113
+
114
+
115
+ def _has_expected_shape(runs: type) -> bool:
116
+ """Check that `Runs.next` and `Runs.put` look like the pinned version.
117
+
118
+ Returns:
119
+ `True` when both are static methods with the expected signatures.
120
+ """
121
+ next_attr = runs.__dict__.get("next")
122
+ put_attr = runs.__dict__.get("put")
123
+ if not isinstance(next_attr, staticmethod) or not isinstance(
124
+ put_attr, staticmethod
125
+ ):
126
+ return False
127
+ next_fn, put_fn = next_attr.__func__, put_attr.__func__
128
+ params = list(inspect.signature(next_fn).parameters)
129
+ return (
130
+ inspect.isasyncgenfunction(next_fn)
131
+ and params[:2] == ["wait", "limit"]
132
+ and inspect.iscoroutinefunction(put_fn)
133
+ )
134
+
135
+
136
+ def install_run_queue_wakeup() -> bool:
137
+ """Patch the in-memory run queue to start new runs without the poll delay.
138
+
139
+ Returns:
140
+ `True` when the patch is active after the call, else `False`.
141
+ """
142
+ try:
143
+ from langgraph_runtime_inmem.ops import Runs
144
+ except ImportError:
145
+ logger.debug("Run queue wakeup skipped: in-memory runtime not installed")
146
+ return False
147
+ if getattr(Runs.next, _PATCHED_MARKER, False):
148
+ return True
149
+ if not _has_expected_shape(Runs):
150
+ logger.debug("Run queue wakeup skipped: Runs.next/put changed upstream")
151
+ return False
152
+ wrappers = {"next": _wrap_next, "put": _wrap_put}
153
+ for name, wrap in wrappers.items():
154
+ setattr(Runs, name, staticmethod(wrap(getattr(Runs, name))))
155
+ logger.debug("Run queue wakeup installed")
156
+ return True
@@ -0,0 +1,184 @@
1
+ """Presence lock telling a launch whether other dcode sessions share its install.
2
+
3
+ A Python migration rebuilds the uv tool environment, which breaks every dcode
4
+ process — and every server it spawned — still running from the old one. Startup
5
+ auto-update may only migrate when no other session uses the installation, and
6
+ it cannot tell that from a process listing: shims, renamed symlinks, and the
7
+ server bootstrap all run under different names.
8
+
9
+ Instead, each dcode process of a non-editable install (editable installs never
10
+ auto-update) holds a shared `flock` on `SESSION_LOCK_FILE` for its lifetime,
11
+ and hands the descriptor to the servers it spawns so they hold
12
+ the same lock. A process asking "am I alone?" tries to take the lock
13
+ exclusively; that succeeds only when no other session's open file holds it.
14
+ The kernel drops a lock when its last descriptor closes, so a crashed session
15
+ can never leave a stale claim behind.
16
+
17
+ The answer is a snapshot. It cannot see sessions of versions from before this
18
+ lock existed, and nothing stops a session that launches while the migration
19
+ runs; both are accepted as rare and recoverable by a relaunch.
20
+
21
+ POSIX only. On Windows, or whenever the lock cannot be taken, a process is
22
+ never reported as alone, so the migration stays deferred to `dcode update`.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import os
29
+ import sys
30
+ from typing import TYPE_CHECKING
31
+
32
+ from deepagents_code._paths import PATHS
33
+
34
+ if sys.platform != "win32":
35
+ import fcntl
36
+
37
+ if TYPE_CHECKING:
38
+ from pathlib import Path
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ SESSION_LOCK_FILE: Path = PATHS.installation.locks_dir / "sessions.lock"
43
+ """Advisory lock held shared by every registered dcode process of one installation.
44
+
45
+ Lives beside `update.lock`, outside the tool environment, so a migration that
46
+ rebuilds the environment keeps the same file. Carries no data.
47
+
48
+ Installation-scoped only, with no per-profile fallback: a profile-scoped lock
49
+ would hide sessions of other profiles, and over-reporting "alone" is the one
50
+ failure this lock must not have.
51
+ """
52
+
53
+ _held_fd: int | None = None
54
+ """Descriptor holding this process's shared lock, or `None` when not held."""
55
+
56
+
57
+ def hold_session_lock() -> None:
58
+ """Register this process as a running session for its lifetime.
59
+
60
+ Idempotent and fail-soft: when the lock cannot be taken, the process runs
61
+ unregistered and `is_sole_session` reports `False`. The descriptor is
62
+ non-inheritable, so the re-exec after an update drops it and the new
63
+ process registers itself again.
64
+ """
65
+ global _held_fd # noqa: PLW0603 # one registration per process
66
+ if _held_fd is not None or sys.platform == "win32":
67
+ return
68
+ try:
69
+ SESSION_LOCK_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
70
+ fd = os.open(SESSION_LOCK_FILE, os.O_RDWR | os.O_CREAT, 0o600)
71
+ except OSError:
72
+ logger.warning(
73
+ "Could not open session lock %s", SESSION_LOCK_FILE, exc_info=True
74
+ )
75
+ return
76
+ try:
77
+ # Blocks only while another process is inside `is_sole_session`, the
78
+ # one place the lock is ever held exclusively, and only for an instant.
79
+ # Failing fast instead would leave this session invisible to that check.
80
+ fcntl.flock(fd, fcntl.LOCK_SH)
81
+ except OSError:
82
+ logger.warning(
83
+ "Could not take session lock %s", SESSION_LOCK_FILE, exc_info=True
84
+ )
85
+ os.close(fd)
86
+ return
87
+ _held_fd = fd
88
+
89
+
90
+ def session_lock_fds() -> tuple[int, ...]:
91
+ """Return descriptors a spawned server must inherit to count as this session.
92
+
93
+ `flock` locks belong to the open file description, so a child given the
94
+ descriptor holds the same lock: the server keeps the install marked in use
95
+ if it outlives its client, and does not count as a second session.
96
+ """
97
+ return () if _held_fd is None else (_held_fd,)
98
+
99
+
100
+ def session_lock_unavailable() -> bool:
101
+ """Return whether this POSIX process failed to register as a session.
102
+
103
+ Lets callers tell "other sessions are running" apart from "presence cannot
104
+ be known", which quitting other sessions would never fix.
105
+ """
106
+ return sys.platform != "win32" and _held_fd is None
107
+
108
+
109
+ def is_sole_session() -> bool:
110
+ """Return whether no other dcode session holds this installation.
111
+
112
+ Converting a `flock` is not atomic: the shared lock is released before the
113
+ exclusive one is tried, so two processes checking at once could each see
114
+ the other as absent. An exclusive lock on `sessions.check.lock` beside
115
+ `SESSION_LOCK_FILE` admits one checker at a time; a process that finds it
116
+ taken is not alone.
117
+
118
+ Returns:
119
+ `True` only when this process holds the session lock and could take
120
+ it exclusively. `False` when another session holds it, or when the
121
+ lock is unavailable and presence cannot be known.
122
+ """
123
+ if _held_fd is None:
124
+ return False
125
+ check_file = SESSION_LOCK_FILE.with_name("sessions.check.lock")
126
+ try:
127
+ guard = os.open(check_file, os.O_RDWR | os.O_CREAT, 0o600)
128
+ except OSError:
129
+ logger.warning("Could not open %s", check_file, exc_info=True)
130
+ return False
131
+ try:
132
+ fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
133
+ except OSError:
134
+ return False
135
+ else:
136
+ return _probe_exclusive(_held_fd)
137
+ finally:
138
+ os.close(guard)
139
+
140
+
141
+ def _probe_exclusive(fd: int) -> bool:
142
+ """Try to take the session lock exclusively, then hold it shared again.
143
+
144
+ Returns:
145
+ Whether the exclusive attempt succeeded and the shared lock is back.
146
+ """
147
+ try:
148
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
149
+ except BlockingIOError:
150
+ sole = False
151
+ except OSError:
152
+ logger.warning(
153
+ "Could not check session lock %s", SESSION_LOCK_FILE, exc_info=True
154
+ )
155
+ sole = False
156
+ else:
157
+ sole = True
158
+ # Re-take it on both branches: on Linux a failed upgrade attempt has already
159
+ # released the shared lock.
160
+ return _restore_shared(fd) and sole
161
+
162
+
163
+ def _restore_shared(fd: int) -> bool:
164
+ """Hold the session lock shared again, or unregister on failure.
165
+
166
+ Returns:
167
+ Whether this process is still registered.
168
+ """
169
+ global _held_fd # noqa: PLW0603 # unregister a lock this process lost
170
+ try:
171
+ # Blocking is safe: the check lock is held, so no other process holds
172
+ # this lock exclusively.
173
+ fcntl.flock(fd, fcntl.LOCK_SH)
174
+ except OSError:
175
+ logger.warning(
176
+ "Could not restore the shared session lock %s; other dcode sessions "
177
+ "will not see this one",
178
+ SESSION_LOCK_FILE,
179
+ exc_info=True,
180
+ )
181
+ _held_fd = None
182
+ os.close(fd)
183
+ return False
184
+ return True
@@ -293,6 +293,11 @@ else:
293
293
 
294
294
 
295
295
  def _rendered_text(widget: Widget) -> str | None:
296
+ # A widget that renders a custom visual (such as `FinalMarkdown`) can
297
+ # publish the text its selection offsets index into.
298
+ selectable = getattr(widget, "selectable_text", None)
299
+ if isinstance(selectable, str):
300
+ return selectable
296
301
  visual = widget._render() # match Textual's get_selection path
297
302
  if isinstance(visual, (Content, Text)):
298
303
  return str(visual)