@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.
- package/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- 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="
|
|
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)
|
package/server/chat_mailbox.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
110
|
+
def _claude_prompt_needs_verbatim_delivery(
|
|
109
111
|
prompt: str,
|
|
110
112
|
validated_provider_command_name: str | None = None,
|
|
111
|
-
) ->
|
|
112
|
-
"""
|
|
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
|
|
120
|
+
return False
|
|
125
121
|
candidate = re.sub(r"^[\s\ufeff]+", "", prompt)
|
|
126
|
-
|
|
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
|
-
|
|
559
|
+
message = {
|
|
566
560
|
"type": "user",
|
|
567
561
|
"message": {
|
|
568
562
|
"role": "user",
|
|
569
|
-
"content":
|
|
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
|
-
|
|
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.
|
|
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:
|