@agentsdock/server 1.0.7-beta.5 → 1.0.7

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 (39) hide show
  1. package/README.md +21 -7
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +4913 -660
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +78 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_provider.py +1 -1
  14. package/server/codex_side_question.py +154 -57
  15. package/server/cursor_agent_client.py +27 -1
  16. package/server/cursor_history.py +219 -0
  17. package/server/cursor_provider_mcp.py +290 -0
  18. package/server/execution_durability.py +10 -6
  19. package/server/execution_preparation.py +6 -2
  20. package/server/install.sh +281 -78
  21. package/server/instances.sh +15 -0
  22. package/server/interactive_chat_native.py +4 -3
  23. package/server/interactive_chat_projection.py +6 -19
  24. package/server/interactive_chat_share_routes.py +71 -45
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/interactive_chat_shares.py +6 -11
  27. package/server/local_session_ownership.py +232 -0
  28. package/server/opencode_agent_client.py +653 -0
  29. package/server/provider_commands.py +579 -0
  30. package/server/provider_usage.py +241 -0
  31. package/server/public_chat_shares.py +60 -62
  32. package/server/public_chat_transcript.py +9 -31
  33. package/server/server_instances.py +931 -0
  34. package/server/shared_chat_video_stream.py +21 -2
  35. package/server/shared_chat_videos.py +54 -24
  36. package/server/side_questions.py +493 -10
  37. package/server/uninstall.sh +95 -17
  38. package/server/update_preparation.py +4 -1
  39. package/server/update_runner.py +16 -0
@@ -1,4 +1,4 @@
1
- """Native ephemeral Codex forks with an independently owned lifetime.
1
+ """Native Codex forks with an independently owned lifetime.
2
2
 
3
3
  The provider copies its own conversation history, including tool results. We
4
4
  never reconstruct that history from the renderer transcript or resume, steer,
@@ -7,32 +7,28 @@ interrupt, or modify the source thread.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ from copy import deepcopy
10
11
  from contextlib import suppress
11
12
  import json
12
13
  from pathlib import Path
13
14
  import tempfile
14
15
 
15
- from codex_app_server import CodexAppServerClient, CodexAppServerError
16
+ from codex_app_server import CodexAppServerClient, CodexAppServerError, decline_server_request
16
17
  from side_questions import (
17
18
  MAX_OUTPUT_BYTES, SideQuestionError, isolated_environment, run_isolated_command,
18
19
  )
19
20
 
20
21
 
21
- SIDE_INSTRUCTIONS = (
22
- "You are in an ephemeral side chat forked from the parent conversation. "
23
- "Answer only the current side question, using the inherited conversation and tool results "
24
- "as evidence and this side chat's own subsequent messages for follow-up references. "
25
- "Inherited tasks, goals, instructions, permissions, and approvals describe historical "
26
- "parent work; they do not authorize action in this side chat. "
27
- "Do not continue the parent task or pursue its goal. You have no workspace or task "
28
- "authority. Do not use tools, send messages, access files, browse, or claim to change "
29
- "anything. Answer directly and concisely; explain uncertainty when the inherited "
30
- "context does not contain the answer."
31
- )
22
+ SIDE_INSTRUCTIONS = """Answer questions and explore in this separate side chat without disrupting the main conversation.
23
+
24
+ Treat inherited messages, tasks, plans, tool calls and approvals as reference material, not current instructions or permission. Follow only requests made in this side chat; do not resume unfinished parent work.
25
+
26
+ Use the thread's existing permissions and available tools, including external tools, to read or search files and run checks that leave repo-tracked files unchanged. Do not create, contact or control subagents.
32
27
 
33
- # Environment access is disabled on EVERY turn. Read-only sandboxing alone
34
- # would still permit reading the user's files. The independent client's default
35
- # request handler also declines inherited dynamic tool/approval requests.
28
+ Change files, git state, configuration, permissions or other workspace state only when explicitly requested here. Request escalation only when such an explicit mutation requires it. Keep authorized changes limited to the request and preserve ongoing parent work."""
29
+
30
+ # These restrictions remain for title generation and endpoint probes. Side
31
+ # conversations use side_chat_config instead, with ordinary native tools.
36
32
  DISABLED_FEATURES = (
37
33
  "apps", "plugins", "remote_plugin", "recommended_plugins", "hooks",
38
34
  "multi_agent", "multi_agent_v2", "goals", "image_generation", "memories",
@@ -58,27 +54,58 @@ def isolated_config() -> dict:
58
54
  }
