@agentsdock/server 1.0.7-beta.5 → 1.0.8

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 (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  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 +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -1096,7 +1096,8 @@ def parser() -> argparse.ArgumentParser:
1096
1096
  command.set_defaults(handler=send)
1097
1097
  ask_command = commands.add_parser(
1098
1098
  "ask",
1099
- help="ask a same-server agent and wait until it answers or is stopped",
1099
+ help=("send one request; async_route_v1 returns a durable delivery receipt without waiting for a reply; "
1100
+ "legacy routes wait until answered or stopped"),
1100
1101
  allow_abbrev=False,
1101
1102
  )
1102
1103
  ask_destination = ask_command.add_mutually_exclusive_group(required=True)
@@ -103,23 +103,37 @@ _VALID = """m.excluded_at IS NULL AND e.status='stored' AND e.delivery_mode='mai
103
103
  AND e.source_session_id=m.source_session_id AND e.target_session_id=m.target_session_id
104
104
  AND e.authorization_pair_id=m.pair_id"""
105
105
  _COLUMNS = """m.*, e.body AS original_body, e.target_body, e.message_revision, e.created_at,
106
- e.source_user_instruction"""
106
+ e.source_run_id, e.source_user_instruction, e.source_user_delegation_action"""
107
107
 
108
108
 
109
109
  def _message(row: sqlite3.Row) -> dict:
110
110
  edited = row["message_revision"] > 0 and isinstance(row["target_body"], str)
111
- return {
111
+ message = {
112
112
  "message_id": row["message_id"], "mailbox_seq": row["mailbox_seq"],
113
113
  "source_session_id": row["source_session_id"], "target_session_id": row["target_session_id"],
114
114
  "conversation_id": row["pair_id"],
115
115
  "body": row["target_body"] if edited else row["original_body"],
116
- "source_user_instruction": row["source_user_instruction"] or "",
117
116
  "created_at": row["created_at"], "stored_at": row["stored_at"],
118
117
  "in_reply_to_message_id": row["in_reply_to_message_id"],
119
118
  "read_id": row["read_id"], "read_at": row["read_at"],
120
119
  "message_revision": row["message_revision"] if edited else 0,
121
120
  "message_edited_by_user": edited,
122
121
  }
122
+ if (row["source_user_delegation_action"] in ("route", "instruction", "request_reply")
123
+ and isinstance(row["source_user_instruction"], str) and row["source_user_instruction"].strip()
124
+ and row["message_revision"] == 0):
125
+ # Only the server's immutable admission marker can attest user origin.
126
+ # An edited body is no longer the originally delegated handoff. Keep
127
+ # it readable, but do not let it inherit the original attestation.
128
+ message["user_delegation"] = {
129
+ "version": 1,
130
+ "source_session_id": row["source_session_id"],
131
+ "source_run_id": row["source_run_id"],
132
+ "target_session_id": row["target_session_id"],
133
+ "reference_action": row["source_user_delegation_action"],
134
+ "source_user_instruction": row["source_user_instruction"],
135
+ }
136
+ return message
123
137
 
124
138
 
125
139
  def store_message(connection: sqlite3.Connection, message_id: str, *, now: str,
@@ -158,10 +172,13 @@ def store_message(connection: sqlite3.Connection, message_id: str, *, now: str,
158
172
  or row["in_reply_to_message_id"] != parent):
159
173
  raise MailboxConflict("Stored mailbox identity changed")
160
174
  message = _message(row)
161
- # Reject before the caller commits, rather than accepting mail that no
162
- # bounded read can deliver. Include the metadata added on its first read.
175
+ # Include all provenance in the same bounded response before accepting.
163
176
  readable = {**message, "read_id": "mailread_" + "0" * 32, "read_at": now}
164
- if 8192 + len(json.dumps(readable, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > MAX_PAGE_BYTES:
177
+ try:
178
+ size = len(json.dumps(readable, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
179
+ except UnicodeEncodeError as exc:
180
+ raise MailboxConflict("Message and user delegation must be valid UTF-8") from exc
181
+ if 8192 + size > MAX_PAGE_BYTES:
165
182
  raise MailboxConflict("Message and source user instruction exceed the bounded mailbox response")
166
183
  return message
167
184
 
@@ -23,6 +23,7 @@ from typing import Callable
23
23
 
24
24
  from claude_history_provenance import ClaudeInterruptionTracker
25
25
  from claude_goals import ClaudeGoalHistoryNormalizer, is_claude_synthetic_no_response
26
+ from claude_sdk_client import CLAUDE_SDK_LITERAL_MESSAGE_PREFIX
26
27
 
27
28
 
28
29
  MAX_BYTES = 96 * 1024 * 1024
@@ -165,6 +166,7 @@ class _AssistantReplays:
165
166
  self.candidates, self.sources, self.identities, self.source_credits = [], {}, {}, {}
166
167
  self.source_display, self.source_message_counts = {}, {}
167
168
  self.source_parents, self.source_users, self.source_input = OrderedDict(), {}, {}
169
+ self.source_commands, self.local_command_prompts = {}, {}
168
170
 
169
171
  def display_key(self, text: str) -> str | None:
170
172
  if self.normalize_assistant is None:
@@ -211,13 +213,33 @@ class _AssistantReplays:
211
213
  tool_result = isinstance(content, list) and any(isinstance(part, dict) and part.get("type") == "tool_result" for part in content)
212
214
  if event.get("type") == "user" and event.get("isMeta") is not True and text.strip() and not tool_result:
213
215
  self.source_users.setdefault(identity, []).append((offset, _text_key(text.strip())))
216
+ command = re.fullmatch(
217
+ r"<command-name>/([A-Za-z0-9_][A-Za-z0-9_.:-]{0,127})</command-name>\s*"
218
+ r"<command-message>\1</command-message>\s*<command-args>([\s\S]*)</command-args>",
219
+ text.strip(),
220
+ )
221
+ if command is not None:
222
+ self.source_commands[identity] = command.groups()
214
223
  parent_input = identity
215
224
  self.source_parents[identity[0]] = parent_input
216
225
  while len(self.source_parents) > 256:
217
226
  self.source_parents.popitem(last=False)
218
227
  if len(self.source_users) > MAX_KEYS:
219
228
  raise _Unproven()
220
- if event.get("type") != "assistant":
229
+ local_output = None
230
+ if event.get("type") == "system" and event.get("subtype") == "local_command":
231
+ command = event.get("commandRun")
232
+ output = event.get("content")
233
+ wrapper = self.source_commands.get(parent_input)
234
+ if (not isinstance(command, dict) or wrapper is None
235
+ or wrapper != (command.get("command"), command.get("args"))
236
+ or not isinstance(output, str)):
237
+ return
238
+ local_output = re.fullmatch(r"<local-command-stdout>([\s\S]*)</local-command-stdout>", output)
239
+ if local_output is None:
240
+ return
241
+ self.local_command_prompts[identity] = "/" + wrapper[0] + (" " + wrapper[1] if wrapper[1] else "")
242
+ elif event.get("type") != "assistant":
221
243
  return
222
244
  if parent_input is not None:
223
245
  self.source_input[identity] = parent_input
@@ -229,7 +251,9 @@ class _AssistantReplays:
229
251
  content = message.get("content") if isinstance(message, dict) else None
230
252
  # Only public text blocks. Thinking/tool payloads cannot establish a
231
253
  # public replay, even if their wording happens to match another row.
232
- if isinstance(content, str):
254
+ if local_output is not None:
255
+ text = local_output[1]
256
+ elif isinstance(content, str):
233
257
  text = content
234
258
  elif isinstance(content, list):
235
259
  text = "\n".join(block["text"] for block in content if isinstance(block, dict)
@@ -270,7 +294,21 @@ class _AssistantReplays:
270
294
  assistants.add((identity, digest))
271
295
  source_input = self.source_input.get(identity)
272
296
  inputs = self.source_users.get(source_input, ())
273
- if len(inputs) == 1 and isinstance(matches[0], str) and inputs[0][1] == _text_key(matches[0].strip()):
297
+ expected_inputs = set()
298
+ if isinstance(matches[0], str):
299
+ expected_inputs.add(_text_key(matches[0].strip()))
300
+ if re.sub(r"^[\s\ufeff]+", "", matches[0]).startswith("/"):
301
+ # Older transports prepended this exact sentence. The
302
+ # native assistant UUID and source ancestry above prove
303
+ # which immutable user prompt owns this imported copy.
304
+ # Never strip matching prose from an unowned user quote.
305
+ expected_inputs.add(_text_key(
306
+ (CLAUDE_SDK_LITERAL_MESSAGE_PREFIX + matches[0]).strip()))
307
+ # A native local-command result owns its XML input only when the
308
+ # commandRun metadata, source wrapper and original prompt agree.
309
+ command_match = (identity in self.local_command_prompts
310
+ and self.local_command_prompts[identity] == matches[0])
311
+ if len(inputs) == 1 and (inputs[0][1] in expected_inputs or command_match):
274
312
  users.add((source_input, inputs[0][1], inputs[0][0]))
275
313
  return assistants, users
276
314
 
@@ -1288,6 +1326,7 @@ def _prove_recent_scheduled(session_id: str, provider_id: str, events: Path, roo
1288
1326
  if len(identity_counts) + len(source_matches) > MAX_KEYS:
1289
1327
  raise _Unproven()
1290
1328
  targets, counts = set(), {}
1329
+ _owned_assistants, owned_inputs = assistant_replays.exact_owned_sources()
1291
1330
  for target, identity in candidates:
1292
1331
  seq, run, key = target
1293
1332
  batch, matched = eligible.get(run), source_matches.get((identity, key), ())
@@ -1299,7 +1338,9 @@ def _prove_recent_scheduled(session_id: str, provider_id: str, events: Path, roo
1299
1338
  proven_metadata = len(metadata) == 1 and start < metadata[0] <= end
1300
1339
  proven_scheduled = (len(matched) == 1 and occurrence_counts.get(matched[0][1]) == 1
1301
1340
  and start < matched[0][0] <= end)
1302
- if first < seq < last and (proven_metadata or proven_scheduled):
1341
+ proven_owned_input = any(source == identity and digest == key and start < offset <= end
1342
+ for source, digest, offset in owned_inputs)
1343
+ if first < seq < last and (proven_metadata or proven_scheduled or proven_owned_input):
1303
1344
  targets.add(target)
1304
1345
  counts[run] = counts.get(run, 0) + 1
1305
1346
  targets.update(async_inputs.prove(eligible))
@@ -0,0 +1,200 @@
1
+ """Passive Claude model metadata from real SDK connections.
2
+
3
+ Catalog reads never start a CLI, renew credentials, or terminate a process.
4
+ Only bounded model IDs/labels are retained; account/command data is discarded.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections import OrderedDict
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import shutil
15
+ import threading
16
+ import time
17
+ import unicodedata
18
+ from typing import Any
19
+
20
+ MAX_MODELS = 512
21
+ MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/\-\[\]]{0,255}")
22
+ VERSIONED_ID = re.compile(
23
+ r"claude-([a-z][a-z0-9]*)-(\d+(?:-\d{1,2})*)(?:-\d{8})?(\[[a-z0-9]+\])?"
24
+ )
25
+ NATIVE_DESCRIPTION = re.compile(
26
+ r"(?:Claude )?((?:Opus|Sonnet|Haiku|Fable|Mythos) \d+(?:\.\d+)*)"
27
+ r"(?: with (\w+) context)?(?:\s*[·|—]|$)"
28
+ )
29
+ class ClaudeModelCatalogUnavailable(RuntimeError):
30
+ """A metadata probe failed; never include provider output in this error."""
31
+
32
+
33
+ def _safe_label(value: Any) -> str:
34
+ if not isinstance(value, str) or not value or len(value) > 160:
35
+ return ""
36
+ if any(unicodedata.category(char).startswith("C") for char in value):
37
+ return ""
38
+ return value.strip()
39
+
40
+
41
+ def _model_label(model: dict[str, Any], value: str) -> str:
42
+ display = _safe_label(model.get("displayName")) or value[:160]
43
+ # New CLI versions identify the alias target explicitly. Do not guess it
44
+ # from the CLI's version, the newest website announcement, or our fallback.
45
+ resolved = model.get("resolvedModel")
46
+ matched = VERSIONED_ID.fullmatch(resolved) if isinstance(resolved, str) and len(resolved) <= 256 else None
47
+ if matched:
48
+ family, version, context = matched.groups()
49
+ label = f"{family.capitalize()} {version.replace('-', '.')}"
50
+ if context or value.endswith("[1m]"):
51
+ label += f" ({(context or '[1m]')[1:-1].upper()} context)"
52
+ elif not resolved:
53
+ # Older SDK initialize responses expose the version only in the
54
+ # picker description. Accept its leading native model name, not prose
55
+ # mentioning an unrelated model or arbitrary numbers in custom names.
56
+ description = model.get("description")
57
+ match = NATIVE_DESCRIPTION.match(description) if isinstance(description, str) and len(description) <= 800 else None
58
+ if match:
59
+ label = match[1]
60
+ if match[2] or value.endswith("[1m]"):
61
+ label += f" ({(match[2] or '1M').upper()} context)"
62
+ else:
63
+ label = display
64
+ else:
65
+ # Gateway deployment IDs are opaque: retain the provider's display
66
+ # name rather than inventing an Anthropic version for them.
67
+ label = display
68
+ if value == "default" and label != display:
69
+ label = f"Default — {label}"
70
+ return _safe_label(label) or display
71
+
72
+
73
+ def parse_native_models(info: Any) -> list[dict[str, str]]:
74
+ if not isinstance(info, dict) or not isinstance(info.get("models"), list):
75
+ raise ClaudeModelCatalogUnavailable("Native model metadata is unavailable")
76
+ models = info["models"]
77
+ if len(models) > MAX_MODELS:
78
+ raise ClaudeModelCatalogUnavailable("Native model metadata exceeds its limit")
79
+ options: list[dict[str, str]] = []
80
+ seen: set[str] = set()
81
+ for model in models:
82
+ if not isinstance(model, dict):
83
+ continue
84
+ value = model.get("value")
85
+ if not isinstance(value, str) or not MODEL_ID.fullmatch(value) or value in seen:
86
+ continue
87
+ seen.add(value)
88
+ # Never replace an alias with its resolved ID: that would pin new
89
+ # chats to today's version instead of following native updates.
90
+ if model.get("disabled") is not True:
91
+ options.append({"value": value, "label": _model_label(model, value)})
92
+ if models and not seen:
93
+ raise ClaudeModelCatalogUnavailable("Native model metadata is invalid")
94
+ return options
95
+
96
+
97
+
98
+ CACHE_TTL_SECONDS = 300.0
99
+ CACHE_LIMIT = 16
100
+ _CACHE_LOCK = threading.Lock()
101
+ _CACHE: OrderedDict[str, tuple[float, list[dict[str, str]]]] = OrderedDict()
102
+
103
+
104
+ def _file_revision(path: Path) -> tuple:
105
+ try:
106
+ stat = path.stat()
107
+ return (str(path.resolve()), stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
108
+ except FileNotFoundError:
109
+ return (str(path.absolute()), None)
110
+
111
+
112
+ def _account_identity(config: Path, home: Path) -> list:
113
+ # Claude writes counters and picker caches to .claude.json on every
114
+ # startup. Its mtime is not a settings revision. Only fingerprint the
115
+ # account identity; never retain email, tokens or the whole document.
116
+ paths = {home / ".claude.json", config / ".claude.json"}
117
+ result = []
118
+ for path in sorted(paths):
119
+ try:
120
+ with path.open("rb") as stream:
121
+ raw = stream.read(2 * 1024 * 1024 + 1)
122
+ if len(raw) > 2 * 1024 * 1024:
123
+ raise ValueError("Native identity metadata exceeds its limit")
124
+ value = json.loads(raw)
125
+ except FileNotFoundError:
126
+ value = {}
127
+ account = value.get("oauthAccount", {}) if isinstance(value, dict) else {}
128
+ result.append([str(path), *(
129
+ str(account.get(field) or "")[:256] if isinstance(account, dict) else ""
130
+ for field in ("accountUuid", "organizationUuid")
131
+ )])
132
+ return result
133
+
134
+
135
+ def native_catalog_key(executable: str, env: dict[str, str]) -> str:
136
+ """Fingerprint runtime/config identity without reading native credentials.
137
+
138
+ Environment credentials are hashed, never retained in plaintext. Native
139
+ credential rotation alone is not evidence of login or logout; this cache
140
+ is short lived and is never used as authentication/authorization evidence.
141
+ """
142
+ home = Path(env.get("HOME") or str(Path.home()))
143
+ config = Path(env.get("CLAUDE_CONFIG_DIR") or home / ".claude")
144
+ relevant = {key: value for key, value in env.items()
145
+ if key.startswith(("ANTHROPIC_", "CLAUDE_")) or key == "HOME"}
146
+ resolved = shutil.which(executable, path=env.get("PATH")) or executable
147
+ revisions = [_file_revision(Path(resolved)), *(
148
+ _file_revision(path) for path in (
149
+ config / "settings.json", config / ".credentials.json",
150
+ Path("/Library/Application Support/ClaudeCode/managed-settings.json"),
151
+ Path("/etc/claude-code/managed-settings.json"),
152
+ )
153
+ )]
154
+ return hashlib.sha256(json.dumps([relevant, revisions, _account_identity(config, home)], sort_keys=True).encode()).hexdigest()
155
+
156
+
157
+ def _has_project_settings(cwd: str, env: dict[str, str]) -> bool:
158
+ """Do not promote a workspace-specific picker to the server-wide catalog."""
159
+ root = Path(cwd).resolve()
160
+ config = Path(env.get("CLAUDE_CONFIG_DIR") or Path(env.get("HOME") or str(Path.home())) / ".claude").resolve()
161
+ for parent in (root, *root.parents):
162
+ folder = parent / ".claude"
163
+ for name in ("settings.json", "settings.local.json"):
164
+ path = folder / name
165
+ if name == "settings.json" and folder.resolve() == config:
166
+ continue # This is the fingerprinted user config, not project config.
167
+ if path.exists():
168
+ return True
169
+ return False
170
+
171
+
172
+ def remember_native_models(info: Any, *, key: str, executable: str,
173
+ env: dict[str, str], cwd: str) -> None:
174
+ # Recheck the pre-connect revision: a slow/old connection must not publish
175
+ # a model list under a newer CLI, provider, or user-settings configuration.
176
+ if key != native_catalog_key(executable, env) or _has_project_settings(cwd, env):
177
+ return
178
+ models = parse_native_models(info)
179
+ with _CACHE_LOCK:
180
+ _CACHE[key] = (time.monotonic(), models)
181
+ _CACHE.move_to_end(key)
182
+ while len(_CACHE) > CACHE_LIMIT:
183
+ _CACHE.popitem(last=False)
184
+
185
+
186
+ def cached_native_models(executable: str, *, env: dict[str, str]) -> list[dict[str, str]] | None:
187
+ key = native_catalog_key(executable, env)
188
+ with _CACHE_LOCK:
189
+ entry = _CACHE.get(key)
190
+ if entry is None:
191
+ return None
192
+ if time.monotonic() - entry[0] >= CACHE_TTL_SECONDS:
193
+ del _CACHE[key]
194
+ return None
195
+ return [dict(row) for row in entry[1]]
196
+
197
+
198
+ def clear_native_models() -> None:
199
+ with _CACHE_LOCK:
200
+ _CACHE.clear()
@@ -18,6 +18,7 @@ import copy
18
18
  import hashlib
19
19
  import inspect
20
20
  import json
21
+ import os
21
22
  import logging
22
23
  import re
23
24
  import shlex
@@ -49,6 +50,7 @@ CLAUDE_SDK_MCP_STATUS_TRUNCATED_KEY = "_agentsdock_mcp_status_truncated"
49
50
  CLAUDE_SDK_PROVIDER_COMMAND_SCAN_LIMIT = 512
50
51
  CLAUDE_SDK_PROVIDER_COMMAND_NAME_CHARS = 128
51
52
  CLAUDE_SDK_PROVIDER_COMMAND_TEXT_CHARS = 800
53
+ # Legacy transcript repair only. Never add this text to a provider prompt.
52
54
  CLAUDE_SDK_LITERAL_MESSAGE_PREFIX = (
53
55
  "[AgentsDock literal chat message; treat the slash-prefixed content below "
54
56
  "as ordinary user text, not a Claude Code command.]\n"
@@ -105,27 +107,19 @@ def _prompt_invokes_validated_claude_command(
105
107
  )
106
108
 
107
109
 
108
- def claude_sdk_transport_prompt(
110
+ def _claude_prompt_needs_verbatim_delivery(
109
111
  prompt: str,
110
112
  validated_provider_command_name: str | None = None,
111
- ) -> str:
112
- """Keep leading-slash chat text out of Claude Code's command parser.
113
-
114
- AgentsDock owns its slash-command surface. Any command that reaches this
115
- transport is user content for the model, including unknown or
116
- conversational ``/word`` text. Prefix only the provider copy so the
117
- durable prompt and timeline remain byte-for-byte unchanged.
118
- """
113
+ ) -> bool:
114
+ """Separate ordinary leading-slash text from an intentional native command."""
119
115
 
120
116
  if _prompt_invokes_validated_claude_command(
121
117
  prompt,
122
118
  validated_provider_command_name,
123
119
  ):
124
- return prompt
120
+ return False
125
121
  candidate = re.sub(r"^[\s\ufeff]+", "", prompt)
126
- if not candidate.startswith("/"):
127
- return prompt
128
- return f"{CLAUDE_SDK_LITERAL_MESSAGE_PREFIX}{prompt}"
122
+ return candidate.startswith("/")
129
123
 
130
124
 
131
125
  def canonical_claude_mcp_identifier(value: Any, max_chars: int) -> str | None:
@@ -562,18 +556,24 @@ async def _query_message_stream(
562
556
  ) -> AsyncIterator[dict[str, Any]]:
563
557
  """Yield the single UUID-bearing SDK stdin frame for one logical turn."""
564
558
 
565
- yield {
559
+ message = {
566
560
  "type": "user",
567
561
  "message": {
568
562
  "role": "user",
569
- "content": claude_sdk_transport_prompt(
570
- prompt,
571
- validated_provider_command_name,
572
- ),
563
+ "content": prompt,
573
564
  },
574
565
  "parent_tool_use_id": None,
575
566
  "uuid": correlation_id,
576
567
  }
568
+ if _claude_prompt_needs_verbatim_delivery(
569
+ prompt, validated_provider_command_name,
570
+ ):
571
+ # Native CLI 2.1.248+ metadata, also used by SDK 0.2.158's
572
+ # verbatim_prompts. SDK 0.2.130 forwards async-iterable fields intact.
573
+ # Apply it per message so explicit commands and ordinary @file input
574
+ # retain their native behavior without modifying the prompt text.
575
+ message["client_composed"] = True
576
+ yield message
577
577
 
578
578
 
579
579
  def default_claude_sdk_client_factory(options: Any) -> ClaudeSDKClientProtocol:
@@ -586,6 +586,29 @@ def default_claude_sdk_client_factory(options: Any) -> ClaudeSDKClientProtocol:
586
586
  "claude-agent-sdk is not installed; use the claude -p fallback"
587
587
  ) from exc
588
588
  class GoalAwareClaudeSDKClient(ClaudeSDKClient):
589
+ async def connect(self, prompt=None) -> None:
590
+ from claude_model_catalog import native_catalog_key, remember_native_models
591
+
592
+ # Read metadata already produced by this real connection. Never
593
+ # open a second connection merely to populate a model picker.
594
+ env = {**os.environ, **(getattr(options, "env", None) or {})}
595
+ executable = str(getattr(options, "cli_path", None) or "claude")
596
+ cwd = str(getattr(options, "cwd", None) or os.getcwd())
597
+ key = None
598
+ try:
599
+ key = native_catalog_key(executable, env)
600
+ except (OSError, ValueError):
601
+ pass # Optional metadata must not block actual work.
602
+ await super().connect(prompt)
603
+ if key is not None:
604
+ try:
605
+ # Pinned SDK get_server_info returns its cached initialize
606
+ # response; it sends no control request or model prompt.
607
+ info = await super().get_server_info()
608
+ remember_native_models(info, key=key, executable=executable, env=env, cwd=cwd)
609
+ except Exception:
610
+ pass # Do not log raw initialization/account data.
611
+
589
612
  async def receive_messages(self) -> AsyncIterator[Any]:
590
613
  # SDK 0.2.130 drops local-command provenance and active_goal. Keep
591
614
  # those native fields without changing parsing of normal messages.
@@ -1348,6 +1371,7 @@ class ClaudeSDKSupervisor:
1348
1371
  ack_timeout_seconds: float = 60.0,
1349
1372
  query_delivery_timeout_seconds: float = 10.0,
1350
1373
  control_timeout_seconds: float = 15.0,
1374
+ usage_observer: Callable[[str, str, Any], Awaitable[None]] | None = None,
1351
1375
  ) -> None:
1352
1376
  clean_chat_id = str(chat_id or "").strip()
1353
1377
  if not clean_chat_id:
@@ -1359,6 +1383,7 @@ class ClaudeSDKSupervisor:
1359
1383
  bind_permission_owner(self.options, self.ownership_token)
1360
1384
  self._client_factory = client_factory
1361
1385
  self._is_result_message = is_result_message
1386
+ self._usage_observer = usage_observer
1362
1387
  if connect_timeout_seconds <= 0:
1363
1388
  raise ValueError("connect_timeout_seconds must be positive")
1364
1389
  self._connect_timeout_seconds = float(connect_timeout_seconds)
@@ -2117,10 +2142,7 @@ class ClaudeSDKSupervisor:
2117
2142
  provider_id = self.options.get("resume") if isinstance(self.options, dict) else getattr(self.options, "resume", None)
2118
2143
  background_hook.bind(
2119
2144
  handle,
2120
- claude_sdk_transport_prompt(
2121
- command.prompt,
2122
- command.validated_provider_command_name,
2123
- ),
2145
+ command.prompt,
2124
2146
  _receipt_field(provider_id),
2125
2147
  lambda: self._active_run is handle and not self._closed
2126
2148
  and self._background_reconciliation_hook is background_hook,
@@ -2692,6 +2714,14 @@ class ClaudeSDKSupervisor:
2692
2714
  async def _handle_received(self, command: _ReceivedMessage) -> None:
2693
2715
  if command.generation != self._generation:
2694
2716
  return
2717
+ if _message_type(command.message) in {"ratelimitevent", "rate_limit_event"} and self._usage_observer is not None:
2718
+ generation = self.control_generation
2719
+ if generation is not None:
2720
+ try:
2721
+ await self._usage_observer(self.chat_id, generation, command.message)
2722
+ except Exception:
2723
+ logger.debug("Claude usage observer unavailable")
2724
+ return
2695
2725
  pending = self._pending_goal_clear
2696
2726
  if pending is not None:
2697
2727
  local_run = _message_field(command.message, "local_command_run")
@@ -2924,6 +2954,7 @@ class ClaudeSDKSupervisorManager:
2924
2954
  ack_timeout_seconds: float = 60.0,
2925
2955
  query_delivery_timeout_seconds: float = 10.0,
2926
2956
  control_timeout_seconds: float = 15.0,
2957
+ usage_observer: Callable[[str, str, Any], Awaitable[None]] | None = None,
2927
2958
  ) -> None:
2928
2959
  if max_clients < 1:
2929
2960
  raise ValueError("max_clients must be positive")
@@ -2931,6 +2962,7 @@ class ClaudeSDKSupervisorManager:
2931
2962
  raise ValueError("idle_ttl_seconds must be non-negative or None")
2932
2963
  self._client_factory = client_factory
2933
2964
  self._is_result_message = is_result_message
2965
+ self._usage_observer = usage_observer
2934
2966
  self._max_clients = int(max_clients)
2935
2967
  self._idle_ttl_seconds = idle_ttl_seconds
2936
2968
  if connect_timeout_seconds <= 0:
@@ -3043,6 +3075,7 @@ class ClaudeSDKSupervisorManager:
3043
3075
  ack_timeout_seconds=self._ack_timeout_seconds,
3044
3076
  query_delivery_timeout_seconds=self._query_delivery_timeout_seconds,
3045
3077
  control_timeout_seconds=self._control_timeout_seconds,
3078
+ usage_observer=self._usage_observer,
3046
3079
  )
