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,1716 @@
1
+ """Thread management using LangGraph's built-in checkpoint persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import sqlite3
9
+ from contextlib import asynccontextmanager
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast
13
+
14
+ from deepagents_code._constants import SYSTEM_MESSAGE_PREFIX
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import AsyncIterator
18
+
19
+ import aiosqlite
20
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
21
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
22
+
23
+ from deepagents_code.output import OutputFormat
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _aiosqlite_patched = False
28
+ _jsonplus_serializer: JsonPlusSerializer | None = None
29
+ _message_count_cache: dict[str, tuple[str | None, int]] = {}
30
+ _MAX_MESSAGE_COUNT_CACHE = 4096
31
+ _initial_prompt_cache: dict[str, tuple[str | None, str | None]] = {}
32
+ _MAX_INITIAL_PROMPT_CACHE = 4096
33
+ _recent_threads_cache: dict[tuple[str | None, int], list[ThreadInfo]] = {}
34
+ _MAX_RECENT_THREADS_CACHE_KEYS = 16
35
+
36
+
37
+ def _patch_aiosqlite() -> None:
38
+ """Patch aiosqlite.Connection with `is_alive()` if missing.
39
+
40
+ Required by langgraph-checkpoint>=2.1.0.
41
+ See: https://github.com/langchain-ai/langgraph/issues/6583
42
+ """
43
+ global _aiosqlite_patched # noqa: PLW0603 # Module-level flag requires global statement
44
+ if _aiosqlite_patched:
45
+ return
46
+
47
+ import aiosqlite as _aiosqlite
48
+
49
+ if not hasattr(_aiosqlite.Connection, "is_alive"):
50
+
51
+ def _is_alive(self: _aiosqlite.Connection) -> bool:
52
+ """Check if the connection is still alive.
53
+
54
+ Returns:
55
+ True if connection is alive, False otherwise.
56
+ """
57
+ return bool(self._running and self._connection is not None)
58
+
59
+ # Dynamically adding a method to aiosqlite.Connection at runtime.
60
+ # Type checkers can't understand this monkey-patch, so we suppress the
61
+ # "attr-defined" error that would otherwise be raised.
62
+ _aiosqlite.Connection.is_alive = _is_alive # ty: ignore[unresolved-attribute]
63
+
64
+ _aiosqlite_patched = True
65
+
66
+
67
+ async def _drain_aiosqlite_worker(conn: aiosqlite.Connection) -> None:
68
+ """Join the aiosqlite worker thread after its connection is closed.
69
+
70
+ `aiosqlite.Connection` wraps a daemon `Thread` (`conn._thread`) that
71
+ drains its tx queue independently of the caller's event loop. The
72
+ library's `close()` puts a stop sentinel on the queue and awaits the
73
+ sentinel's future, but does not explicitly join the worker thread.
74
+
75
+ If the connection is leaked (no explicit close) and the surrounding
76
+ event loop has already shut down, the worker can still pop a queued
77
+ item (typically from `Connection.__del__` calling `stop()`) and call
78
+ `future.get_loop().call_soon_threadsafe(...)` on the closed loop. That
79
+ raises `RuntimeError: Event loop is closed`, which pytest surfaces as
80
+ `PytestUnhandledThreadExceptionWarning` (and GitHub Actions then
81
+ surfaces as a workflow annotation).
82
+
83
+ Explicitly joining the worker thread after close guarantees it has
84
+ exited before this coroutine returns, eliminating the race for any
85
+ connection routed through `_connect` / `get_checkpointer`.
86
+ """
87
+ worker = getattr(conn, "_thread", None)
88
+ if worker is None or not worker.is_alive():
89
+ return
90
+ # `RuntimeError` covers the "thread was never started" case; treat as
91
+ # already drained.
92
+ with contextlib.suppress(RuntimeError):
93
+ await asyncio.to_thread(worker.join, 5.0)
94
+
95
+
96
+ @asynccontextmanager
97
+ async def _connect() -> AsyncIterator[aiosqlite.Connection]:
98
+ """Import aiosqlite, apply the compatibility patch, and connect.
99
+
100
+ Centralizes the deferred import + patch + connect sequence used by every
101
+ database function in this module.
102
+
103
+ Yields:
104
+ An open aiosqlite connection to the sessions database.
105
+ """
106
+ import aiosqlite as _aiosqlite
107
+
108
+ _patch_aiosqlite()
109
+
110
+ conn: aiosqlite.Connection | None = None
111
+ try:
112
+ async with _aiosqlite.connect(str(get_db_path()), timeout=30.0) as opened:
113
+ conn = opened
114
+ yield opened
115
+ finally:
116
+ if conn is not None:
117
+ await _drain_aiosqlite_worker(conn)
118
+
119
+
120
+ class ThreadInfo(TypedDict):
121
+ """Thread metadata returned by `list_threads`."""
122
+
123
+ thread_id: str
124
+ """Unique identifier for the thread."""
125
+
126
+ agent_name: str | None
127
+ """Name of the agent that owns the thread."""
128
+
129
+ updated_at: str | None
130
+ """ISO timestamp of the last update."""
131
+
132
+ created_at: NotRequired[str | None]
133
+ """ISO timestamp of thread creation (earliest checkpoint)."""
134
+
135
+ git_branch: NotRequired[str | None]
136
+ """Git branch active when the thread was created."""
137
+
138
+ initial_prompt: NotRequired[str | None]
139
+ """First human message in the thread."""
140
+
141
+ message_count: NotRequired[int]
142
+ """Number of messages in the thread."""
143
+
144
+ latest_checkpoint_id: NotRequired[str | None]
145
+ """Most recent checkpoint ID for cache invalidation."""
146
+
147
+ cwd: NotRequired[str | None]
148
+ """Working directory where the thread was last used."""
149
+
150
+
151
+ class _CheckpointSummary(NamedTuple):
152
+ """Structured data extracted from a thread's latest checkpoint."""
153
+
154
+ message_count: int | None
155
+ """Number of messages inlined in the latest checkpoint, or `None`.
156
+
157
+ `None` means the latest checkpoint did not inline the `messages` channel
158
+ value, so the count is unknown and must be reconstructed from the `writes`
159
+ table. This happens when `messages` uses a `DeltaChannel` (a LangGraph
160
+ channel the deepagents SDK applies to `messages` as of v0.6) and the latest
161
+ checkpoint falls between periodic snapshots. An `int` (including `0`) is a
162
+ trustworthy count.
163
+ """
164
+
165
+ initial_prompt: str | None
166
+ """First human prompt recovered from the latest checkpoint."""
167
+
168
+
169
+ def format_timestamp(iso_timestamp: str | None) -> str:
170
+ """Format ISO timestamp for display (e.g., 'Dec 30, 6:10pm').
171
+
172
+ Args:
173
+ iso_timestamp: ISO 8601 timestamp string, or `None`.
174
+
175
+ Returns:
176
+ Formatted timestamp string or empty string if invalid.
177
+ """
178
+ if not iso_timestamp:
179
+ return ""
180
+ try:
181
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
182
+ return (
183
+ dt.strftime("%b %d, %-I:%M%p")
184
+ .lower()
185
+ .replace("am", "am")
186
+ .replace("pm", "pm")
187
+ )
188
+ except (ValueError, TypeError):
189
+ logger.debug(
190
+ "Failed to parse timestamp %r; displaying as blank",
191
+ iso_timestamp,
192
+ exc_info=True,
193
+ )
194
+ return ""
195
+
196
+
197
+ def format_relative_timestamp(iso_timestamp: str | None) -> str:
198
+ """Format ISO timestamp as relative time (e.g., '5m ago', '2h ago').
199
+
200
+ Args:
201
+ iso_timestamp: ISO 8601 timestamp string, or `None`.
202
+
203
+ Returns:
204
+ Relative time string or empty string if invalid.
205
+ """
206
+ if not iso_timestamp:
207
+ return ""
208
+ try:
209
+ dt = datetime.fromisoformat(iso_timestamp).astimezone()
210
+ except (ValueError, TypeError):
211
+ logger.debug(
212
+ "Failed to parse timestamp %r; displaying as blank",
213
+ iso_timestamp,
214
+ exc_info=True,
215
+ )
216
+ return ""
217
+
218
+ delta = datetime.now(tz=dt.tzinfo) - dt
219
+ seconds = int(delta.total_seconds())
220
+ if seconds < 0:
221
+ return "just now"
222
+ if seconds < 60: # noqa: PLR2004
223
+ return f"{seconds}s ago"
224
+ minutes = seconds // 60
225
+ if minutes < 60: # noqa: PLR2004
226
+ return f"{minutes}m ago"
227
+ hours = minutes // 60
228
+ if hours < 24: # noqa: PLR2004
229
+ return f"{hours}h ago"
230
+ days = hours // 24
231
+ if days < 30: # noqa: PLR2004
232
+ return f"{days}d ago"
233
+ if days < 365: # noqa: PLR2004
234
+ months = days // 30
235
+ return f"{months}mo ago"
236
+ years = days // 365
237
+ return f"{years}y ago"
238
+
239
+
240
+ def format_path(path: str | None) -> str:
241
+ """Format a filesystem path for display.
242
+
243
+ Paths under the user's home directory are shown relative to `~`.
244
+ All other paths are returned as-is.
245
+
246
+ Args:
247
+ path: Absolute filesystem path, or `None`.
248
+
249
+ Returns:
250
+ Formatted path string, or empty string if path is falsy.
251
+ """
252
+ if not path:
253
+ return ""
254
+ try:
255
+ home = str(Path.home())
256
+ if path == home:
257
+ return "~"
258
+ prefix = home + "/"
259
+ if path.startswith(prefix):
260
+ return "~/" + path[len(prefix) :]
261
+ except (RuntimeError, KeyError, OSError):
262
+ logger.debug(
263
+ "Could not resolve home directory for path formatting", exc_info=True
264
+ )
265
+ return path
266
+ else:
267
+ return path
268
+
269
+
270
+ _db_path: Path | None = None
271
+
272
+
273
+ def get_db_path() -> Path:
274
+ """Get path to global database.
275
+
276
+ The result is cached after the first successful call to avoid repeated
277
+ filesystem operations.
278
+
279
+ Returns:
280
+ Path to the SQLite database file.
281
+ """
282
+ global _db_path # noqa: PLW0603 # Module-level cache requires global statement
283
+ if _db_path is not None:
284
+ return _db_path
285
+ from deepagents_code.model_config import DEFAULT_STATE_DIR
286
+
287
+ DEFAULT_STATE_DIR.mkdir(parents=True, exist_ok=True)
288
+ _db_path = DEFAULT_STATE_DIR / "sessions.db"
289
+ return _db_path
290
+
291
+
292
+ def generate_thread_id() -> str:
293
+ """Generate a new thread ID as a full UUID7 string.
294
+
295
+ Returns:
296
+ UUID7 string (time-ordered for natural sort by creation time).
297
+ """
298
+ from uuid_utils import uuid7
299
+
300
+ return str(uuid7())
301
+
302
+
303
+ async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool:
304
+ """Check if a table exists in the database.
305
+
306
+ Returns:
307
+ True if table exists, False otherwise.
308
+ """
309
+ query = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"
310
+ async with conn.execute(query, (table,)) as cursor:
311
+ return await cursor.fetchone() is not None
312
+
313
+
314
+ _THREADS_LIST_INDEX = "idx_dcode_threads_list"
315
+ """Covering index that makes the `list_threads` GROUP BY an index-only scan.
316
+
317
+ LangGraph's `SqliteSaver` stores each checkpoint's full state blob inline in the
318
+ `checkpoints` row alongside the small `metadata` field. The thread-list query
319
+ only needs `metadata` (per-thread latest `updated_at`, `agent_name`, etc.), but
320
+ without a covering index SQLite scans the whole table — dragging every state
321
+ blob through I/O. On a large profile (e.g. ~12 GB of blobs) that scan takes
322
+ tens of seconds. This index carries exactly the expressions the query reads, so
323
+ the planner satisfies the GROUP BY from the index alone and never touches the
324
+ blob-bearing rows, turning a ~60 s scan into a sub-second lookup.
325
+
326
+ The column order (leading `thread_id`) also lets the GROUP BY consume the index
327
+ in order. Keep the indexed expressions in sync with the `list_threads` query.
328
+ """
329
+
330
+
331
+ async def _ensure_threads_list_index(conn: aiosqlite.Connection) -> None:
332
+ """Create the `list_threads` covering index if it does not already exist.
333
+
334
+ Idempotent: `CREATE INDEX IF NOT EXISTS` is a near-instant catalog check once
335
+ the index exists. The one-time build on a pre-existing large database costs a
336
+ single full table scan (seconds to tens of seconds), after which every
337
+ `list_threads` call is a sub-second index-only scan. Runs in the aiosqlite
338
+ worker thread, so it does not block the event loop.
339
+
340
+ A failure here is non-fatal: the list query still returns correct results via
341
+ the slower table scan, so we log and continue rather than break `threads
342
+ list` (e.g. on a read-only database or under write-lock contention).
343
+ """
344
+ try:
345
+ await conn.execute(
346
+ f"CREATE INDEX IF NOT EXISTS {_THREADS_LIST_INDEX} ON checkpoints("
347
+ "thread_id, "
348
+ "json_extract(metadata, '$.updated_at'), "
349
+ "checkpoint_id, "
350
+ "json_extract(metadata, '$.agent_name'), "
351
+ "json_extract(metadata, '$.git_branch'), "
352
+ "json_extract(metadata, '$.cwd'))"
353
+ )
354
+ await conn.commit()
355
+ except Exception:
356
+ logger.warning(
357
+ "Failed to create the %s index; `threads list` will fall back to a "
358
+ "full table scan and may be slow on large databases",
359
+ _THREADS_LIST_INDEX,
360
+ exc_info=True,
361
+ )
362
+
363
+
364
+ async def list_threads(
365
+ agent_name: str | None = None,
366
+ limit: int = 20,
367
+ include_message_count: bool = False,
368
+ sort_by: str = "updated",
369
+ branch: str | None = None,
370
+ cwd: str | None = None,
371
+ ) -> list[ThreadInfo]:
372
+ """List threads from checkpoints table.
373
+
374
+ Args:
375
+ agent_name: Optional filter by agent name.
376
+ limit: Maximum number of threads to return.
377
+ include_message_count: Whether to include message counts.
378
+ sort_by: Sort field — `"updated"` or `"created"`.
379
+ branch: Optional filter by git branch name.
380
+ cwd: Optional filter by working directory. Only threads whose stored
381
+ `cwd` metadata equals this path are returned. Matching is an
382
+ exact string comparison — no path normalization, symlink
383
+ resolution, or prefix matching. Threads without a stored `cwd`
384
+ (older rows) are excluded.
385
+
386
+ Returns:
387
+ List of `ThreadInfo` dicts with `thread_id`, `agent_name`,
388
+ `updated_at`, `created_at`, `latest_checkpoint_id`, `git_branch`,
389
+ `cwd`, and optionally `message_count`.
390
+
391
+ Raises:
392
+ ValueError: If `sort_by` is not `"updated"` or `"created"`.
393
+ """
394
+ async with _connect() as conn:
395
+ if not await _table_exists(conn, "checkpoints"):
396
+ return []
397
+
398
+ # Ensure the covering index exists before the GROUP BY below, so the
399
+ # query is an index-only scan instead of a full scan over the (large,
400
+ # blob-bearing) checkpoints table.
401
+ await _ensure_threads_list_index(conn)
402
+
403
+ if sort_by not in {"updated", "created"}:
404
+ msg = f"Invalid sort_by {sort_by!r}; expected 'updated' or 'created'"
405
+ raise ValueError(msg)
406
+ order_col = "created_at" if sort_by == "created" else "updated_at"
407
+
408
+ where_clauses: list[str] = []
409
+ params_list: list[str | int] = []
410
+
411
+ if agent_name:
412
+ where_clauses.append("json_extract(metadata, '$.agent_name') = ?")
413
+ params_list.append(agent_name)
414
+ if branch:
415
+ where_clauses.append("json_extract(metadata, '$.git_branch') = ?")
416
+ params_list.append(branch)
417
+ if cwd:
418
+ where_clauses.append("json_extract(metadata, '$.cwd') = ?")
419
+ params_list.append(cwd)
420
+
421
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
422
+
423
+ query = f"""
424
+ SELECT thread_id,
425
+ json_extract(metadata, '$.agent_name') as agent_name,
426
+ MAX(json_extract(metadata, '$.updated_at')) as updated_at,
427
+ MAX(checkpoint_id) as latest_checkpoint_id,
428
+ MIN(json_extract(metadata, '$.updated_at')) as created_at,
429
+ MAX(json_extract(metadata, '$.git_branch')) as git_branch,
430
+ MAX(json_extract(metadata, '$.cwd')) as cwd
431
+ FROM checkpoints
432
+ {where_sql}
433
+ GROUP BY thread_id
434
+ ORDER BY {order_col} DESC
435
+ LIMIT ?
436
+ """ # noqa: S608 # where_sql/order_col derived from controlled internal values; user values use ? placeholders
437
+ params: tuple = (*params_list, limit)
438
+
439
+ async with conn.execute(query, params) as cursor:
440
+ rows = await cursor.fetchall()
441
+ threads: list[ThreadInfo] = [
442
+ ThreadInfo(
443
+ thread_id=r[0],
444
+ agent_name=r[1],
445
+ updated_at=r[2],
446
+ latest_checkpoint_id=r[3],
447
+ created_at=r[4],
448
+ git_branch=r[5],
449
+ cwd=r[6],
450
+ )
451
+ for r in rows
452
+ ]
453
+
454
+ # Fetch message counts if requested
455
+ if include_message_count and threads:
456
+ await _populate_message_counts(conn, threads)
457
+
458
+ # Only cache unfiltered results so the thread selector modal
459
+ # doesn't receive branch-/cwd-filtered or differently-sorted data.
460
+ if sort_by == "updated" and branch is None and cwd is None:
461
+ _cache_recent_threads(agent_name, limit, threads)
462
+ return threads
463
+
464
+
465
+ async def populate_thread_checkpoint_details(
466
+ threads: list[ThreadInfo],
467
+ *,
468
+ include_message_count: bool = True,
469
+ include_initial_prompt: bool = True,
470
+ ) -> list[ThreadInfo]:
471
+ """Populate checkpoint-derived fields for an existing thread list.
472
+
473
+ This is used by the `/threads` modal to enrich rows in one background pass,
474
+ so the latest checkpoint is fetched and deserialized at most once per row.
475
+
476
+ Args:
477
+ threads: Thread rows to enrich in place.
478
+ include_message_count: Whether to populate `message_count`.
479
+ include_initial_prompt: Whether to populate `initial_prompt`.
480
+
481
+ Returns:
482
+ The same list object with missing checkpoint-derived fields populated.
483
+ """
484
+ if not threads or (not include_message_count and not include_initial_prompt):
485
+ return threads
486
+
487
+ async with _connect() as conn:
488
+ await _populate_checkpoint_fields(
489
+ conn,
490
+ threads,
491
+ include_message_count=include_message_count,
492
+ include_initial_prompt=include_initial_prompt,
493
+ )
494
+ return threads
495
+
496
+
497
+ async def prewarm_thread_message_counts(limit: int | None = None) -> None:
498
+ """Prewarm thread selector cache for faster `/threads` open.
499
+
500
+ Fetches a bounded list of recent threads and populates checkpoint-derived
501
+ fields for currently visible columns into the in-memory cache. Intended to
502
+ run in a background worker during app startup.
503
+
504
+ Args:
505
+ limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`.
506
+ """
507
+ thread_limit = limit if limit is not None else get_thread_limit()
508
+ if thread_limit < 1:
509
+ return
510
+
511
+ try:
512
+ from deepagents_code.model_config import load_thread_config
513
+
514
+ cfg = load_thread_config()
515
+ threads = await list_threads(limit=thread_limit, include_message_count=False)
516
+ if threads:
517
+ await populate_thread_checkpoint_details(
518
+ threads,
519
+ include_message_count=cfg.columns.get("messages", False),
520
+ include_initial_prompt=cfg.columns.get("initial_prompt", False),
521
+ )
522
+ _cache_recent_threads(None, thread_limit, threads)
523
+ except (OSError, sqlite3.Error):
524
+ logger.debug("Could not prewarm thread selector cache", exc_info=True)
525
+ except Exception:
526
+ logger.warning(
527
+ "Unexpected error while prewarming thread selector cache",
528
+ exc_info=True,
529
+ )
530
+
531
+
532
+ def get_cached_threads(
533
+ agent_name: str | None = None,
534
+ limit: int | None = None,
535
+ ) -> list[ThreadInfo] | None:
536
+ """Get cached recent threads, if available.
537
+
538
+ Args:
539
+ agent_name: Optional agent-name filter key.
540
+ limit: Maximum rows requested. Uses `get_thread_limit()` when `None`.
541
+
542
+ Returns:
543
+ Copy of cached rows when available, otherwise `None`.
544
+ """
545
+
546
+ def _copy_with_cached_counts(rows: list[ThreadInfo]) -> list[ThreadInfo]:
547
+ copied_rows = _copy_threads(rows)
548
+ apply_cached_thread_message_counts(copied_rows)
549
+ apply_cached_thread_initial_prompts(copied_rows)
550
+ return copied_rows
551
+
552
+ thread_limit = limit if limit is not None else get_thread_limit()
553
+ if thread_limit < 1:
554
+ return None
555
+
556
+ exact = _recent_threads_cache.get((agent_name, thread_limit))
557
+ if exact is not None:
558
+ return _copy_with_cached_counts(exact)
559
+
560
+ best_key: tuple[str | None, int] | None = None
561
+ for key in _recent_threads_cache:
562
+ cache_agent, cache_limit = key
563
+ if cache_agent != agent_name or cache_limit < thread_limit:
564
+ continue
565
+ if best_key is None or cache_limit < best_key[1]:
566
+ best_key = key
567
+
568
+ if best_key is None:
569
+ return None
570
+
571
+ return _copy_with_cached_counts(_recent_threads_cache[best_key][:thread_limit])
572
+
573
+
574
+ def apply_cached_thread_message_counts(threads: list[ThreadInfo]) -> int:
575
+ """Apply cached message counts onto thread rows when freshness matches.
576
+
577
+ Args:
578
+ threads: Thread rows to mutate in place.
579
+
580
+ Returns:
581
+ Number of rows that were populated from cache.
582
+ """
583
+ populated = 0
584
+ for thread in threads:
585
+ if "message_count" in thread:
586
+ continue
587
+ thread_id = thread["thread_id"]
588
+ freshness = _thread_freshness(thread)
589
+ cached = _message_count_cache.get(thread_id)
590
+ if cached is None or cached[0] != freshness:
591
+ continue
592
+ thread["message_count"] = cached[1]
593
+ populated += 1
594
+ return populated
595
+
596
+
597
+ def apply_cached_thread_initial_prompts(threads: list[ThreadInfo]) -> int:
598
+ """Apply cached initial prompts onto thread rows when freshness matches.
599
+
600
+ Args:
601
+ threads: Thread rows to mutate in place.
602
+
603
+ Returns:
604
+ Number of rows that were populated from cache.
605
+ """
606
+ populated = 0
607
+ for thread in threads:
608
+ if "initial_prompt" in thread:
609
+ continue
610
+ thread_id = thread["thread_id"]
611
+ freshness = _thread_freshness(thread)
612
+ cached = _initial_prompt_cache.get(thread_id)
613
+ if cached is None or cached[0] != freshness:
614
+ continue
615
+ thread["initial_prompt"] = cached[1]
616
+ populated += 1
617
+ return populated
618
+
619
+
620
+ async def _populate_message_counts(
621
+ conn: aiosqlite.Connection,
622
+ threads: list[ThreadInfo],
623
+ ) -> None:
624
+ """Fill `message_count` on thread rows with cache-aware lookup."""
625
+ await _populate_checkpoint_fields(
626
+ conn,
627
+ threads,
628
+ include_message_count=True,
629
+ include_initial_prompt=False,
630
+ )
631
+
632
+
633
+ async def _get_jsonplus_serializer() -> JsonPlusSerializer:
634
+ """Return a cached JsonPlus serializer, loading it off the UI loop."""
635
+ global _jsonplus_serializer # noqa: PLW0603 # Module-level cache requires global statement
636
+ if _jsonplus_serializer is not None:
637
+ return _jsonplus_serializer
638
+
639
+ loop = asyncio.get_running_loop()
640
+ _jsonplus_serializer = await loop.run_in_executor(None, _create_jsonplus_serializer)
641
+ return _jsonplus_serializer
642
+
643
+
644
+ def _create_jsonplus_serializer() -> JsonPlusSerializer:
645
+ """Import and create a JsonPlus serializer.
646
+
647
+ Returns:
648
+ A ready `JsonPlusSerializer` instance.
649
+ """
650
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
651
+
652
+ return JsonPlusSerializer()
653
+
654
+
655
+ def _cache_message_count(thread_id: str, freshness: str | None, count: int) -> None:
656
+ """Cache a thread's message count with a freshness token."""
657
+ if len(_message_count_cache) >= _MAX_MESSAGE_COUNT_CACHE and (
658
+ thread_id not in _message_count_cache
659
+ ):
660
+ oldest = next(iter(_message_count_cache))
661
+ _message_count_cache.pop(oldest, None)
662
+ _message_count_cache[thread_id] = (freshness, count)
663
+
664
+
665
+ def _cache_initial_prompt(
666
+ thread_id: str,
667
+ freshness: str | None,
668
+ initial_prompt: str | None,
669
+ ) -> None:
670
+ """Cache a thread's initial prompt with a freshness token."""
671
+ if len(_initial_prompt_cache) >= _MAX_INITIAL_PROMPT_CACHE and (
672
+ thread_id not in _initial_prompt_cache
673
+ ):
674
+ oldest = next(iter(_initial_prompt_cache))
675
+ _initial_prompt_cache.pop(oldest, None)
676
+ _initial_prompt_cache[thread_id] = (freshness, initial_prompt)
677
+
678
+
679
+ def _thread_freshness(thread: ThreadInfo) -> str | None:
680
+ """Return a cache freshness token for a thread row.
681
+
682
+ The token is checkpoint-granular (`latest_checkpoint_id`). The
683
+ writes-reconstructed `message_count` includes pending writes on the latest
684
+ checkpoint, which in principle can change without a new checkpoint ID — so
685
+ this token does not capture intra-checkpoint write churn. In practice that
686
+ is benign: dcode only mutates `messages` through the agent graph, and every
687
+ batch of message writes culminates in a new checkpoint (each superstep,
688
+ `aupdate_state`, interrupt, and cancellation all advance
689
+ `latest_checkpoint_id`). The only window where a cached count can lag is
690
+ opening the `/threads` selector mid-superstep against an actively streaming
691
+ thread; the selector does not live-refresh, so that count stays put until
692
+ the modal is reopened (by then a new checkpoint exists and the cache
693
+ refreshes). Making the key write-sensitive would require probing the
694
+ `writes` table for every row on every `list_threads`, which is not worth it
695
+ for a cosmetic count.
696
+ """
697
+ return thread.get("latest_checkpoint_id") or thread.get("updated_at")
698
+
699
+
700
+ def _cache_recent_threads(
701
+ agent_name: str | None,
702
+ limit: int,
703
+ threads: list[ThreadInfo],
704
+ ) -> None:
705
+ """Store a copy of recent thread rows for fast selector startup."""
706
+ key = (agent_name, max(1, limit))
707
+ if len(_recent_threads_cache) >= _MAX_RECENT_THREADS_CACHE_KEYS and (
708
+ key not in _recent_threads_cache
709
+ ):
710
+ _recent_threads_cache.clear()
711
+ _recent_threads_cache[key] = _copy_threads(threads)
712
+
713
+
714
+ def _copy_threads(threads: list[ThreadInfo]) -> list[ThreadInfo]:
715
+ """Return shallow-copied thread rows."""
716
+ return [ThreadInfo(**thread) for thread in threads]
717
+
718
+
719
+ async def _populate_checkpoint_fields(
720
+ conn: aiosqlite.Connection,
721
+ threads: list[ThreadInfo],
722
+ *,
723
+ include_message_count: bool,
724
+ include_initial_prompt: bool,
725
+ ) -> None:
726
+ """Populate checkpoint-derived thread fields with a batched latest-row pass."""
727
+ serde = await _get_jsonplus_serializer()
728
+
729
+ # Phase 1: apply cache hits, collect threads that need DB fetch.
730
+ uncached: list[ThreadInfo] = []
731
+ for thread in threads:
732
+ thread_id = thread["thread_id"]
733
+ freshness = _thread_freshness(thread)
734
+ needs_count = False
735
+ needs_prompt = False
736
+
737
+ if include_message_count:
738
+ cached = _message_count_cache.get(thread_id)
739
+ if cached is not None and cached[0] == freshness:
740
+ thread["message_count"] = cached[1]
741
+ else:
742
+ needs_count = True
743
+
744
+ if include_initial_prompt and "initial_prompt" not in thread:
745
+ cached_prompt = _initial_prompt_cache.get(thread_id)
746
+ if cached_prompt is not None and cached_prompt[0] == freshness:
747
+ thread["initial_prompt"] = cached_prompt[1]
748
+ else:
749
+ needs_prompt = True
750
+
751
+ if needs_count or needs_prompt:
752
+ uncached.append(thread)
753
+
754
+ if not uncached:
755
+ return
756
+
757
+ # Phase 2: batch-fetch all uncached threads.
758
+ uncached_ids = [t["thread_id"] for t in uncached]
759
+ batch_results: dict[str, _CheckpointSummary] = {}
760
+ if include_message_count or include_initial_prompt:
761
+ batch_results = await _load_latest_checkpoint_summaries_batch(
762
+ conn, uncached_ids, serde
763
+ )
764
+ # `initial_prompt` cannot be recovered from the latest checkpoint alone:
765
+ # `after_model` middleware (e.g., `ResumeStateMiddleware`) writes partial
766
+ # checkpoints whose `channel_values` omit `messages`. Read the very first
767
+ # write to the `messages` channel from the `writes` table instead — that
768
+ # row holds the user's original input.
769
+ prompt_results: dict[str, str | None] = {}
770
+ if include_initial_prompt:
771
+ prompt_results = await _load_initial_prompts_from_writes_batch(
772
+ conn, uncached_ids, serde
773
+ )
774
+
775
+ # Phase 3: apply inline results, deferring threads whose latest checkpoint
776
+ # does not inline the `messages` channel value. When `messages` uses a
777
+ # `DeltaChannel` (LangGraph channel applied by the deepagents SDK as of
778
+ # v0.6) the full list is only snapshotted into `channel_values` periodically,
779
+ # so the latest checkpoint usually omits it; the count must then be
780
+ # reconstructed from the `writes` table.
781
+ needs_writes_count: list[str] = []
782
+ for thread in uncached:
783
+ thread_id = thread["thread_id"]
784
+ freshness = _thread_freshness(thread)
785
+
786
+ if include_message_count and "message_count" not in thread:
787
+ summary = batch_results.get(thread_id)
788
+ if summary is not None and summary.message_count is not None:
789
+ thread["message_count"] = summary.message_count
790
+ _cache_message_count(thread_id, freshness, summary.message_count)
791
+ else:
792
+ needs_writes_count.append(thread_id)
793
+ if include_initial_prompt and "initial_prompt" not in thread:
794
+ if thread_id in prompt_results:
795
+ prompt = prompt_results[thread_id]
796
+ else:
797
+ prompt = batch_results.get(
798
+ thread_id, _CheckpointSummary(None, None)
799
+ ).initial_prompt
800
+ thread["initial_prompt"] = prompt
801
+ _cache_initial_prompt(thread_id, freshness, prompt)
802
+
803
+ # Phase 4: reconstruct counts for delta-channel threads from the `writes`
804
+ # table by replaying the `messages` writes through the canonical reducer.
805
+ if needs_writes_count:
806
+ writes_counts = await _load_message_counts_from_writes_batch(
807
+ conn, needs_writes_count, serde
808
+ )
809
+ uncached_by_id = {t["thread_id"]: t for t in uncached}
810
+ for thread_id in needs_writes_count:
811
+ count = writes_counts.get(thread_id, 0)
812
+ thread = uncached_by_id[thread_id]
813
+ thread["message_count"] = count
814
+ _cache_message_count(thread_id, _thread_freshness(thread), count)
815
+
816
+
817
+ _SQLITE_MAX_VARIABLE_NUMBER = 500
818
+ """Max `?` placeholders per SQL query.
819
+
820
+ SQLite limits how many `?` parameters a single query can have (default 999,
821
+ lower on some builds). If a user accumulates hundreds of threads and the
822
+ `/threads` modal fetches them all at once, the `IN (?, ?, ...)` clause could
823
+ exceed that limit. We chunk to this size to stay safe.
824
+ """
825
+
826
+
827
+ async def _load_latest_checkpoint_summaries_batch(
828
+ conn: aiosqlite.Connection,
829
+ thread_ids: list[str],
830
+ serde: JsonPlusSerializer,
831
+ ) -> dict[str, _CheckpointSummary]:
832
+ """Batch-load the latest checkpoint summary for multiple threads.
833
+
834
+ Uses a window function to fetch the latest checkpoint per thread, issuing
835
+ one query per chunk for SQLite variable-limit safety.
836
+
837
+ Args:
838
+ conn: Database connection.
839
+ thread_ids: Thread IDs to look up.
840
+ serde: Serializer for decoding checkpoint blobs.
841
+
842
+ Returns:
843
+ Dict mapping thread IDs to their checkpoint summaries.
844
+ """
845
+ if not thread_ids:
846
+ return {}
847
+
848
+ results: dict[str, _CheckpointSummary] = {}
849
+
850
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
851
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
852
+ placeholders = ",".join("?" * len(chunk))
853
+ query = f"""
854
+ SELECT thread_id, type, checkpoint FROM (
855
+ SELECT thread_id, type, checkpoint,
856
+ ROW_NUMBER() OVER (
857
+ PARTITION BY thread_id ORDER BY checkpoint_id DESC
858
+ ) AS rn
859
+ FROM checkpoints
860
+ WHERE thread_id IN ({placeholders})
861
+ ) WHERE rn = 1
862
+ """ # noqa: S608 # placeholders built from len(chunk); user values use ? params
863
+ async with conn.execute(query, chunk) as cursor:
864
+ rows = await cursor.fetchall()
865
+
866
+ loop = asyncio.get_running_loop()
867
+ for row in rows:
868
+ tid, type_str, checkpoint_blob = row
869
+ if not type_str or not checkpoint_blob:
870
+ results[tid] = _CheckpointSummary(
871
+ message_count=None, initial_prompt=None
872
+ )
873
+ continue
874
+ try:
875
+ data = await loop.run_in_executor(
876
+ None, serde.loads_typed, (type_str, checkpoint_blob)
877
+ )
878
+ results[tid] = _summarize_checkpoint(data)
879
+ except Exception:
880
+ logger.warning(
881
+ "Failed to deserialize checkpoint for thread %s; "
882
+ "message count and initial prompt may be incomplete",
883
+ tid,
884
+ exc_info=True,
885
+ )
886
+ results[tid] = _CheckpointSummary(
887
+ message_count=None, initial_prompt=None
888
+ )
889
+
890
+ return results
891
+
892
+
893
+ async def _load_initial_prompts_from_writes_batch(
894
+ conn: aiosqlite.Connection,
895
+ thread_ids: list[str],
896
+ serde: JsonPlusSerializer,
897
+ ) -> dict[str, str | None]:
898
+ """Batch-load initial prompts from the LangGraph `writes` table.
899
+
900
+ For each thread, returns the first human/user message extracted from the
901
+ earliest write to the `messages` channel (ordered by `checkpoint_id` ASC,
902
+ then `idx` ASC).
903
+
904
+ Args:
905
+ conn: Database connection.
906
+ thread_ids: Thread IDs to look up.
907
+ serde: Serializer for decoding write blobs.
908
+
909
+ Returns:
910
+ Dict mapping thread IDs to their initial prompt text. Threads with no
911
+ write to the `messages` channel are absent from the result; threads
912
+ whose first such write decoded but contained no human/user entry map
913
+ to `None`.
914
+ """
915
+ if not thread_ids:
916
+ return {}
917
+
918
+ results: dict[str, str | None] = {}
919
+ loop = asyncio.get_running_loop()
920
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
921
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
922
+ placeholders = ",".join("?" * len(chunk))
923
+ query = f"""
924
+ SELECT thread_id, type, value FROM (
925
+ SELECT thread_id, type, value,
926
+ ROW_NUMBER() OVER (
927
+ PARTITION BY thread_id
928
+ ORDER BY checkpoint_id ASC, idx ASC
929
+ ) AS rn
930
+ FROM writes
931
+ WHERE thread_id IN ({placeholders}) AND channel = 'messages'
932
+ ) WHERE rn = 1
933
+ """ # noqa: S608 # placeholders built from len(chunk); user values use ? params
934
+ async with conn.execute(query, chunk) as cursor:
935
+ rows = await cursor.fetchall()
936
+
937
+ for row in rows:
938
+ tid, type_str, value_blob = row
939
+ if not type_str or not value_blob:
940
+ continue
941
+ try:
942
+ messages = await loop.run_in_executor(
943
+ None, serde.loads_typed, (type_str, value_blob)
944
+ )
945
+ except Exception:
946
+ logger.warning(
947
+ "Failed to deserialize initial messages write for thread %s",
948
+ tid,
949
+ exc_info=True,
950
+ )
951
+ continue
952
+ if not isinstance(messages, list):
953
+ continue
954
+ results[tid] = _initial_prompt_from_messages(cast("list[object]", messages))
955
+
956
+ return results
957
+
958
+
959
+ async def _load_message_counts_from_writes_batch(
960
+ conn: aiosqlite.Connection,
961
+ thread_ids: list[str],
962
+ serde: JsonPlusSerializer,
963
+ ) -> dict[str, int]:
964
+ """Reconstruct message counts from the LangGraph `writes` table.
965
+
966
+ For threads whose latest checkpoint does not inline the `messages` channel
967
+ value — a `DeltaChannel` between snapshots, where the deepagents SDK (>= 0.6)
968
+ applies LangGraph's `DeltaChannel` to `messages` — the full list is rebuilt
969
+ by replaying every `messages` write, then counted. We replay through
970
+ `add_messages` as a count-equivalent stand-in for the channel's actual
971
+ reducer (`_messages_delta_reducer`): both dedup by ID and honor
972
+ `RemoveMessage` / `REMOVE_ALL_MESSAGES`, so they produce the same final
973
+ message set. An `Overwrite` write resets the accumulator to its value,
974
+ matching the net effect of `DeltaChannel.replay_writes` (where the last
975
+ `Overwrite` is the reset point).
976
+
977
+ Reduction runs in a single worker-thread hop per chunk (decode is CPU-bound
978
+ and a long thread can have thousands of writes; dispatching per row both
979
+ serialized the work and added an executor round-trip each time). The common
980
+ append-and-clear history folds in one `add_messages` pass (linear), which is
981
+ why a busy thread no longer takes seconds to count. See
982
+ `_count_messages_from_deltas` for the fold and its exact-fold fallback.
983
+
984
+ Only the root namespace (`checkpoint_ns = ''`) is counted, matching both the
985
+ inline path and the conversation the `/threads` selector cares about;
986
+ subgraph (subagent) writes under the same `thread_id` are excluded.
987
+
988
+ Folding the *entire* write history (rather than walking the head
989
+ checkpoint's parent chain) is intentional and matches what dcode shows when
990
+ a thread is opened: it reads state via `aget_state` without a
991
+ `checkpoint_id`, which applies pending writes (`apply_pending_writes=True`),
992
+ so the latest checkpoint's not-yet-committed `messages` writes are part of
993
+ the user-visible list and must be counted. dcode only ever appends to the
994
+ latest checkpoint (no time travel, no `checkpoint_id`-targeted
995
+ `aupdate_state`), so histories are linear and the full fold equals the
996
+ head-of-chain reconstruction. A forked/abandoned branch (which dcode does
997
+ not create) is the only case where this could over-count.
998
+
999
+ Args:
1000
+ conn: Database connection.
1001
+ thread_ids: Thread IDs to look up.
1002
+ serde: Serializer for decoding write blobs.
1003
+
1004
+ Returns:
1005
+ Dict mapping each thread ID with at least one decodable `messages`
1006
+ write to its reconstructed message count. Threads with no such
1007
+ writes are absent from the result.
1008
+ """
1009
+ if not thread_ids:
1010
+ return {}
1011
+
1012
+ loop = asyncio.get_running_loop()
1013
+ results: dict[str, int] = {}
1014
+ # Chunks partition by thread, so every write for a given thread lands in the
1015
+ # same query; each thread is counted exactly once. Ordering by
1016
+ # (checkpoint_id, task_id, idx) replays deltas oldest-to-newest, matching how
1017
+ # LangGraph applies them on load.
1018
+ for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
1019
+ chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
1020
+ placeholders = ",".join("?" * len(chunk))
1021
+ query = f"""
1022
+ SELECT thread_id, type, value
1023
+ FROM writes
1024
+ WHERE thread_id IN ({placeholders})
1025
+ AND checkpoint_ns = ''
1026
+ AND channel = 'messages'
1027
+ ORDER BY thread_id, checkpoint_id ASC, task_id ASC, idx ASC
1028
+ """ # noqa: S608 # placeholders built from len(chunk); user values use ? params
1029
+ async with conn.execute(query, chunk) as cursor:
1030
+ rows = await cursor.fetchall()
1031
+
1032
+ chunk_counts = await loop.run_in_executor(
1033
+ None, _reduce_message_write_rows, list(rows), serde
1034
+ )
1035
+ results.update(chunk_counts)
1036
+
1037
+ return results
1038
+
1039
+
1040
+ def _reduce_message_write_rows(
1041
+ rows: list[tuple[str, str | None, bytes | None]],
1042
+ serde: JsonPlusSerializer,
1043
+ ) -> dict[str, int]:
1044
+ """Decode `messages`-channel write rows and count messages per thread.
1045
+
1046
+ Runs synchronously in a worker thread. Rows must be ordered so each thread's
1047
+ deltas are oldest-to-newest. Undecodable rows are skipped (logged), matching
1048
+ the per-row error handling of the previous implementation.
1049
+
1050
+ Returns:
1051
+ Mapping of thread ID to reconstructed message count.
1052
+ """
1053
+ deltas_by_thread: dict[str, list[Any]] = {}
1054
+ for tid, type_str, value_blob in rows:
1055
+ if not type_str or not value_blob:
1056
+ continue
1057
+ try:
1058
+ delta = serde.loads_typed((type_str, value_blob))
1059
+ except Exception:
1060
+ logger.warning(
1061
+ "Failed to replay messages write for thread %s; "
1062
+ "message count may be inaccurate",
1063
+ tid,
1064
+ exc_info=True,
1065
+ )
1066
+ continue
1067
+ deltas_by_thread.setdefault(tid, []).append(delta)
1068
+
1069
+ counts: dict[str, int] = {}
1070
+ for tid, deltas in deltas_by_thread.items():
1071
+ try:
1072
+ counts[tid] = _count_messages_from_deltas(deltas)
1073
+ except Exception:
1074
+ # Keep one malformed thread from failing the whole `threads list`
1075
+ # load: skip it (its count is simply absent) rather than propagating.
1076
+ logger.warning(
1077
+ "Failed to count messages for thread %s; omitting its count",
1078
+ tid,
1079
+ exc_info=True,
1080
+ )
1081
+ return counts
1082
+
1083
+
1084
+ def _count_messages_from_deltas(deltas: list[Any]) -> int:
1085
+ """Count messages from an ordered list of `messages`-channel write deltas.
1086
+
1087
+ Fast path: appends and full-clears (`REMOVE_ALL_MESSAGES`, `Overwrite`) fold
1088
+ into one `add_messages` pass — O(n) instead of the O(n^2) incremental fold,
1089
+ so threads with thousands of writes count in milliseconds. For these ops the
1090
+ single-pass result is count-equivalent to the sequential fold (both dedup by
1091
+ ID, and clears collapse to the post-clear tail).
1092
+
1093
+ Slow path: a specific `RemoveMessage` (delete-by-ID) or any reducer error
1094
+ falls back to the exact sequential fold as a conservative measure. A
1095
+ delete-by-ID concatenated into the single `buffer` can make batch
1096
+ `add_messages` raise (the target ID may be absent at that buffer position),
1097
+ and we do not rely on unproven count-equivalence of batched removal. In
1098
+ practice the two folds still agree on the count for these histories; the
1099
+ sequential fold simply guarantees it. Such deletes are rare in linear dcode
1100
+ histories, so the common case stays on the fast path.
1101
+
1102
+ Returns:
1103
+ Number of messages after reducing the deltas.
1104
+ """
1105
+ from langchain_core.messages import RemoveMessage
1106
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
1107
+ from langgraph.types import Overwrite
1108
+
1109
+ buffer: list[Any] = []
1110
+ needs_exact_fold = False
1111
+ for delta in deltas:
1112
+ if isinstance(delta, Overwrite):
1113
+ value = delta.value
1114
+ buffer = list(value) if isinstance(value, list) else []
1115
+ continue
1116
+ items = delta if isinstance(delta, list) else [delta]
1117
+ for item in items:
1118
+ if isinstance(item, RemoveMessage):
1119
+ if item.id == REMOVE_ALL_MESSAGES:
1120
+ buffer = []
1121
+ else:
1122
+ needs_exact_fold = True
1123
+ break
1124
+ else:
1125
+ buffer.append(item)
1126
+ if needs_exact_fold:
1127
+ break
1128
+
1129
+ if not needs_exact_fold:
1130
+ try:
1131
+ return len(cast("list[Any]", add_messages([], buffer)))
1132
+ except Exception:
1133
+ logger.debug(
1134
+ "Batched message-count fold failed; using sequential fold",
1135
+ exc_info=True,
1136
+ )
1137
+
1138
+ return _incremental_message_count(deltas)
1139
+
1140
+
1141
+ def _incremental_message_count(deltas: list[Any]) -> int:
1142
+ """Count messages by folding deltas sequentially through `add_messages`.
1143
+
1144
+ Exact reference reduction: applies one delta at a time, resetting on
1145
+ `Overwrite` and skipping any delta the reducer rejects (e.g. a delete for an
1146
+ absent ID). Used as the fallback when the batched fast path cannot guarantee
1147
+ a matching count.
1148
+
1149
+ Returns:
1150
+ Number of messages after the sequential fold.
1151
+ """
1152
+ from langgraph.graph.message import add_messages
1153
+ from langgraph.types import Overwrite
1154
+
1155
+ reduced: list[Any] = []
1156
+ for delta in deltas:
1157
+ if isinstance(delta, Overwrite):
1158
+ value = delta.value
1159
+ reduced = list(value) if isinstance(value, list) else []
1160
+ continue
1161
+ try:
1162
+ reduced = cast("list[Any]", add_messages(reduced, delta))
1163
+ except Exception:
1164
+ logger.warning(
1165
+ "Failed to replay messages write; message count may be inaccurate",
1166
+ exc_info=True,
1167
+ )
1168
+ continue
1169
+ return len(reduced)
1170
+
1171
+
1172
+ def _summarize_checkpoint(data: object) -> _CheckpointSummary:
1173
+ """Extract message count and initial human prompt from checkpoint data.
1174
+
1175
+ Returns:
1176
+ Structured summary for the decoded checkpoint payload.
1177
+ """
1178
+ messages = _checkpoint_messages(data)
1179
+ return _CheckpointSummary(
1180
+ message_count=len(messages) if messages is not None else None,
1181
+ initial_prompt=_initial_prompt_from_messages(messages or []),
1182
+ )
1183
+
1184
+
1185
+ def _checkpoint_messages(data: object) -> list[object] | None:
1186
+ """Return inlined checkpoint messages, or `None` when not inlined.
1187
+
1188
+ A `None` return distinguishes a checkpoint that omits the `messages`
1189
+ channel entirely (a `DeltaChannel` between snapshots, where the deepagents
1190
+ SDK applies LangGraph's `DeltaChannel` to `messages` as of v0.6) from one
1191
+ that inlines an empty list. The former requires reconstructing the count
1192
+ from the `writes` table; the latter is a genuine zero.
1193
+ """
1194
+ if not isinstance(data, dict):
1195
+ return None
1196
+
1197
+ payload = cast("dict[str, object]", data)
1198
+ channel_values = payload.get("channel_values")
1199
+ if not isinstance(channel_values, dict):
1200
+ return None
1201
+
1202
+ channel_values_dict = cast("dict[str, object]", channel_values)
1203
+ messages = channel_values_dict.get("messages")
1204
+ if not isinstance(messages, list):
1205
+ return None
1206
+
1207
+ return cast("list[object]", messages)
1208
+
1209
+
1210
+ def _initial_prompt_from_messages(messages: list[object]) -> str | None:
1211
+ """Return the first non-system human message content from a message list.
1212
+
1213
+ Accepts both LangChain `HumanMessage` objects (with `type == "human"`) and
1214
+ plain dicts in OpenAI chat shape (`{"role": "user", "content": ...}`). The
1215
+ first write to the `messages` channel is the raw user input passed to the
1216
+ agent, which is preserved verbatim as a dict; subsequent writes are
1217
+ serialized `BaseMessage` instances produced after the model runs.
1218
+
1219
+ Synthetic `[SYSTEM]`-prefixed human messages (e.g. an interrupt
1220
+ cancellation notice) are skipped so they never surface as a thread's prompt.
1221
+ """
1222
+ for msg in messages:
1223
+ if getattr(msg, "type", None) == "human":
1224
+ prompt = _coerce_prompt_text(getattr(msg, "content", None))
1225
+ elif isinstance(msg, dict):
1226
+ msg_dict = cast("dict[str, object]", msg)
1227
+ role = msg_dict.get("role")
1228
+ type_ = msg_dict.get("type")
1229
+ if role not in {"user", "human"} and type_ != "human":
1230
+ continue
1231
+ prompt = _coerce_prompt_text(msg_dict.get("content"))
1232
+ else:
1233
+ continue
1234
+ if prompt is not None and prompt.startswith(SYSTEM_MESSAGE_PREFIX):
1235
+ continue
1236
+ return prompt
1237
+ return None
1238
+
1239
+
1240
+ def _coerce_prompt_text(content: object) -> str | None:
1241
+ """Normalize checkpoint message content into displayable text.
1242
+
1243
+ Returns:
1244
+ Displayable prompt text, or `None` when the content is empty.
1245
+ """
1246
+ if isinstance(content, str):
1247
+ return content
1248
+ if isinstance(content, list):
1249
+ parts: list[str] = []
1250
+ for part in content:
1251
+ if isinstance(part, dict):
1252
+ part_dict = cast("dict[str, object]", part)
1253
+ text = part_dict.get("text")
1254
+ parts.append(text if isinstance(text, str) else "")
1255
+ else:
1256
+ parts.append(str(part))
1257
+ joined = " ".join(parts).strip()
1258
+ return joined or None
1259
+ if content is None:
1260
+ return None
1261
+ return str(content)
1262
+
1263
+
1264
+ async def get_most_recent(
1265
+ agent_name: str | None = None,
1266
+ *,
1267
+ exclude_thread_id: str | None = None,
1268
+ ) -> str | None:
1269
+ """Get the most recent thread, optionally agent-filtered and/or excluding a thread.
1270
+
1271
+ Args:
1272
+ agent_name: Return only threads created by this agent.
1273
+ exclude_thread_id: Ignore this thread when selecting the most recent one.
1274
+
1275
+ Returns:
1276
+ Most recent thread ID, or `None` if no matching threads exist.
1277
+ """
1278
+ async with _connect() as conn:
1279
+ if not await _table_exists(conn, "checkpoints"):
1280
+ return None
1281
+
1282
+ if agent_name and exclude_thread_id:
1283
+ query = """
1284
+ SELECT thread_id FROM checkpoints
1285
+ WHERE json_extract(metadata, '$.agent_name') = ?
1286
+ AND thread_id != ?
1287
+ ORDER BY checkpoint_id DESC
1288
+ LIMIT 1
1289
+ """
1290
+ params: tuple[str, ...] = (agent_name, exclude_thread_id)
1291
+ elif agent_name:
1292
+ query = """
1293
+ SELECT thread_id FROM checkpoints
1294
+ WHERE json_extract(metadata, '$.agent_name') = ?
1295
+ ORDER BY checkpoint_id DESC
1296
+ LIMIT 1
1297
+ """
1298
+ params = (agent_name,)
1299
+ elif exclude_thread_id:
1300
+ query = """
1301
+ SELECT thread_id FROM checkpoints
1302
+ WHERE thread_id != ?
1303
+ ORDER BY checkpoint_id DESC
1304
+ LIMIT 1
1305
+ """
1306
+ params = (exclude_thread_id,)
1307
+ else:
1308
+ query = (
1309
+ "SELECT thread_id FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 1"
1310
+ )
1311
+ params = ()
1312
+
1313
+ async with conn.execute(query, params) as cursor:
1314
+ row = await cursor.fetchone()
1315
+ return row[0] if row else None
1316
+
1317
+
1318
+ async def get_thread_agent(thread_id: str) -> str | None:
1319
+ """Get agent_name for a thread.
1320
+
1321
+ Returns:
1322
+ Agent name associated with the thread, or None if not found.
1323
+ """
1324
+ async with _connect() as conn:
1325
+ if not await _table_exists(conn, "checkpoints"):
1326
+ return None
1327
+
1328
+ query = """
1329
+ SELECT json_extract(metadata, '$.agent_name')
1330
+ FROM checkpoints
1331
+ WHERE thread_id = ?
1332
+ LIMIT 1
1333
+ """
1334
+ async with conn.execute(query, (thread_id,)) as cursor:
1335
+ row = await cursor.fetchone()
1336
+ return row[0] if row else None
1337
+
1338
+
1339
+ async def get_thread_cwd(thread_id: str) -> str | None:
1340
+ """Get the most recently stored cwd for a thread.
1341
+
1342
+ Args:
1343
+ thread_id: The thread whose stored cwd to look up.
1344
+
1345
+ Returns:
1346
+ Most recent cwd for the thread, or None if not found.
1347
+ """
1348
+ async with _connect() as conn:
1349
+ if not await _table_exists(conn, "checkpoints"):
1350
+ return None
1351
+
1352
+ query = """
1353
+ SELECT json_extract(metadata, '$.cwd')
1354
+ FROM checkpoints
1355
+ WHERE thread_id = ? AND json_extract(metadata, '$.cwd') IS NOT NULL
1356
+ ORDER BY checkpoint_id DESC
1357
+ LIMIT 1
1358
+ """
1359
+ async with conn.execute(query, (thread_id,)) as cursor:
1360
+ row = await cursor.fetchone()
1361
+ value = row[0] if row else None
1362
+ return value if isinstance(value, str) and value else None
1363
+
1364
+
1365
+ async def thread_exists(thread_id: str) -> bool:
1366
+ """Check if a thread exists in checkpoints.
1367
+
1368
+ Returns:
1369
+ True if thread exists, False otherwise.
1370
+ """
1371
+ async with _connect() as conn:
1372
+ if not await _table_exists(conn, "checkpoints"):
1373
+ return False
1374
+
1375
+ query = "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1"
1376
+ async with conn.execute(query, (thread_id,)) as cursor:
1377
+ row = await cursor.fetchone()
1378
+ return row is not None
1379
+
1380
+
1381
+ async def find_similar_threads(thread_id: str, limit: int = 3) -> list[str]:
1382
+ """Find threads whose IDs start with the given prefix.
1383
+
1384
+ Args:
1385
+ thread_id: Prefix to match against thread IDs.
1386
+ limit: Maximum number of matching threads to return.
1387
+
1388
+ Returns:
1389
+ List of thread IDs that begin with the given prefix.
1390
+ """
1391
+ async with _connect() as conn:
1392
+ if not await _table_exists(conn, "checkpoints"):
1393
+ return []
1394
+
1395
+ query = """
1396
+ SELECT DISTINCT thread_id
1397
+ FROM checkpoints
1398
+ WHERE thread_id LIKE ?
1399
+ ORDER BY thread_id
1400
+ LIMIT ?
1401
+ """
1402
+ prefix = thread_id + "%"
1403
+ async with conn.execute(query, (prefix, limit)) as cursor:
1404
+ rows = await cursor.fetchall()
1405
+ return [r[0] for r in rows]
1406
+
1407
+
1408
+ async def delete_thread(thread_id: str) -> bool:
1409
+ """Delete thread checkpoints.
1410
+
1411
+ Returns:
1412
+ True if thread was deleted, False if not found.
1413
+ """
1414
+ async with _connect() as conn:
1415
+ if not await _table_exists(conn, "checkpoints"):
1416
+ return False
1417
+
1418
+ cursor = await conn.execute(
1419
+ "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
1420
+ )
1421
+ deleted = cursor.rowcount > 0
1422
+ if await _table_exists(conn, "writes"):
1423
+ await conn.execute("DELETE FROM writes WHERE thread_id = ?", (thread_id,))
1424
+ await conn.commit()
1425
+ if deleted:
1426
+ _message_count_cache.pop(thread_id, None)
1427
+ for key, rows in list(_recent_threads_cache.items()):
1428
+ filtered = [row for row in rows if row["thread_id"] != thread_id]
1429
+ _recent_threads_cache[key] = filtered
1430
+ return deleted
1431
+
1432
+
1433
+ @asynccontextmanager
1434
+ async def get_checkpointer() -> AsyncIterator[AsyncSqliteSaver]:
1435
+ """Get AsyncSqliteSaver for the global database.
1436
+
1437
+ Yields:
1438
+ AsyncSqliteSaver instance for checkpoint persistence.
1439
+ """
1440
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
1441
+
1442
+ _patch_aiosqlite()
1443
+
1444
+ saver: AsyncSqliteSaver | None = None
1445
+ try:
1446
+ async with AsyncSqliteSaver.from_conn_string(
1447
+ str(get_db_path())
1448
+ ) as checkpointer:
1449
+ saver = checkpointer
1450
+ yield checkpointer
1451
+ finally:
1452
+ if saver is not None:
1453
+ conn = getattr(saver, "conn", None)
1454
+ if conn is not None:
1455
+ await _drain_aiosqlite_worker(conn)
1456
+
1457
+
1458
+ _DEFAULT_THREAD_LIMIT = 20
1459
+
1460
+
1461
+ def get_thread_limit() -> int:
1462
+ """Read the thread listing limit from `DA_CLI_RECENT_THREADS`.
1463
+
1464
+ Falls back to `_DEFAULT_THREAD_LIMIT` when the variable is unset or contains
1465
+ a non-integer value. The result is clamped to a minimum of 1.
1466
+
1467
+ Returns:
1468
+ Number of threads to display.
1469
+ """
1470
+ import os
1471
+
1472
+ raw = os.environ.get("DA_CLI_RECENT_THREADS")
1473
+ if raw is None:
1474
+ return _DEFAULT_THREAD_LIMIT
1475
+ try:
1476
+ return max(1, int(raw))
1477
+ except ValueError:
1478
+ logger.warning(
1479
+ "Invalid DA_CLI_RECENT_THREADS value %r, using default %d",
1480
+ raw,
1481
+ _DEFAULT_THREAD_LIMIT,
1482
+ )
1483
+ return _DEFAULT_THREAD_LIMIT
1484
+
1485
+
1486
+ async def list_threads_command(
1487
+ agent_name: str | None = None,
1488
+ limit: int | None = None,
1489
+ sort_by: str | None = None,
1490
+ branch: str | None = None,
1491
+ cwd: str | None = None,
1492
+ verbose: bool = False,
1493
+ relative: bool | None = None,
1494
+ *,
1495
+ output_format: OutputFormat = "text",
1496
+ ) -> None:
1497
+ """CLI handler for `deepagents threads list`.
1498
+
1499
+ Fetches and displays a table of recent conversation threads, optionally
1500
+ filtered by agent name, git branch, or working directory.
1501
+
1502
+ Args:
1503
+ agent_name: Only show threads belonging to this agent.
1504
+
1505
+ When `None`, threads for all agents are shown.
1506
+ limit: Maximum number of threads to display.
1507
+
1508
+ When `None`, reads from `DA_CLI_RECENT_THREADS` or falls back to
1509
+ the default.
1510
+ sort_by: Sort field — `"updated"` or `"created"`.
1511
+
1512
+ When `None`, reads from config (`~/.deepagents/config.toml`).
1513
+ branch: Only show threads from this git branch.
1514
+ cwd: Only show threads whose stored `cwd` metadata equals this path
1515
+ (exact string match — no normalization or prefix matching). When
1516
+ `None`, no cwd filter is applied. Threads without a stored `cwd`
1517
+ (older rows) are excluded when this is set.
1518
+ verbose: When `True`, show all columns (branch, created, prompt).
1519
+ relative: Show timestamps as relative time (e.g., '5m ago').
1520
+
1521
+ When `None`, reads from config (`~/.deepagents/config.toml`).
1522
+ output_format: Output format — `'text'` (Rich) or `'json'`.
1523
+ """
1524
+ from deepagents_code.model_config import (
1525
+ load_thread_relative_time,
1526
+ load_thread_sort_order,
1527
+ )
1528
+
1529
+ if sort_by is None:
1530
+ raw = load_thread_sort_order()
1531
+ sort_by = "created" if raw == "created_at" else "updated"
1532
+ if relative is None:
1533
+ relative = load_thread_relative_time()
1534
+
1535
+ fmt_ts = format_relative_timestamp if relative else format_timestamp
1536
+
1537
+ limit = get_thread_limit() if limit is None else max(1, limit)
1538
+
1539
+ threads = await list_threads(
1540
+ agent_name,
1541
+ limit=limit,
1542
+ include_message_count=True,
1543
+ sort_by=sort_by,
1544
+ branch=branch,
1545
+ cwd=cwd,
1546
+ )
1547
+
1548
+ if verbose and threads:
1549
+ await populate_thread_checkpoint_details(
1550
+ threads, include_message_count=False, include_initial_prompt=True
1551
+ )
1552
+
1553
+ if output_format == "json":
1554
+ from deepagents_code.output import write_json
1555
+
1556
+ write_json("threads list", list(threads))
1557
+ return
1558
+
1559
+ from rich.markup import escape as escape_markup
1560
+ from rich.table import Table
1561
+
1562
+ from deepagents_code import theme
1563
+ from deepagents_code.config import console
1564
+
1565
+ if not threads:
1566
+ filters = []
1567
+ if agent_name:
1568
+ filters.append(f"agent '{escape_markup(agent_name)}'")
1569
+ if branch:
1570
+ filters.append(f"branch '{escape_markup(branch)}'")
1571
+ if cwd:
1572
+ filters.append(f"cwd '{escape_markup(cwd)}'")
1573
+ if filters:
1574
+ console.print(
1575
+ f"[yellow]No threads found for {' and '.join(filters)}.[/yellow]"
1576
+ )
1577
+ else:
1578
+ console.print("[yellow]No threads found.[/yellow]")
1579
+ if cwd:
1580
+ # Older threads predating cwd-metadata storage are silently
1581
+ # filtered out by the `json_extract` equality match. Tell the
1582
+ # user explicitly when unfiltered rows exist so they don't think
1583
+ # their history is gone.
1584
+ unfiltered = await list_threads(
1585
+ agent_name,
1586
+ limit=limit,
1587
+ include_message_count=False,
1588
+ sort_by=sort_by,
1589
+ branch=branch,
1590
+ )
1591
+ legacy_count = sum(1 for t in unfiltered if not t.get("cwd"))
1592
+ if legacy_count:
1593
+ console.print(
1594
+ f"[dim]{legacy_count} older thread"
1595
+ f"{'s have' if legacy_count != 1 else ' has'} "
1596
+ "no cwd metadata; run without --cwd to see "
1597
+ f"{'them' if legacy_count != 1 else 'it'}.[/dim]"
1598
+ )
1599
+ console.print("[dim]Start a conversation with: deepagents[/dim]")
1600
+ return
1601
+
1602
+ title_parts = []
1603
+ if agent_name:
1604
+ title_parts.append(f"agent '{escape_markup(agent_name)}'")
1605
+ if branch:
1606
+ title_parts.append(f"branch '{escape_markup(branch)}'")
1607
+ if cwd:
1608
+ title_parts.append(f"cwd '{escape_markup(cwd)}'")
1609
+
1610
+ title_filter = f" for {' and '.join(title_parts)}" if title_parts else ""
1611
+ sort_label = "created" if sort_by == "created" else "updated"
1612
+ title = f"Recent Threads{title_filter} (last {limit}, by {sort_label})"
1613
+
1614
+ table = Table(title=title, show_header=True, header_style=f"bold {theme.PRIMARY}")
1615
+ table.add_column("Thread ID", style="bold")
1616
+ table.add_column("Agent")
1617
+ table.add_column("Messages", justify="right")
1618
+ if verbose:
1619
+ table.add_column("Created")
1620
+ table.add_column("Updated" if sort_by == "updated" else "Last Used")
1621
+ if verbose:
1622
+ table.add_column("Branch")
1623
+ table.add_column("Location")
1624
+ table.add_column("Prompt", max_width=40, no_wrap=True)
1625
+
1626
+ prompt_max = 40
1627
+
1628
+ for t in threads:
1629
+ row: list[str] = [
1630
+ t["thread_id"],
1631
+ t["agent_name"] or "unknown",
1632
+ str(t.get("message_count", 0)),
1633
+ ]
1634
+ if verbose:
1635
+ row.append(fmt_ts(t.get("created_at")))
1636
+ row.append(fmt_ts(t.get("updated_at")))
1637
+ if verbose:
1638
+ prompt = " ".join((t.get("initial_prompt") or "").split())
1639
+ if len(prompt) > prompt_max:
1640
+ prompt = prompt[: prompt_max - 3] + "..."
1641
+ row.extend(
1642
+ [
1643
+ t.get("git_branch") or "",
1644
+ format_path(t.get("cwd")),
1645
+ prompt,
1646
+ ]
1647
+ )
1648
+ table.add_row(*row)
1649
+
1650
+ console.print()
1651
+ console.print(table)
1652
+ if len(threads) >= limit:
1653
+ console.print(
1654
+ f"[dim]Showing last {limit} threads. "
1655
+ "Override with -n/--limit or DA_CLI_RECENT_THREADS.[/dim]"
1656
+ )
1657
+ console.print()
1658
+
1659
+
1660
+ async def delete_thread_command(
1661
+ thread_id: str,
1662
+ *,
1663
+ dry_run: bool = False,
1664
+ output_format: OutputFormat = "text",
1665
+ ) -> None:
1666
+ """CLI handler for: deepagents threads delete.
1667
+
1668
+ Args:
1669
+ thread_id: ID of the thread to delete.
1670
+ dry_run: If `True`, print what would happen without making changes.
1671
+ output_format: Output format — `'text'` (Rich) or `'json'`.
1672
+ """
1673
+ if dry_run:
1674
+ exists = await thread_exists(thread_id)
1675
+ if output_format == "json":
1676
+ from deepagents_code.output import write_json
1677
+
1678
+ write_json(
1679
+ "threads delete",
1680
+ {"thread_id": thread_id, "exists": exists, "dry_run": True},
1681
+ )
1682
+ return
1683
+
1684
+ from rich.markup import escape as escape_markup
1685
+
1686
+ from deepagents_code.config import console
1687
+
1688
+ escaped_id = escape_markup(thread_id)
1689
+ if exists:
1690
+ console.print(f"Would delete thread '{escaped_id}'.")
1691
+ else:
1692
+ console.print(f"Thread '{escaped_id}' not found. Nothing to delete.")
1693
+ console.print("No changes made.", style="dim")
1694
+ return
1695
+
1696
+ deleted = await delete_thread(thread_id)
1697
+
1698
+ if output_format == "json":
1699
+ from deepagents_code.output import write_json
1700
+
1701
+ write_json("threads delete", {"thread_id": thread_id, "deleted": deleted})
1702
+ return
1703
+
1704
+ from rich.markup import escape as escape_markup
1705
+
1706
+ from deepagents_code import theme
1707
+ from deepagents_code.config import console
1708
+
1709
+ escaped_id = escape_markup(thread_id)
1710
+ if deleted:
1711
+ console.print(f"[green]Thread '{escaped_id}' deleted.[/green]")
1712
+ else:
1713
+ console.print(
1714
+ f"Thread '{escaped_id}' not found or already deleted.",
1715
+ style=theme.MUTED,
1716
+ )