@agentsdock/server 1.0.7-beta.5
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/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Cursor Agent CLI translation and command construction for AgentsServer.
|
|
2
|
+
|
|
3
|
+
The parser normalizes newline-delimited ``stream-json`` emitted by compatible
|
|
4
|
+
``cursor-agent`` and ``agent`` builds into the provider-neutral lifecycle used
|
|
5
|
+
by AgentsDock. Fixtures in :mod:`test_cursor_agent_client` are captured from
|
|
6
|
+
an authenticated Cursor CLI, and the server independently probes the selected
|
|
7
|
+
executable before advertising this backend.
|
|
8
|
+
|
|
9
|
+
Permission names describe headless behavior, not an interactive approval UI:
|
|
10
|
+
``default`` uses Cursor's configured permissions without forcing tools,
|
|
11
|
+
``full_access`` force-allows commands except explicit Cursor deny rules, and
|
|
12
|
+
``plan`` requests planning mode. Cursor's
|
|
13
|
+
interactive auto-review mode is intentionally not exposed until AgentsServer
|
|
14
|
+
has a bounded approval bridge. The
|
|
15
|
+
server also owns wall-clock/startup timeouts and bounded concurrent stderr
|
|
16
|
+
draining; this module deliberately contains no process lifecycle state.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any, Iterator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CursorEventParseError(ValueError):
|
|
27
|
+
"""A stream-json line was not valid JSON or had an unrecognized shape."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CURSOR_SESSION_IDENTIFIER_RE = re.compile(
|
|
31
|
+
r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"
|
|
32
|
+
)
|
|
33
|
+
CURSOR_CALL_ID_MAX_CHARS = 240
|
|
34
|
+
CURSOR_LIFECYCLE_VALUE_MAX = 2_147_483_647
|
|
35
|
+
CURSOR_ANSI_ESCAPE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
|
|
36
|
+
CURSOR_TOOL_SUCCESS_OUTCOMES = (
|
|
37
|
+
"success",
|
|
38
|
+
"complete",
|
|
39
|
+
"registered",
|
|
40
|
+
"startSuccess",
|
|
41
|
+
"saveSuccess",
|
|
42
|
+
"discardSuccess",
|
|
43
|
+
)
|
|
44
|
+
CURSOR_TOOL_STATUS_OUTCOMES = (
|
|
45
|
+
# These current protobuf-JSON oneofs describe a valid tool response, but
|
|
46
|
+
# not completion of the underlying work. Project them as neutral status
|
|
47
|
+
# instead of the green/successful tool completion used for real outcomes.
|
|
48
|
+
"approved",
|
|
49
|
+
"async",
|
|
50
|
+
"stillRunning",
|
|
51
|
+
)
|
|
52
|
+
CURSOR_TOOL_CONFIRMATION_OUTCOMES = (
|
|
53
|
+
# AgentsServer has no Cursor approval bridge. This must fail closed rather
|
|
54
|
+
# than claim that the requested PR-management action completed.
|
|
55
|
+
"needsConfirmation",
|
|
56
|
+
)
|
|
57
|
+
CURSOR_TOOL_FAILURE_OUTCOMES = (
|
|
58
|
+
"failure",
|
|
59
|
+
"error",
|
|
60
|
+
"timeout",
|
|
61
|
+
"spawnError",
|
|
62
|
+
"permissionDenied",
|
|
63
|
+
"readPermissionDenied",
|
|
64
|
+
"writePermissionDenied",
|
|
65
|
+
"fileNotFound",
|
|
66
|
+
"invalidFile",
|
|
67
|
+
"notFile",
|
|
68
|
+
"fileBusy",
|
|
69
|
+
"noSpace",
|
|
70
|
+
"sandboxUnsupported",
|
|
71
|
+
"notFound",
|
|
72
|
+
"serverNotFound",
|
|
73
|
+
"toolNotFound",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def canonical_cursor_session_id(value: Any) -> str | None:
|
|
78
|
+
"""Return one bounded Cursor resume id or reject protocol/schema drift."""
|
|
79
|
+
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
if not isinstance(value, str):
|
|
83
|
+
raise CursorEventParseError("Cursor session_id must be a string")
|
|
84
|
+
clean = value.strip()
|
|
85
|
+
if not CURSOR_SESSION_IDENTIFIER_RE.fullmatch(clean):
|
|
86
|
+
raise CursorEventParseError(
|
|
87
|
+
"Cursor session_id is not a valid bounded local identifier"
|
|
88
|
+
)
|
|
89
|
+
return clean
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def canonical_cursor_call_id(value: Any) -> str:
|
|
93
|
+
"""Validate the opaque correlation key used by Cursor tool events."""
|
|
94
|
+
|
|
95
|
+
if not isinstance(value, str):
|
|
96
|
+
raise CursorEventParseError("tool_call call_id must be a string")
|
|
97
|
+
if (
|
|
98
|
+
not value
|
|
99
|
+
or value != value.strip()
|
|
100
|
+
or len(value) > CURSOR_CALL_ID_MAX_CHARS
|
|
101
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
102
|
+
):
|
|
103
|
+
raise CursorEventParseError(
|
|
104
|
+
"tool_call call_id must be a bounded nonempty identifier"
|
|
105
|
+
)
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cursor_lifecycle_session_id(event: dict[str, Any], event_name: str) -> str:
|
|
110
|
+
session_id = canonical_cursor_session_id(event.get("session_id"))
|
|
111
|
+
if not session_id:
|
|
112
|
+
raise CursorEventParseError(f"{event_name} missing session_id")
|
|
113
|
+
return session_id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _bounded_cursor_lifecycle_int(value: Any) -> int | None:
|
|
117
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
118
|
+
return None
|
|
119
|
+
if value < 0 or value > CURSOR_LIFECYCLE_VALUE_MAX:
|
|
120
|
+
return None
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cursor_tool_exit_code(value: Any, *, default: int) -> int:
|
|
125
|
+
"""Return a bounded tool exit status from a Cursor result envelope."""
|
|
126
|
+
|
|
127
|
+
if not isinstance(value, dict):
|
|
128
|
+
return default
|
|
129
|
+
exit_code = value.get("exitCode", value.get("exit_code"))
|
|
130
|
+
if (
|
|
131
|
+
isinstance(exit_code, bool)
|
|
132
|
+
or not isinstance(exit_code, int)
|
|
133
|
+
or exit_code < -CURSOR_LIFECYCLE_VALUE_MAX
|
|
134
|
+
or exit_code > CURSOR_LIFECYCLE_VALUE_MAX
|
|
135
|
+
):
|
|
136
|
+
return default
|
|
137
|
+
return exit_code
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cursor_tool_result_outcome(
|
|
141
|
+
value: Any,
|
|
142
|
+
) -> tuple[str, dict[str, Any]]:
|
|
143
|
+
"""Extract one protobuf-JSON tool result outcome and fail on drift."""
|
|
144
|
+
|
|
145
|
+
if not isinstance(value, dict):
|
|
146
|
+
raise CursorEventParseError(
|
|
147
|
+
"completed tool_call result must be an object"
|
|
148
|
+
)
|
|
149
|
+
outcome_keys = (
|
|
150
|
+
*CURSOR_TOOL_SUCCESS_OUTCOMES,
|
|
151
|
+
*CURSOR_TOOL_STATUS_OUTCOMES,
|
|
152
|
+
*CURSOR_TOOL_CONFIRMATION_OUTCOMES,
|
|
153
|
+
"rejected",
|
|
154
|
+
*CURSOR_TOOL_FAILURE_OUTCOMES,
|
|
155
|
+
)
|
|
156
|
+
present = [key for key in outcome_keys if key in value]
|
|
157
|
+
if len(present) != 1:
|
|
158
|
+
raise CursorEventParseError(
|
|
159
|
+
"completed tool_call result must contain exactly one supported "
|
|
160
|
+
"outcome"
|
|
161
|
+
)
|
|
162
|
+
outcome = present[0]
|
|
163
|
+
payload = value.get(outcome)
|
|
164
|
+
if not isinstance(payload, dict):
|
|
165
|
+
raise CursorEventParseError(
|
|
166
|
+
f"{outcome} tool_call result must be an object"
|
|
167
|
+
)
|
|
168
|
+
return outcome, payload
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _bounded_cursor_query_type(value: Any) -> str | None:
|
|
172
|
+
if not isinstance(value, str):
|
|
173
|
+
return None
|
|
174
|
+
clean = value.strip()
|
|
175
|
+
if len(clean) > 80 or not re.fullmatch(r"[A-Za-z0-9_.:-]+", clean):
|
|
176
|
+
return None
|
|
177
|
+
return clean
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _normalize_cursor_usage(value: Any) -> dict[str, int]:
|
|
181
|
+
if not isinstance(value, dict):
|
|
182
|
+
return {}
|
|
183
|
+
normalized: dict[str, int] = {}
|
|
184
|
+
for source, target in (
|
|
185
|
+
("inputTokens", "input_tokens"),
|
|
186
|
+
("outputTokens", "output_tokens"),
|
|
187
|
+
("cacheReadTokens", "cache_read_tokens"),
|
|
188
|
+
("cacheWriteTokens", "cache_write_tokens"),
|
|
189
|
+
):
|
|
190
|
+
bounded = _bounded_cursor_lifecycle_int(value.get(source))
|
|
191
|
+
if bounded is not None:
|
|
192
|
+
normalized[target] = bounded
|
|
193
|
+
return normalized
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cursor_approval_request_text(value: str) -> bool:
|
|
197
|
+
"""Recognize approval prompts that a headless runner cannot answer."""
|
|
198
|
+
|
|
199
|
+
compact = re.sub(r"[^a-z]+", " ", str(value or "").lower()).strip()
|
|
200
|
+
return any(marker in compact for marker in (
|
|
201
|
+
"request approval",
|
|
202
|
+
"approval required",
|
|
203
|
+
"waiting for approval",
|
|
204
|
+
"permission request",
|
|
205
|
+
"requires confirmation",
|
|
206
|
+
"approve this command",
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _tool_call_name_and_body(tool_call: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
211
|
+
"""Cursor nests each tool call under a dynamic ``<name>ToolCall`` key
|
|
212
|
+
(e.g. ``readToolCall``, ``editToolCall``, ``shellToolCall``, ``globToolCall``)
|
|
213
|
+
instead of a stable ``name`` field. Extract both generically.
|
|
214
|
+
"""
|
|
215
|
+
for key, body in tool_call.items():
|
|
216
|
+
if key.endswith("ToolCall") and isinstance(body, dict):
|
|
217
|
+
return key[: -len("ToolCall")], body
|
|
218
|
+
raise CursorEventParseError("tool_call payload has no supported *ToolCall key")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def normalize_cursor_stream_event(raw_line: str) -> dict[str, Any] | None:
|
|
222
|
+
"""Parse one raw stream-json line into a small, neutral event shape.
|
|
223
|
+
|
|
224
|
+
Returns None for event types the server deliberately does not
|
|
225
|
+
project (e.g. the "user" echo, which AgentsServer already has from the
|
|
226
|
+
prompt it sent). Raises CursorEventParseError for anything unrecognized,
|
|
227
|
+
on purpose - silently dropping an unknown event type would hide a real
|
|
228
|
+
schema change in a future Cursor CLI release.
|
|
229
|
+
"""
|
|
230
|
+
raw_line = raw_line.strip()
|
|
231
|
+
if not raw_line:
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
event = json.loads(raw_line)
|
|
235
|
+
except json.JSONDecodeError as exc:
|
|
236
|
+
raise CursorEventParseError("Cursor stream line is not valid JSON") from exc
|
|
237
|
+
if not isinstance(event, dict):
|
|
238
|
+
raise CursorEventParseError("Cursor stream line must be a JSON object")
|
|
239
|
+
|
|
240
|
+
event_type = event.get("type")
|
|
241
|
+
session_id = canonical_cursor_session_id(event.get("session_id"))
|
|
242
|
+
|
|
243
|
+
if event_type == "system":
|
|
244
|
+
subtype = event.get("subtype")
|
|
245
|
+
if subtype == "init":
|
|
246
|
+
if not session_id:
|
|
247
|
+
raise CursorEventParseError("system init missing session_id")
|
|
248
|
+
return {
|
|
249
|
+
"kind": "session_started",
|
|
250
|
+
"session_id": session_id,
|
|
251
|
+
"cwd": event.get("cwd"),
|
|
252
|
+
"model": event.get("model"),
|
|
253
|
+
}
|
|
254
|
+
if subtype == "task_notification":
|
|
255
|
+
return {
|
|
256
|
+
"kind": "background_task_notice",
|
|
257
|
+
"session_id": _cursor_lifecycle_session_id(
|
|
258
|
+
event, "system task_notification"
|
|
259
|
+
),
|
|
260
|
+
}
|
|
261
|
+
if subtype == "background_shell_timeout":
|
|
262
|
+
return {
|
|
263
|
+
"kind": "background_task_timeout",
|
|
264
|
+
"session_id": _cursor_lifecycle_session_id(
|
|
265
|
+
event, "system background_shell_timeout"
|
|
266
|
+
),
|
|
267
|
+
"aborted_count": _bounded_cursor_lifecycle_int(
|
|
268
|
+
event.get("aborted_count")
|
|
269
|
+
),
|
|
270
|
+
"timeout_ms": _bounded_cursor_lifecycle_int(
|
|
271
|
+
event.get("timeout_ms")
|
|
272
|
+
),
|
|
273
|
+
}
|
|
274
|
+
raise CursorEventParseError("unrecognized system subtype")
|
|
275
|
+
|
|
276
|
+
if event_type == "user":
|
|
277
|
+
# AgentsServer already has the prompt it sent; nothing new here.
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
if event_type == "retry":
|
|
281
|
+
subtype = event.get("subtype")
|
|
282
|
+
if subtype not in {"starting", "resuming"}:
|
|
283
|
+
raise CursorEventParseError("unrecognized retry subtype")
|
|
284
|
+
return {
|
|
285
|
+
"kind": "provider_status",
|
|
286
|
+
"session_id": _cursor_lifecycle_session_id(event, "retry"),
|
|
287
|
+
"status": f"retry_{subtype}",
|
|
288
|
+
"attempt": _bounded_cursor_lifecycle_int(event.get("attempt")),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if event_type == "connection":
|
|
292
|
+
subtype = event.get("subtype")
|
|
293
|
+
if subtype not in {"reconnecting", "reconnected"}:
|
|
294
|
+
raise CursorEventParseError("unrecognized connection subtype")
|
|
295
|
+
return {
|
|
296
|
+
"kind": "provider_status",
|
|
297
|
+
"session_id": _cursor_lifecycle_session_id(event, "connection"),
|
|
298
|
+
"status": subtype,
|
|
299
|
+
"attempt": _bounded_cursor_lifecycle_int(event.get("attempt")),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if event_type == "interaction_query":
|
|
303
|
+
subtype = event.get("subtype")
|
|
304
|
+
if subtype not in {"request", "response"}:
|
|
305
|
+
raise CursorEventParseError("unrecognized interaction_query subtype")
|
|
306
|
+
# The query/response objects can contain prompts, commands, URLs, and
|
|
307
|
+
# provider permission decisions. Cursor headless mode resolves these
|
|
308
|
+
# internally, so retain only a schema marker and never project either
|
|
309
|
+
# payload into AgentsDock history or logs.
|
|
310
|
+
return {
|
|
311
|
+
"kind": "provider_interaction",
|
|
312
|
+
"session_id": _cursor_lifecycle_session_id(
|
|
313
|
+
event, "interaction_query"
|
|
314
|
+
),
|
|
315
|
+
"phase": subtype,
|
|
316
|
+
"query_type": _bounded_cursor_query_type(event.get("query_type")),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if event_type == "thinking":
|
|
320
|
+
subtype = event.get("subtype")
|
|
321
|
+
if subtype == "delta":
|
|
322
|
+
return {
|
|
323
|
+
"kind": "reasoning_delta",
|
|
324
|
+
"session_id": session_id,
|
|
325
|
+
"text": event.get("text", ""),
|
|
326
|
+
}
|
|
327
|
+
if subtype == "completed":
|
|
328
|
+
return {"kind": "reasoning_completed", "session_id": session_id}
|
|
329
|
+
raise CursorEventParseError("unrecognized thinking subtype")
|
|
330
|
+
|
|
331
|
+
if event_type == "tool_call":
|
|
332
|
+
subtype = event.get("subtype")
|
|
333
|
+
tool_call = event.get("tool_call")
|
|
334
|
+
if not isinstance(tool_call, dict):
|
|
335
|
+
raise CursorEventParseError("tool_call event missing tool_call payload")
|
|
336
|
+
name, body = _tool_call_name_and_body(tool_call)
|
|
337
|
+
call_id = canonical_cursor_call_id(event.get("call_id"))
|
|
338
|
+
if subtype == "started":
|
|
339
|
+
return {
|
|
340
|
+
"kind": "tool_started",
|
|
341
|
+
"session_id": session_id,
|
|
342
|
+
"call_id": call_id,
|
|
343
|
+
"tool": name,
|
|
344
|
+
"args": body.get("args"),
|
|
345
|
+
}
|
|
346
|
+
if subtype == "completed":
|
|
347
|
+
outcome, outcome_payload = _cursor_tool_result_outcome(
|
|
348
|
+
body.get("result")
|
|
349
|
+
)
|
|
350
|
+
if outcome in CURSOR_TOOL_STATUS_OUTCOMES:
|
|
351
|
+
status_messages = {
|
|
352
|
+
"approved": (
|
|
353
|
+
"Cursor approved the requested action; any underlying "
|
|
354
|
+
"execution is reported separately."
|
|
355
|
+
),
|
|
356
|
+
"async": "Cursor queued an asynchronous question.",
|
|
357
|
+
"stillRunning": (
|
|
358
|
+
"Cursor reports that the underlying task is still "
|
|
359
|
+
"running."
|
|
360
|
+
),
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
"kind": "tool_status",
|
|
364
|
+
"session_id": session_id,
|
|
365
|
+
"call_id": call_id,
|
|
366
|
+
"tool": name,
|
|
367
|
+
"args": body.get("args"),
|
|
368
|
+
"status": outcome,
|
|
369
|
+
"message": status_messages[outcome],
|
|
370
|
+
}
|
|
371
|
+
if outcome in CURSOR_TOOL_CONFIRMATION_OUTCOMES:
|
|
372
|
+
return {
|
|
373
|
+
"kind": "tool_confirmation_required",
|
|
374
|
+
"session_id": session_id,
|
|
375
|
+
"call_id": call_id,
|
|
376
|
+
"tool": name,
|
|
377
|
+
"args": body.get("args"),
|
|
378
|
+
"message": (
|
|
379
|
+
"Cursor requires confirmation before this action can "
|
|
380
|
+
"continue."
|
|
381
|
+
),
|
|
382
|
+
}
|
|
383
|
+
# Shell calls can come back as {"rejected": {...}} instead of
|
|
384
|
+
# {"success": {...}} - approval/sandbox policy declined the
|
|
385
|
+
# call rather than it failing. Surface that distinctly; it is
|
|
386
|
+
# not the same thing as a tool erroring.
|
|
387
|
+
if outcome == "rejected":
|
|
388
|
+
return {
|
|
389
|
+
"kind": "tool_rejected",
|
|
390
|
+
"session_id": session_id,
|
|
391
|
+
"call_id": call_id,
|
|
392
|
+
"tool": name,
|
|
393
|
+
"args": body.get("args"),
|
|
394
|
+
"reason": outcome_payload.get("reason") or None,
|
|
395
|
+
}
|
|
396
|
+
if outcome in CURSOR_TOOL_FAILURE_OUTCOMES:
|
|
397
|
+
return {
|
|
398
|
+
"kind": "tool_finished",
|
|
399
|
+
"session_id": session_id,
|
|
400
|
+
"call_id": call_id,
|
|
401
|
+
"tool": name,
|
|
402
|
+
"args": body.get("args"),
|
|
403
|
+
"result": outcome_payload,
|
|
404
|
+
"exit_code": _cursor_tool_exit_code(
|
|
405
|
+
outcome_payload,
|
|
406
|
+
default=1,
|
|
407
|
+
),
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
"kind": "tool_finished",
|
|
411
|
+
"session_id": session_id,
|
|
412
|
+
"call_id": call_id,
|
|
413
|
+
"tool": name,
|
|
414
|
+
"args": body.get("args"),
|
|
415
|
+
"result": outcome_payload,
|
|
416
|
+
"exit_code": _cursor_tool_exit_code(
|
|
417
|
+
outcome_payload,
|
|
418
|
+
default=0,
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
raise CursorEventParseError("unrecognized tool_call subtype")
|
|
422
|
+
|
|
423
|
+
if event_type == "assistant":
|
|
424
|
+
message = event.get("message") or {}
|
|
425
|
+
if not isinstance(message, dict):
|
|
426
|
+
raise CursorEventParseError("assistant message must be an object")
|
|
427
|
+
content = message.get("content") or []
|
|
428
|
+
if not isinstance(content, list):
|
|
429
|
+
raise CursorEventParseError("assistant content must be a list")
|
|
430
|
+
parts = [
|
|
431
|
+
part.get("text", "")
|
|
432
|
+
for part in content
|
|
433
|
+
if isinstance(part, dict) and part.get("type") == "text"
|
|
434
|
+
]
|
|
435
|
+
return {
|
|
436
|
+
"kind": "assistant_text",
|
|
437
|
+
"session_id": session_id,
|
|
438
|
+
"text": "".join(parts),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if event_type == "result":
|
|
442
|
+
if not session_id:
|
|
443
|
+
raise CursorEventParseError("result missing session_id")
|
|
444
|
+
subtype = event.get("subtype")
|
|
445
|
+
is_error = event.get("is_error")
|
|
446
|
+
result_text = event.get("result", "")
|
|
447
|
+
if subtype not in {"success", "error"}:
|
|
448
|
+
raise CursorEventParseError("unrecognized result subtype")
|
|
449
|
+
if not isinstance(is_error, bool):
|
|
450
|
+
raise CursorEventParseError("result is_error must be a boolean")
|
|
451
|
+
if (subtype == "success") == is_error:
|
|
452
|
+
raise CursorEventParseError(
|
|
453
|
+
"result subtype and is_error disagree"
|
|
454
|
+
)
|
|
455
|
+
if not isinstance(result_text, str):
|
|
456
|
+
raise CursorEventParseError("result text must be a string")
|
|
457
|
+
return {
|
|
458
|
+
"kind": "turn_finished",
|
|
459
|
+
"session_id": session_id,
|
|
460
|
+
"is_error": is_error,
|
|
461
|
+
"result_text": result_text,
|
|
462
|
+
"duration_ms": event.get("duration_ms"),
|
|
463
|
+
"usage": _normalize_cursor_usage(event.get("usage")),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
raise CursorEventParseError("unrecognized Cursor stream event type")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def normalize_cursor_stream(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
|
|
470
|
+
"""Normalize a full stream-json output, skipping events with no projection."""
|
|
471
|
+
for raw_line in lines:
|
|
472
|
+
normalized = normalize_cursor_stream_event(raw_line)
|
|
473
|
+
if normalized is not None:
|
|
474
|
+
yield normalized
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parse_cursor_models_list(output: str) -> list[dict[str, Any]]:
|
|
478
|
+
"""Parse Cursor's ``--list-models`` plain-text output.
|
|
479
|
+
|
|
480
|
+
Unlike Claude/Codex, this is not JSON - one "id - display name" pair per
|
|
481
|
+
line, with a header line, a blank-line-separated footer tip, and no
|
|
482
|
+
other structure. The model matching the id "auto" is Cursor's own
|
|
483
|
+
server-side model router, not a concrete model; every other line is a
|
|
484
|
+
concrete selectable model.
|
|
485
|
+
"""
|
|
486
|
+
models: list[dict[str, Any]] = []
|
|
487
|
+
for raw_line in output.splitlines():
|
|
488
|
+
line = raw_line.strip()
|
|
489
|
+
if not line or line.startswith("Available models") or line.startswith("Tip:"):
|
|
490
|
+
continue
|
|
491
|
+
if " - " not in line:
|
|
492
|
+
continue
|
|
493
|
+
model_id, _, label = line.partition(" - ")
|
|
494
|
+
model_id = model_id.strip()
|
|
495
|
+
label = label.strip()
|
|
496
|
+
# Real captured formats have varied by CLI version: "(current,
|
|
497
|
+
# default)" and, later, plain "(default)". Match "default" as a
|
|
498
|
+
# word inside a trailing parenthetical rather than one exact phrase.
|
|
499
|
+
default_match = re.search(r"\(\s*[^)]*\bdefault\b[^)]*\)\s*$", label, re.IGNORECASE)
|
|
500
|
+
is_default = default_match is not None
|
|
501
|
+
if default_match:
|
|
502
|
+
label = label[: default_match.start()].strip()
|
|
503
|
+
models.append({
|
|
504
|
+
"id": model_id,
|
|
505
|
+
"label": label,
|
|
506
|
+
"is_router": model_id == "auto",
|
|
507
|
+
"is_default": is_default,
|
|
508
|
+
})
|
|
509
|
+
return models
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
CURSOR_PERMISSION_MODES = ("default", "full_access", "plan")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def cursor_permission_flags(mode: str) -> list[str]:
|
|
516
|
+
"""Translate a single-enum permission mode (matching the precedent set by
|
|
517
|
+
AgentsDock's ClaudePermissionMenu.tsx - one user-chosen mode per session,
|
|
518
|
+
not a server-hardcoded default) into real Cursor CLI flags.
|
|
519
|
+
|
|
520
|
+
``--trust`` acknowledges workspace trust but does not override Cursor's
|
|
521
|
+
global/project permission configuration. ``--force`` auto-allows commands
|
|
522
|
+
except operations explicitly denied by Cursor configuration. Interactive
|
|
523
|
+
auto-review is deliberately unavailable because this headless runner
|
|
524
|
+
cannot answer approval prompts.
|
|
525
|
+
"""
|
|
526
|
+
if mode == "plan":
|
|
527
|
+
return ["--trust", "--mode", "plan"]
|
|
528
|
+
if mode == "full_access":
|
|
529
|
+
return ["--trust", "--force"]
|
|
530
|
+
# "default" and any unrecognized mode use Cursor's configured permissions
|
|
531
|
+
# without force. This is not an interactive "ask" mode and clients must
|
|
532
|
+
# not label it as one.
|
|
533
|
+
return ["--trust"]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def build_cursor_cmd(
|
|
537
|
+
sess: dict[str, Any],
|
|
538
|
+
*,
|
|
539
|
+
cursor_bin: str = "cursor-agent",
|
|
540
|
+
) -> list[str]:
|
|
541
|
+
"""Build one Cursor CLI argv from a session dict and composed prompt.
|
|
542
|
+
|
|
543
|
+
The prompt is deliberately supplied over stdin by the runner. Cursor's
|
|
544
|
+
documented headless mode accepts piped input, and keeping generated policy,
|
|
545
|
+
user text, and scoped authority out of argv prevents same-user process
|
|
546
|
+
listings from exposing the complete composed prompt.
|
|
547
|
+
"""
|
|
548
|
+
cmd = [cursor_bin, "-p", "--output-format", "stream-json"]
|
|
549
|
+
resume_id = sess.get("cursor_session_id")
|
|
550
|
+
if resume_id:
|
|
551
|
+
cmd += ["--resume", str(resume_id)]
|
|
552
|
+
# Reuses the generic "model" field (same one Claude/Codex sessions use)
|
|
553
|
+
# rather than a cursor-specific field - already fully wired through
|
|
554
|
+
# CreateSessionRequest/UpdateSessionRequest with no schema changes
|
|
555
|
+
# needed, and matches the client's existing reset-on-backend-switch
|
|
556
|
+
# behavior (`updateSession(id, { backend, model: null, effort: null })`).
|
|
557
|
+
model = sess.get("model")
|
|
558
|
+
if model:
|
|
559
|
+
cmd += ["--model", str(model)]
|
|
560
|
+
cmd += cursor_permission_flags(str(sess.get("cursor_permission_mode") or "default"))
|
|
561
|
+
return cmd
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def parse_cursor_auth_status(
|
|
565
|
+
status_output: str,
|
|
566
|
+
*,
|
|
567
|
+
executable_name: str = "cursor-agent",
|
|
568
|
+
) -> dict[str, Any]:
|
|
569
|
+
"""Parse Cursor ``status`` output into a ready/unauthenticated
|
|
570
|
+
shape matching the vocabulary /api/runtime/catalog already reports for
|
|
571
|
+
Claude and Codex (ready, missing, unauthenticated, error).
|
|
572
|
+
"""
|
|
573
|
+
text = CURSOR_ANSI_ESCAPE_RE.sub("", status_output).strip()
|
|
574
|
+
lower = text.lower()
|
|
575
|
+
if any(marker in lower for marker in (
|
|
576
|
+
"not logged in",
|
|
577
|
+
"not authenticated",
|
|
578
|
+
"authentication required",
|
|
579
|
+
"login required",
|
|
580
|
+
)):
|
|
581
|
+
return {
|
|
582
|
+
"state": "unauthenticated",
|
|
583
|
+
"action": (
|
|
584
|
+
f"Run '{executable_name} login', or set CURSOR_API_KEY."
|
|
585
|
+
),
|
|
586
|
+
}
|
|
587
|
+
if re.search(
|
|
588
|
+
r"(?:is_?authenticated|authenticated)\s*[\":=]*\s*false\b",
|
|
589
|
+
lower,
|
|
590
|
+
):
|
|
591
|
+
return {
|
|
592
|
+
"state": "unauthenticated",
|
|
593
|
+
"action": (
|
|
594
|
+
f"Run '{executable_name} login', or set CURSOR_API_KEY."
|
|
595
|
+
),
|
|
596
|
+
}
|
|
597
|
+
logged_in_match = re.search(
|
|
598
|
+
r"(?:^|\n)\s*(?:✓\s*)?logged in as\s+([^\r\n]+)",
|
|
599
|
+
text,
|
|
600
|
+
re.IGNORECASE,
|
|
601
|
+
)
|
|
602
|
+
if logged_in_match:
|
|
603
|
+
email = logged_in_match.group(1).strip()
|
|
604
|
+
return {"state": "ready", "email": email or None}
|
|
605
|
+
if re.search(
|
|
606
|
+
r"(?:is_?authenticated|authenticated)\s*[\":=]*\s*true\b",
|
|
607
|
+
lower,
|
|
608
|
+
) or re.search(r"(?:^|\n)\s*authenticated\s*$", lower):
|
|
609
|
+
return {"state": "ready", "email": None}
|
|
610
|
+
return {"state": "error", "message": text}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# Cursor reports a free-form "Subscription Tier" value. Only explicit free
|
|
614
|
+
# labels lock named models; unknown/future paid labels remain usable and Cursor
|
|
615
|
+
# itself returns the authoritative model-access error when necessary.
|
|
616
|
+
CURSOR_EXPLICIT_FREE_TIERS = {"free", "hobby", "individual free"}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def parse_cursor_account_tier(about_output: str) -> str | None:
|
|
620
|
+
"""Parse ``agent about``'s table output for the ``Subscription Tier`` row.
|
|
621
|
+
|
|
622
|
+
Real captured output (2026.08.11-e8db854, authenticated):
|
|
623
|
+
About Cursor CLI
|
|
624
|
+
|
|
625
|
+
CLI Version 2026.08.11-e8db854
|
|
626
|
+
Model Claude Sonnet 5 300K High
|
|
627
|
+
Subscription Tier Pro
|
|
628
|
+
OS darwin (arm64)
|
|
629
|
+
...
|
|
630
|
+
|
|
631
|
+
Returns the raw tier string (e.g. "Pro") verbatim, or None if the row
|
|
632
|
+
isn't present (older CLI, or output shape changed).
|
|
633
|
+
"""
|
|
634
|
+
for raw_line in about_output.splitlines():
|
|
635
|
+
match = re.match(
|
|
636
|
+
r"^\s*subscription\s+tier\s*(?::\s*|\s+)(.*?)\s*$",
|
|
637
|
+
raw_line,
|
|
638
|
+
re.IGNORECASE,
|
|
639
|
+
)
|
|
640
|
+
if match:
|
|
641
|
+
return match.group(1).strip() or None
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def cursor_account_is_free_tier(tier: str | None) -> bool:
|
|
646
|
+
"""True only for an explicit Cursor free-tier label.
|
|
647
|
+
|
|
648
|
+
``agent about`` may report service accounts, API-key users, or future paid
|
|
649
|
+
plans with an unfamiliar label. Treating every unknown as free silently
|
|
650
|
+
disabled named models for paying users; Cursor remains the final authority
|
|
651
|
+
if an account cannot use a selected model.
|
|
652
|
+
"""
|
|
653
|
+
clean = re.sub(r"[^a-z0-9]+", " ", str(tier or "").strip().lower()).strip()
|
|
654
|
+
return clean in CURSOR_EXPLICIT_FREE_TIERS
|