3047
3080
  self._supervisors[chat_id] = supervisor
3048
3081
  else:
@@ -3372,7 +3405,6 @@ class ClaudeSDKSupervisorManager:
3372
3405
  history: list[dict[str, str]] | None = None,
3373
3406
  options: Any,
3374
3407
  configuration_key: str,
3375
- timeout_seconds: float = 150.0,
3376
3408
  expected_provider_id: str | None = None,
3377
3409
  ) -> dict[str, Any]:
3378
3410
  """Lease native parent context without occupying its main-turn actor.
@@ -3418,7 +3450,6 @@ class ClaudeSDKSupervisorManager:
3418
3450
  side_task = asyncio.create_task(
3419
3451
  ask_native_side_question(
3420
3452
  lease.client, question, history=history,
3421
- timeout_seconds=timeout_seconds,
3422
3453
  ),
3423
3454
  name=f"claude-sdk-side-question:{clean_chat_id}",
3424
3455
  )
@@ -3603,6 +3634,14 @@ class ClaudeSDKSupervisorManager:
3603
3634
  and not supervisor.closed
3604
3635
  )
3605
3636
 
3637
+ def usage_generation(self, chat_id: str, *, run_id: str | None = None) -> str | None:
3638
+ """Identify the existing native owner without connecting or issuing RPCs."""
3639
+ supervisor = self._supervisors.get(str(chat_id))
3640
+ if (supervisor is None or supervisor.closed or not supervisor.connected
3641
+ or (run_id is not None and supervisor.active_run_id != run_id)):
3642
+ return None
3643
+ return supervisor.control_generation
3644
+
3606
3645
  async def evict(
3607
3646
  self,
3608
3647
  chat_id: str,
@@ -22,7 +22,6 @@ from typing import Any
22
22
  from side_questions import (
23
23
  MAX_OUTPUT_BYTES,
24
24
  MAX_QUESTION_CHARS,
25
- REQUEST_TIMEOUT_SECONDS,
26
25
  SideQuestionError,
27
26
  )
28
27
 
@@ -128,7 +127,6 @@ async def ask_native_side_question(
128
127
  question: str,
129
128
  *,
130
129
  history: list[dict] | None = None,
131
- timeout_seconds: float = REQUEST_TIMEOUT_SECONDS,
132
130
  ) -> dict:
133
131
  """Ask native /btw without acquiring the parent's main-turn authority.
134
132
 
@@ -138,8 +136,6 @@ async def ask_native_side_question(
138
136
  """
139
137
  request = _request(question, history)
140
138
  query = _native_query(client)
141
- if timeout_seconds <= 0:
142
- raise ValueError("timeout_seconds must be positive")
143
139
  request_id = f"agentsdock_side_{uuid.uuid4().hex}"
144
140
  event = asyncio.Event()
145
141
  query.pending_control_responses[request_id] = event
@@ -187,14 +183,11 @@ async def ask_native_side_question(
187
183
  await cleanup
188
184
 
189
185
  try:
190
- result = await asyncio.wait_for(asyncio.shield(receiving), timeout_seconds)
186
+ result = await asyncio.shield(receiving)
191
187
  return _answer(result)
192
188
  except asyncio.CancelledError:
193
189
  await cancel_and_settle()
194
190
  raise
195
- except TimeoutError:
196
- await cancel_and_settle()
197
- raise SideQuestionError(504, "Claude native side question timed out") from None
198
191
  except SideQuestionError:
199
192
  raise
200
193
  except Exception: