@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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