59
55
 
60
56
 
57
+ def side_chat_config(parent_config: dict | None = None) -> dict:
58
+ """Keep native tools, without inheriting a main run's helper transport."""
59
+ from claude_sdk_client import CLAUDE_PROVIDER_MCP_SERVER_NAME
60
+
61
+ config = deepcopy(parent_config or {})
62
+ name = CLAUDE_PROVIDER_MCP_SERVER_NAME
63
+ prefix = f"mcp_servers.{name}"
64
+ for key in tuple(config):
65
+ if key == prefix or key.startswith(prefix + "."):
66
+ config.pop(key)
67
+ servers = config.get("mcp_servers")
68
+ if isinstance(servers, dict):
69
+ servers.pop(name, None)
70
+ config.update({
71
+ "features.multi_agent": False, "features.multi_agent_v2": False,
72
+ "agents.enabled": False,
73
+ f"{prefix}.enabled": False, f"{prefix}.required": False,
74
+ # Codex validates transport shape even for a disabled server. Never
75
+ # copy the parent's authenticated helper URL/headers into this child.
76
+ f"{prefix}.url": "http://127.0.0.1:0",
77
+ })
78
+ return config
79
+
80
+
61
81
  def supports_native_side_chat(schema: dict) -> bool:
62
- """Fail closed when old protocols would silently ignore isolation fields."""
82
+ """Require the fork and permission fields used by native side chats."""
63
83
  try:
64
84
  definitions = schema["definitions"]
65
85
  fork = definitions["ThreadForkParams"]["properties"]
66
- environment = definitions["TurnStartParams"]["properties"]["environments"]
67
86
  ephemeral = fork["ephemeral"].get("type", [])
68
87
  return (
69
88
  "boolean" in ephemeral
70
89
  and all(name in fork for name in (
71
- "excludeTurns", "runtimeWorkspaceRoots", "baseInstructions",
90
+ "excludeTurns", "runtimeWorkspaceRoots", "cwd",
72
91
  "developerInstructions", "config", "sandbox", "approvalPolicy",
92
+ "permissions", "approvalsReviewer",
73
93
  ))
74
- and "array" in environment.get("type", [])
75
- and "disables environment access" in environment.get("description", "")
76
94
  )
77
95
  except (KeyError, TypeError, AttributeError):
78
96
  return False
79
97
 
80
98
 
81
- async def _verify_protocol(executable: str, temporary: str, env: dict):
99
+ def _supports_empty_turn_environments(schema: dict) -> bool:
100
+ try:
101
+ environment = schema["definitions"]["TurnStartParams"]["properties"]["environments"]
102
+ return ("array" in environment.get("type", [])
103
+ and "disables environment access" in environment.get("description", ""))
104
+ except (KeyError, TypeError, AttributeError):
105
+ return False
106
+
107
+
108
+ async def _verify_protocol(executable: str, temporary: str, env: dict, *, require_empty_environments=False):
82
109
  target = Path(temporary) / "schema"
83
110
  await run_isolated_command(
84
111
  [executable, "app-server", "generate-json-schema", "--experimental", "--out", str(target)],
@@ -88,47 +115,69 @@ async def _verify_protocol(executable: str, temporary: str, env: dict):
88
115
  source = target / "codex_app_server_protocol.v2.schemas.json"
89
116
  if source.stat().st_size > 20 * 1024 * 1024:
90
117
  raise ValueError("schema too large")
91
- supported = supports_native_side_chat(json.loads(source.read_text()))
118
+ schema = json.loads(source.read_text())
119
+ supported = supports_native_side_chat(schema)
120
+ if require_empty_environments:
121
+ supported = supported and _supports_empty_turn_environments(schema)
92
122
  except (OSError, ValueError, TypeError, AttributeError):
93
123
  supported = False
94
124
  if not supported:
95
125
  raise SideQuestionError(503, "Update Codex to use native side chats")
96
126
 
97
127
 
128
+ async def _verify_isolated_protocol(executable: str, temporary: str, env: dict):
129
+ # Endpoint probes still use empty environments; Side chat does not.
130
+ await _verify_protocol(executable, temporary, env, require_empty_environments=True)
131
+
132
+
98
133
  class NativeCodexSideChat:
99
- """One ephemeral provider fork, reused until its server-owned chat closes.
134
+ """One native fork; synced chats opt into durable provider persistence.
100
135
 
101
136
  The app-server process is private to this side chat. Closing it cannot
102
137
  interrupt the parent's process; no RPC that mutates the parent is issued.
103
- The owning service must close this object on dismissal, expiry, and shutdown.
138
+ The owning service closes idle transports, cancellation and shutdown.
139
+ A durable fork is resumed from its native rollout after transport closure.
104
140
  """
105
141
 
106
142
  def __init__(self, parent_thread_id: str, *, executable: str, model: str | None,
107
143
  env: dict, parent_rollout_path: str | None = None,
108
- provider_selection: dict | None = None):
144
+ provider_selection: dict | None = None, cwd: str | None = None,
145
+ fork_overrides: dict | None = None, turn_overrides: dict | None = None,
146
+ server_request_handler=None, durable: bool = False,
147
+ resume_state: dict | None = None, persist_state=None):
109
148
  if not isinstance(parent_thread_id, str) or not parent_thread_id.strip():
110
149
  raise SideQuestionError(409, "The parent Codex conversation is not available yet")
111
150
  self.parent_thread_id = parent_thread_id
112
151
  self.parent_rollout_path = parent_rollout_path
113
152
  self.executable = executable
114
153
  self.model = model
154
+ self.cwd = cwd
155
+ self.fork_overrides = deepcopy(fork_overrides or {})
156
+ self.turn_overrides = deepcopy(turn_overrides or {})
157
+ self.server_request_handler = server_request_handler
158
+ self.durable = durable
159
+ self.resume_state = deepcopy(resume_state)
160
+ self.persist_state = persist_state
115
161
  self.env = isolated_environment(env)
116
162
  self.provider_config = {}
117
163
  self.provider_turn_overrides = {}
118
164
  self.sensitive_values = ()
165
+ self.protected_env_keys = ()
119
166
  if provider_selection:
120
167
  # Lazy import avoids the provider probe/isolated-config cycle.
121
- from codex_provider import native_config, native_environment, turn_overrides
168
+ from codex_provider import ENV_KEY, native_config, native_environment, turn_overrides as provider_turn_overrides
122
169
  self.env = native_environment(self.env, provider_selection)
123
170
  self.provider_config = native_config(provider_selection)
124
- self.provider_turn_overrides = turn_overrides(provider_selection["model"], provider_selection.get("effort") or "",
171
+ self.provider_turn_overrides = provider_turn_overrides(provider_selection["model"], provider_selection.get("effort") or "",
125
172
  summary=provider_selection.get("reasoning_summary") or "none")
126
173
  self.sensitive_values = (provider_selection["api_key"],)
174
+ self.protected_env_keys = (ENV_KEY,)
127
175
  self.thread_id: str | None = None
128
176
  self._client: CodexAppServerClient | None = None
129
177
  self._temporary: tempfile.TemporaryDirectory | None = None
130
178
  self._opening: asyncio.Task | None = None
131
179
  self._cleaning: asyncio.Task | None = None
180
+ self._process_started = asyncio.Event()
132
181
  self._active: asyncio.Task | None = None
133
182
  self._lock = asyncio.Lock()
134
183
  self._closed = False
@@ -137,13 +186,22 @@ class NativeCodexSideChat:
137
186
  def closed(self) -> bool:
138
187
  return self._closed
139
188
 
189
+ async def _handle_server_request(self, request_id, method, params):
190
+ # The callback can present approvals through the existing UI, but only
191
+ # for this exact child. Never route a parent's inherited tool request.
192
+ if (self.server_request_handler is not None and not self._closed
193
+ and self._active is not None and self.thread_id is not None
194
+ and params.get("threadId") == self.thread_id):
195
+ return await self.server_request_handler(request_id, method, params)
196
+ return await decline_server_request(request_id, method, params)
197
+
140
198
  async def _open(self):
141
199
  self._temporary = tempfile.TemporaryDirectory(prefix="agentsdock-side-chat-")
142
200
  temporary = self._temporary.name
143
201
  await _verify_protocol(self.executable, temporary, self.env)
144
202
  if self._closed:
145
203
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
146
- config = isolated_config()
204
+ config = side_chat_config(self.fork_overrides.get("config"))
147
205
  config.update(self.provider_config)
148
206
  # Preserve Codex's auth/runtime location. Overriding sqlite_home while
149
207
  # retaining the user's history root can trigger a complete reindex.
@@ -167,44 +225,63 @@ class NativeCodexSideChat:
167
225
  client_options["before_start"] = prepare_catalog
168
226
  args = config_args(config)
169
227
  self._client = CodexAppServerClient(
170
- self.executable, cwd=temporary, env_factory=lambda: self.env,
171
- app_server_args=args, request_timeout=20, lifecycle_timeout=30,
228
+ self.executable, cwd=self.cwd or temporary, env_factory=lambda: self.env,
229
+ app_server_args=args,
172
230
  process_stream_limit=MAX_OUTPUT_BYTES, notification_queue_limit=512,
173
231
  sensitive_values=self.sensitive_values,
232
+ protected_env_keys=self.protected_env_keys,
233
+ server_request_handler=self._handle_server_request,
234
+ on_process_started=lambda _pid, _group: self._process_started.set(),
174
235
  **client_options,
175
236
  )
176
237
  await self._client.start()
177
238
  if self._closed:
178
239
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
179
- effective = await self._client.request("config/read", {"includeLayers": False})
180
- settings = effective.get("config") if isinstance(effective, dict) else None
181
- if not isinstance(settings, dict):
182
- raise SideQuestionError(503, "Codex could not confirm isolated configuration")
183
- servers = settings.get("mcp_servers", {})
184
- if not isinstance(servers, dict) or any(not isinstance(value, dict) for value in servers.values()):
185
- raise SideQuestionError(503, "Codex could not confirm isolated integrations")
186
- # Empty maps merge with inherited config; explicitly disable every
187
- # server. Nested keys preserve integration names containing dots.
188
- config["mcp_servers"] = {name: {"enabled": False} for name in servers}
240
+ inherited_instructions = self.fork_overrides.get("developerInstructions") or ""
189
241
  params = {
190
- "ephemeral": True, "excludeTurns": True, "runtimeWorkspaceRoots": [],
191
- "cwd": temporary, "approvalPolicy": "never", "sandbox": "read-only",
192
- "baseInstructions": SIDE_INSTRUCTIONS, "developerInstructions": SIDE_INSTRUCTIONS,
242
+ **self.fork_overrides,
243
+ "ephemeral": not self.durable, "excludeTurns": True,
244
+ "developerInstructions": "\n\n".join(value for value in (inherited_instructions, SIDE_INSTRUCTIONS) if value),
193
245
  "config": config,
194
246
  }
247
+ if self.cwd:
248
+ params["cwd"] = self.cwd
195
249
  if self.model:
196
250
  params["model"] = self.model
197
251
  if self.provider_config:
198
252
  params["modelProvider"] = self.provider_config["model_provider"]
199
253
  if self.parent_rollout_path:
200
254
  params["path"] = self.parent_rollout_path
201
- # Native ephemeral forks cannot carry a goal. In particular, do not set
202
- # deferGoalContinuation: Codex rejects it when ephemeral is true.
203
- self.thread_id = await self._client.fork_thread(self.parent_thread_id, params)
255
+ if self.durable:
256
+ # Preserve full native context across server restart without ever
257
+ # allowing an inherited parent goal to continue in the side chat.
258
+ params["deferGoalContinuation"] = True
259
+ if self.resume_state is not None:
260
+ saved_id = self.resume_state.get("thread_id")
261
+ if not self.durable or not isinstance(saved_id, str) or not saved_id or saved_id == self.parent_thread_id:
262
+ raise SideQuestionError(410, "Saved Codex side conversation is unavailable; clear Side chat")
263
+ params.pop("ephemeral", None)
264
+ params.pop("path", None)
265
+ if self.resume_state.get("path"):
266
+ params["path"] = self.resume_state["path"]
267
+ self.thread_id = await self._client.resume_thread(saved_id, params)
268
+ else:
269
+ # Native ephemeral forks cannot carry deferGoalContinuation.
270
+ self.thread_id = await self._client.fork_thread(self.parent_thread_id, params)
271
+ if self._closed:
272
+ # A fork reply can race Stop. Do not let the following read lazily
273
+ # restart a private transport that cleanup has already closed.
274
+ raise SideQuestionError(409, "Side chat was closed; open a new side chat")
204
275
  if self.thread_id == self.parent_thread_id:
205
276
  raise SideQuestionError(503, "Codex did not create a separate side chat")
206
277
  metadata = await self._client.read_thread(self.thread_id, include_turns=False)
207
- if metadata.get("ephemeral") is not True or metadata.get("path") is not None:
278
+ if self.durable:
279
+ if metadata.get("ephemeral") is not False or not isinstance(metadata.get("path"), str):
280
+ raise SideQuestionError(503, "Codex did not save the native side conversation")
281
+ if self.persist_state is not None:
282
+ await self.persist_state({"thread_id": self.thread_id, "path": metadata["path"]})
283
+ await self._client.clear_thread_goal(self.thread_id)
284
+ elif metadata.get("ephemeral") is not True or metadata.get("path") is not None:
208
285
  raise SideQuestionError(503, "Codex did not confirm an ephemeral side chat")
209
286
 
210
287
  async def ask(self, question: str) -> str:
@@ -225,7 +302,7 @@ class NativeCodexSideChat:
225
302
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
226
303
  turn = await self._client.start_turn(
227
304
  self.thread_id, [{"type": "text", "text": question}],
228
- overrides={**self.provider_turn_overrides, "environments": []},
305
+ overrides={**self.turn_overrides, **self.provider_turn_overrides},
229
306
  )
230
307
  answers: dict[str, str] = {}
231
308
  while True:
@@ -265,15 +342,31 @@ class NativeCodexSideChat:
265
342
  active.cancel()
266
343
  if self._cleaning is None:
267
344
  async def cleanup():
268
- if self._opening is not None:
269
- with suppress(BaseException):
270
- await self._opening
345
+ client_closed = False
271
346
  try:
272
- if self._client is not None:
347
+ if self._opening is not None and not self._opening.done():
348
+ # Keep ownership while subprocess creation is in flight.
349
+ # Once its handle exists, Stop can close this private
350
+ # process without waiting for initialize/fork replies.
351
+ started = asyncio.create_task(self._process_started.wait())
352
+ try:
353
+ await asyncio.wait({self._opening, started}, return_when=asyncio.FIRST_COMPLETED)
354
+ finally:
355
+ started.cancel()
356
+ await asyncio.gather(started, return_exceptions=True)
357
+ if self._process_started.is_set() and self._client is not None:
273
358
  await self._client.close()
359
+ client_closed = True
360
+ if self._opening is not None:
361
+ with suppress(BaseException):
362
+ await self._opening
274
363
  finally:
275
- if self._temporary is not None:
276
- self._temporary.cleanup()
364
+ try:
365
+ if self._client is not None and not client_closed:
366
+ await self._client.close()
367
+ finally:
368
+ if self._temporary is not None:
369
+ self._temporary.cleanup()
277
370
  self._cleaning = asyncio.create_task(cleanup())
278
371
  cancelled = False
279
372
  while not self._cleaning.done():
@@ -291,10 +384,14 @@ class NativeCodexSideChat:
291
384
 
292
385
  async def answer_side_question(question: str, *, parent_thread_id: str, executable: str,
293
386
  model: str | None, env: dict,
294
- parent_rollout_path: str | None = None) -> str:
387
+ parent_rollout_path: str | None = None,
388
+ cwd: str | None = None, fork_overrides: dict | None = None,
389
+ turn_overrides: dict | None = None, server_request_handler=None) -> str:
295
390
  """Single-question convenience wrapper; follow-ups use NativeCodexSideChat."""
296
391
  chat = NativeCodexSideChat(parent_thread_id, executable=executable, model=model,
297
- env=env, parent_rollout_path=parent_rollout_path)
392
+ env=env, parent_rollout_path=parent_rollout_path,
393
+ cwd=cwd, fork_overrides=fork_overrides, turn_overrides=turn_overrides,
394
+ server_request_handler=server_request_handler)
298
395
  try:
299
396
  return await chat.ask(question)
300
397
  finally:
@@ -106,6 +106,32 @@ def canonical_cursor_call_id(value: Any) -> str:
106
106
  return value
107
107
 
108
108
 
109
+ def _cursor_tool_call_id(
110
+ event: dict[str, Any],
111
+ body: dict[str, Any],
112
+ ) -> str:
113
+ """Return Cursor's tool correlation key with one narrow compatibility fallback.
114
+
115
+ Cursor documents the correlation key at the top-level ``call_id`` field,
116
+ but current tool payloads also repeat it as ``args.toolCallId``. Some CLI
117
+ events have emitted an invalid top-level value while retaining the valid
118
+ nested copy. Prefer the documented field and consult only that exact
119
+ nested location when top-level validation fails; all candidates still use
120
+ the same bounded, control-free validation.
121
+ """
122
+
123
+ try:
124
+ return canonical_cursor_call_id(event.get("call_id"))
125
+ except CursorEventParseError as top_level_error:
126
+ args = body.get("args")
127
+ if not isinstance(args, dict) or "toolCallId" not in args:
128
+ raise
129
+ try:
130
+ return canonical_cursor_call_id(args.get("toolCallId"))
131
+ except CursorEventParseError:
132
+ raise top_level_error from None
133
+
134
+
109
135
  def _cursor_lifecycle_session_id(event: dict[str, Any], event_name: str) -> str:
110
136
  session_id = canonical_cursor_session_id(event.get("session_id"))
111
137
  if not session_id:
@@ -334,7 +360,7 @@ def normalize_cursor_stream_event(raw_line: str) -> dict[str, Any] | None:
334
360
  if not isinstance(tool_call, dict):
335
361
  raise CursorEventParseError("tool_call event missing tool_call payload")
336
362
  name, body = _tool_call_name_and_body(tool_call)
337
- call_id = canonical_cursor_call_id(event.get("call_id"))
363
+ call_id = _cursor_tool_call_id(event, body)
338
364
  if subtype == "started":
339
365
  return {
340
366
  "kind": "tool_started",
@@ -0,0 +1,219 @@
1
+ """Read-only Cursor CLI discovery (verified with 2026.09.18).
2
+
3
+ Only the CLI's metadata and plaintext user-facing JSONL export are read. Never
4
+ open conversation blobs, decrypt data, invoke the CLI, or migrate native stores.
5
+ Cursor IDE and cloud histories are different formats and are not advertised.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import sqlite3
16
+ import stat
17
+ import unicodedata
18
+ from typing import Iterator
19
+
20
+ MAX_METADATA_BYTES = 64 * 1024
21
+ MAX_SCAN_ENTRIES = 10_000
22
+ IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")
23
+
24
+
25
+ def native_title(value: object) -> str | None:
26
+ """Bound display metadata before choosing between native naming sources.
27
+
28
+ Cursor's default is New Agent. Other names (including a user's literal
29
+ 'New chat' or 'Untitled') are not generic AgentsDock placeholders here.
30
+ """
31
+ if not isinstance(value, str) or len(value) > 4096:
32
+ return None
33
+ clean = " ".join("".join(
34
+ character for character in value
35
+ if character.isspace()
36
+ or unicodedata.category(character) not in {"Cc", "Cf", "Cs"}
37
+ ).split())[:120]
38
+ return clean if clean and clean.casefold() != "new agent" else None
39
+
40
+
41
+ def config_root(cwd: str | None = None) -> Path:
42
+ config = os.environ.get("CURSOR_CONFIG_DIR", "").strip()
43
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
44
+ root = Path(config) if config else Path(xdg) / "cursor" if xdg else Path.home() / ".cursor"
45
+ return Path(os.path.abspath(root if root.is_absolute() else Path(cwd or os.getcwd()) / root))
46
+
47
+
48
+ def data_root(cwd: str) -> Path:
49
+ data = os.environ.get("CURSOR_DATA_DIR", "").strip()
50
+ root = Path(data) if data else Path.home() / ".cursor"
51
+ return Path(os.path.abspath(root if root.is_absolute() else Path(cwd) / root))
52
+
53
+
54
+ def project_slug(cwd: str) -> str:
55
+ # Cursor utils/dist/workspace-paths.js; do not guess a cwd by reversing it.
56
+ return re.sub(r"-+", "-", re.sub(r"[^a-zA-Z0-9]", "-", cwd)).strip("-")
57
+
58
+
59
+ def safe_path(path: Path, root: Path) -> bool:
60
+ """Do not follow symlinks beneath the configured native root."""
61
+ try:
62
+ relative = path.relative_to(root)
63
+ if ".." in relative.parts or root.is_symlink():
64
+ return False
65
+ current = root
66
+ for part in relative.parts:
67
+ current = current / part
68
+ if current.is_symlink():
69
+ return False
70
+ return True
71
+ except (OSError, ValueError):
72
+ return False
73
+
74
+
75
+ def read_json(path: Path, root: Path) -> dict | None:
76
+ if not safe_path(path, root):
77
+ return None
78
+ try:
79
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
80
+ with os.fdopen(descriptor, "rb") as stream:
81
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
82
+ return None
83
+ raw = stream.read(MAX_METADATA_BYTES + 1)
84
+ if len(raw) > MAX_METADATA_BYTES:
85
+ return None
86
+ value = json.loads(raw)
87
+ return value if isinstance(value, dict) else None
88
+ except (OSError, ValueError, RecursionError):
89
+ return None
90
+
91
+
92
+ def store_metadata(database: Path, root: Path) -> dict | None:
93
+ if not safe_path(database, root) or not database.is_file():
94
+ return None
95
+ try:
96
+ connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.05)
97
+ try:
98
+ connection.execute("PRAGMA query_only = ON")
99
+ connection.execute("PRAGMA trusted_schema = OFF")
100
+ connection.set_progress_handler(lambda: 1, 10000)
101
+ row = connection.execute(
102
+ "SELECT substr(value, 1, 32769) FROM meta WHERE key = '0' AND typeof(value) = 'text'",
103
+ ).fetchone()
104
+ finally:
105
+ connection.close()
106
+ if not row or len(row[0]) > 32768:
107
+ return None
108
+ value = json.loads(bytes.fromhex(row[0]))
109
+ if not isinstance(value, dict):
110
+ return None
111
+ # Do not return encryption keys, root blobs, or unrelated metadata.
112
+ return {
113
+ "id": value.get("agentId"), "title": value.get("name"),
114
+ "is_subagent": value.get("subagentInfo") is not None,
115
+ }
116
+ except (OSError, ValueError, RecursionError, sqlite3.Error):
117
+ return None
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class LocalSession:
122
+ provider_id: str
123
+ cwd: str
124
+ title: str | None
125
+ updated_at: float
126
+ transcript: Path
127
+
128
+
129
+ def read_session(directory: Path, root: Path, *, expected_cwd: str | None = None) -> LocalSession | None:
130
+ provider_id = directory.name
131
+ if not IDENTIFIER.fullmatch(provider_id) or not safe_path(directory, root):
132
+ return None
133
+ sidecar = read_json(directory / "meta.json", root)
134
+ if not sidecar or type(sidecar.get("schemaVersion")) is not int or sidecar["schemaVersion"] != 1:
135
+ return None
136
+ if sidecar.get("hasConversation") is not True or sidecar.get("isSubagent") is True:
137
+ return None
138
+ cwd = sidecar.get("cwd")
139
+ if (not isinstance(cwd, str) or not cwd or len(cwd) > 16384
140
+ or any(ord(char) < 32 or ord(char) == 127 for char in cwd)
141
+ or not Path(cwd).is_absolute() or os.path.normpath(cwd) != cwd):
142
+ return None
143
+ if expected_cwd is not None and cwd != expected_cwd:
144
+ return None
145
+ try:
146
+ bucket = hashlib.md5(cwd.encode(), usedforsecurity=False).hexdigest()
147
+ usable = Path(cwd).is_dir() and config_root(cwd).absolute() == root.absolute()
148
+ except (OSError, ValueError):
149
+ return None
150
+ if bucket != directory.parent.name:
151
+ return None
152
+ # CLI resume is workspace-bound. Do not silently substitute DEFAULT_CWD
153
+ # for a vanished temporary project, which would create an unrelated chat.
154
+ if not usable:
155
+ return None
156
+ metadata = store_metadata(directory / "store.db", root)
157
+ if not metadata or metadata["id"] != provider_id or metadata["is_subagent"]:
158
+ return None
159
+ # Native configuration and transcript data have independent roots.
160
+ data = data_root(cwd)
161
+ projects = data / "projects"
162
+ slug = project_slug(cwd)
163
+ if not slug:
164
+ return None
165
+ transcript = projects / slug / "agent-transcripts" / provider_id / f"{provider_id}.jsonl"
166
+ if not safe_path(transcript, data):
167
+ return None
168
+ try:
169
+ info = transcript.stat()
170
+ if not stat.S_ISREG(info.st_mode):
171
+ return None
172
+ except OSError:
173
+ return None
174
+ updated = sidecar.get("updatedAtMs")
175
+ timestamp = updated / 1000 if type(updated) is int and 0 < updated < 253402300800000 else info.st_mtime
176
+ title = native_title(metadata["title"]) or native_title(sidecar.get("title"))
177
+ return LocalSession(provider_id, cwd, title, timestamp, transcript)
178
+
179
+
180
+ def find_session(provider_id: str, cwd: str) -> LocalSession | None:
181
+ if not isinstance(provider_id, str) or not IDENTIFIER.fullmatch(provider_id) or not isinstance(cwd, str):
182
+ return None
183
+ root = config_root(cwd)
184
+ try:
185
+ bucket = hashlib.md5(cwd.encode(), usedforsecurity=False).hexdigest()
186
+ except ValueError:
187
+ return None
188
+ return read_session(root / "chats" / bucket / provider_id, root, expected_cwd=cwd)
189
+
190
+
191
+ def local_sessions() -> Iterator[LocalSession]:
192
+ root = config_root()
193
+ chats = root / "chats"
194
+ if not safe_path(chats, root):
195
+ return
196
+ remaining = MAX_SCAN_ENTRIES
197
+ try:
198
+ with os.scandir(chats) as buckets:
199
+ for bucket in buckets:
200
+ remaining -= 1
201
+ if remaining < 0:
202
+ return
203
+ if not re.fullmatch(r"[0-9a-f]{32}", bucket.name) or not bucket.is_dir(follow_symlinks=False):
204
+ continue
205
+ try:
206
+ with os.scandir(bucket.path) as directories:
207
+ for entry in directories:
208
+ remaining -= 1
209
+ if remaining < 0:
210
+ return
211
+ if not entry.is_dir(follow_symlinks=False):
212
+ continue
213
+ session = read_session(Path(entry.path), root)
214
+ if session is not None:
215
+ yield session
216
+ except OSError:
217
+ continue
218
+ except OSError:
219
+ return