optio-cursor 0.1.0__py3-none-any.whl
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.
- optio_cursor/__init__.py +56 -0
- optio_cursor/conversation.py +516 -0
- optio_cursor/conversation_listener.py +321 -0
- optio_cursor/cred_watcher.py +126 -0
- optio_cursor/fs_allowlist.py +124 -0
- optio_cursor/host_actions.py +1453 -0
- optio_cursor/model_probe.py +149 -0
- optio_cursor/models.py +157 -0
- optio_cursor/prompt.py +188 -0
- optio_cursor/seed_manifest.py +82 -0
- optio_cursor/session.py +895 -0
- optio_cursor/snapshots.py +100 -0
- optio_cursor/types.py +308 -0
- optio_cursor/verify.py +142 -0
- optio_cursor-0.1.0.dist-info/METADATA +70 -0
- optio_cursor-0.1.0.dist-info/RECORD +18 -0
- optio_cursor-0.1.0.dist-info/WHEEL +5 -0
- optio_cursor-0.1.0.dist-info/top_level.txt +1 -0
optio_cursor/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""optio-cursor — run Cursor CLI (cursor-agent) as an optio task."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
from optio_agents import HookContext, HookContextProtocol
|
|
6
|
+
from optio_host import (
|
|
7
|
+
HostCommandError,
|
|
8
|
+
RunResult,
|
|
9
|
+
SSHConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from optio_cursor.seed_manifest import (
|
|
13
|
+
CURSOR_CRED_MANIFEST,
|
|
14
|
+
CURSOR_SEED_MANIFEST,
|
|
15
|
+
CURSOR_SEED_SUFFIX,
|
|
16
|
+
delete_seed,
|
|
17
|
+
list_seeds,
|
|
18
|
+
purge_seed,
|
|
19
|
+
)
|
|
20
|
+
from optio_cursor.session import create_cursor_task, run_cursor_session
|
|
21
|
+
from optio_cursor.types import (
|
|
22
|
+
CursorTaskConfig,
|
|
23
|
+
DeliverableCallback,
|
|
24
|
+
HookCallback,
|
|
25
|
+
SeedProvider,
|
|
26
|
+
SeedUnavailableError,
|
|
27
|
+
)
|
|
28
|
+
from optio_cursor.verify import verify_and_refresh_seed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# asyncssh emits per-connection INFO lines that flood worker stdout
|
|
32
|
+
# once an SSH-backed session starts. Quiet by default.
|
|
33
|
+
_logging.getLogger("asyncssh").setLevel(_logging.WARNING)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"create_cursor_task",
|
|
38
|
+
"run_cursor_session",
|
|
39
|
+
"CursorTaskConfig",
|
|
40
|
+
"DeliverableCallback",
|
|
41
|
+
"HookCallback",
|
|
42
|
+
"SSHConfig",
|
|
43
|
+
"HookContext",
|
|
44
|
+
"HookContextProtocol",
|
|
45
|
+
"HostCommandError",
|
|
46
|
+
"RunResult",
|
|
47
|
+
"CURSOR_SEED_MANIFEST",
|
|
48
|
+
"CURSOR_CRED_MANIFEST",
|
|
49
|
+
"CURSOR_SEED_SUFFIX",
|
|
50
|
+
"delete_seed",
|
|
51
|
+
"list_seeds",
|
|
52
|
+
"purge_seed",
|
|
53
|
+
"SeedProvider",
|
|
54
|
+
"SeedUnavailableError",
|
|
55
|
+
"verify_and_refresh_seed",
|
|
56
|
+
]
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""CursorConversation — engine-side driver for one headless Cursor session
|
|
2
|
+
over the Agent Client Protocol (ACP): JSON-RPC 2.0 over the stdin/stdout of
|
|
3
|
+
``cursor-agent acp``.
|
|
4
|
+
|
|
5
|
+
The session body launches ``cursor-agent [--model M] [--force] acp`` via
|
|
6
|
+
``host.launch_subprocess(stdin=True)``, attaches the handle here, starts
|
|
7
|
+
``run_reader()``, runs ``bootstrap()`` (the ACP handshake), publishes this
|
|
8
|
+
object via ``ctx.publish_result``, and waits until the subprocess ends.
|
|
9
|
+
|
|
10
|
+
Event payloads are transparent: every parsed stdout JSON-RPC object is fanned
|
|
11
|
+
out to ``on_event`` subscribers as a dict, unmodified. Synthetic events use
|
|
12
|
+
the ``x-optio-`` type prefix. Adapted from optio-grok's GrokConversation —
|
|
13
|
+
both agents speak the same public ACP protocol.
|
|
14
|
+
|
|
15
|
+
============================================================================
|
|
16
|
+
ACP WIRE FACTS for `cursor-agent acp` (JSON-RPC 2.0 over stdio).
|
|
17
|
+
Provenance per shape:
|
|
18
|
+
[cursor-verified] — pinned by a live UNAUTHENTICATED handshake probe of
|
|
19
|
+
the real `cursor-agent acp` on this host.
|
|
20
|
+
[grok-pinned, cursor runtime-unverified] — copied from optio-grok's
|
|
21
|
+
LIVE-pinned ACP shapes (grok 0.2.81; see
|
|
22
|
+
optio_grok/conversation.py). Cursor implements the
|
|
23
|
+
same public ACP protocol; a logged-in prompt-cycle
|
|
24
|
+
probe was NOT possible (host `cursor-agent status` =
|
|
25
|
+
"Not logged in"). Runtime confirmation deferred to
|
|
26
|
+
the demo stage — tracked in design doc §7 item 3.
|
|
27
|
+
============================================================================
|
|
28
|
+
|
|
29
|
+
Methods present in the cursor binary [cursor-verified]:
|
|
30
|
+
session/new, session/load, session/prompt, session/cancel, session/update,
|
|
31
|
+
session/set_model, session/request_permission, authenticate.
|
|
32
|
+
|
|
33
|
+
Client -> agent REQUESTS (have `id`, expect a `result`):
|
|
34
|
+
* ``initialize`` {protocolVersion:1, clientCapabilities:{…}} ->
|
|
35
|
+
[cursor-verified] {protocolVersion:1, agentCapabilities:{loadSession:
|
|
36
|
+
true, promptCapabilities:{image:true}, sessionCapabilities:{list:{}}},
|
|
37
|
+
authMethods:[{id:"cursor_login"}]}.
|
|
38
|
+
* ``session/new`` {cwd, mcpServers:[]} -> {sessionId, models, _meta}.
|
|
39
|
+
[grok-pinned, cursor runtime-unverified]
|
|
40
|
+
* ``session/prompt`` {sessionId, prompt:[{type:"text", text}]} ->
|
|
41
|
+
**THIS RESPONSE IS THE TURN-END SIGNAL**: {stopReason:"end_turn" |
|
|
42
|
+
"cancelled" | …}. A denied/aborted turn returns stopReason:"cancelled".
|
|
43
|
+
[grok-pinned, cursor runtime-unverified]
|
|
44
|
+
|
|
45
|
+
Agent -> client NOTIFICATIONS (no `id`): ``session/update`` with
|
|
46
|
+
``params.update.sessionUpdate`` ∈: [grok-pinned, cursor runtime-unverified]
|
|
47
|
+
* ``agent_message_chunk`` — {update:{sessionUpdate, content:{type:"text",
|
|
48
|
+
text}}}. Concatenate per turn -> the final answer (on_message).
|
|
49
|
+
* ``agent_thought_chunk`` — same shape; reasoning, NOT folded into answer.
|
|
50
|
+
* ``tool_call`` — {update:{sessionUpdate, toolCallId, title,
|
|
51
|
+
rawInput, …}}.
|
|
52
|
+
* ``tool_call_update`` — {update:{sessionUpdate, toolCallId, kind, title,
|
|
53
|
+
content:[…], rawInput, status}}.
|
|
54
|
+
* ``plan`` / ``available_commands_update`` / ``user_message_chunk`` and any
|
|
55
|
+
vendor-prefixed notifications — passed through untouched to on_event.
|
|
56
|
+
|
|
57
|
+
Agent -> client REQUESTS (have `id` AND `method`, WE must respond):
|
|
58
|
+
* ``session/request_permission`` {sessionId, toolCall:{toolCallId, kind,
|
|
59
|
+
title, rawInput}, options:[{optionId, name, kind}]}. Option `kind` ∈
|
|
60
|
+
{allow_once, allow_always, reject_once, reject_always}. ANSWER with
|
|
61
|
+
``result``:
|
|
62
|
+
allow -> {outcome:{outcome:"selected", optionId:<an allow_* option>}}
|
|
63
|
+
deny -> {outcome:{outcome:"selected", optionId:<a reject_* option>}}
|
|
64
|
+
or {outcome:{outcome:"cancelled"}} if no reject option.
|
|
65
|
+
(Only appears when the client does NOT advertise the relevant capability;
|
|
66
|
+
we advertise neither terminal nor fs write, so cursor runs its own tools
|
|
67
|
+
and asks here — that is the permission gate seam.)
|
|
68
|
+
[grok-pinned, cursor runtime-unverified]
|
|
69
|
+
* ``terminal/create`` / ``fs/*`` — only if we advertise those capabilities
|
|
70
|
+
(we do not); answered with a JSON-RPC method-not-found error defensively.
|
|
71
|
+
[grok-pinned, cursor runtime-unverified]
|
|
72
|
+
|
|
73
|
+
Client -> agent CANCEL: ``session/cancel`` {sessionId} is a NOTIFICATION
|
|
74
|
+
(no `id`, no ack). It makes the in-flight ``session/prompt`` return
|
|
75
|
+
stopReason:"cancelled" — that response is the interrupt's completion signal.
|
|
76
|
+
[grok-pinned, cursor runtime-unverified]
|
|
77
|
+
|
|
78
|
+
Cursor-specific divergences from grok (for Task 1/2):
|
|
79
|
+
* Subprocess is ``cursor-agent [--model M] [--force] acp`` — no
|
|
80
|
+
``--no-leader``/``stdio`` args; ``--force`` is the auto-approve analogue
|
|
81
|
+
of grok's ``--always-approve`` (acceptance by the acp subcommand is
|
|
82
|
+
runtime-unverified — fall back to answering session/request_permission
|
|
83
|
+
allow-all client-side if rejected).
|
|
84
|
+
* authMethods id is ``cursor_login`` (grok differs). [cursor-verified]
|
|
85
|
+
============================================================================
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import asyncio
|
|
91
|
+
import json
|
|
92
|
+
import logging
|
|
93
|
+
|
|
94
|
+
from optio_agents.conversation import (
|
|
95
|
+
ConversationClosed,
|
|
96
|
+
PermissionDecision,
|
|
97
|
+
PermissionRequest,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
_LOG = logging.getLogger(__name__)
|
|
101
|
+
|
|
102
|
+
# ACP option `kind` prefixes for allow / reject decisions.
|
|
103
|
+
_ALLOW_KINDS = ("allow_once", "allow_always", "allow")
|
|
104
|
+
_REJECT_KINDS = ("reject_once", "reject_always", "reject")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CursorConversation:
|
|
108
|
+
"""Implements optio_agents.conversation.Conversation for Cursor (ACP)."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
cwd: str,
|
|
114
|
+
agent_label: str = "cursor",
|
|
115
|
+
permission_gate: bool = False,
|
|
116
|
+
mcp_servers: list | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._cwd = cwd
|
|
119
|
+
self._agent_label = agent_label
|
|
120
|
+
# When False, session/request_permission is answered with a defensive
|
|
121
|
+
# deny instead of being queued for a handler.
|
|
122
|
+
self._permission_gate = permission_gate
|
|
123
|
+
self._mcp_servers = mcp_servers or []
|
|
124
|
+
self._handle = None
|
|
125
|
+
self._session_id: str | None = None
|
|
126
|
+
# ACP model block from session/new (see models.py). Captured at
|
|
127
|
+
# bootstrap so the session can populate the picker without a separate
|
|
128
|
+
# (auth-gated) `cursor-agent models` subprocess.
|
|
129
|
+
self.session_models: dict | None = None
|
|
130
|
+
self.current_model_id: str | None = None
|
|
131
|
+
self._pending = 0 # user turns awaiting their result
|
|
132
|
+
self._closed = asyncio.Event()
|
|
133
|
+
self._close_reason: str | None = None
|
|
134
|
+
# Cooperative-shutdown request towards the owning task body.
|
|
135
|
+
self.close_requested = asyncio.Event()
|
|
136
|
+
self._write_lock = asyncio.Lock()
|
|
137
|
+
self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
138
|
+
self._event_handlers: list = []
|
|
139
|
+
self._message_handlers: list = []
|
|
140
|
+
self._permission_handler = None
|
|
141
|
+
self._queued_permission_requests: list[dict] = []
|
|
142
|
+
# JSON-RPC id bookkeeping.
|
|
143
|
+
self._next_id = 0
|
|
144
|
+
self._req_futures: dict[int, asyncio.Future] = {} # handshake requests
|
|
145
|
+
self._prompt_ids: set[int] = set() # session/prompt turns
|
|
146
|
+
# Accumulates agent_message_chunk text for the current turn.
|
|
147
|
+
self._answer_parts: list[str] = []
|
|
148
|
+
self._dispatcher_task: asyncio.Task | None = None
|
|
149
|
+
|
|
150
|
+
# -- wiring ------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def attach(self, handle) -> None:
|
|
153
|
+
"""Attach the live ProcessHandle (must have been launched with
|
|
154
|
+
stdin=True)."""
|
|
155
|
+
if handle.stdin is None:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"CursorConversation.attach: handle has no stdin writer; launch "
|
|
158
|
+
"the subprocess with stdin=True"
|
|
159
|
+
)
|
|
160
|
+
self._handle = handle
|
|
161
|
+
|
|
162
|
+
async def bootstrap(self) -> None:
|
|
163
|
+
"""Run the ACP handshake: ``initialize`` then ``session/new``.
|
|
164
|
+
|
|
165
|
+
Requires ``run_reader()`` to already be running (it routes the
|
|
166
|
+
responses back to the futures created here). We advertise NEITHER the
|
|
167
|
+
terminal NOR fs-write client capability, so cursor executes its own
|
|
168
|
+
tools and surfaces approval via ``session/request_permission`` (the
|
|
169
|
+
gate seam) instead of delegating tool execution to us.
|
|
170
|
+
"""
|
|
171
|
+
await self._request("initialize", {
|
|
172
|
+
"protocolVersion": 1,
|
|
173
|
+
"clientCapabilities": {
|
|
174
|
+
"fs": {"readTextFile": False, "writeTextFile": False},
|
|
175
|
+
"terminal": False,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
resp = await self._request("session/new", {
|
|
179
|
+
"cwd": self._cwd,
|
|
180
|
+
"mcpServers": self._mcp_servers,
|
|
181
|
+
})
|
|
182
|
+
result = (resp or {}).get("result") or {}
|
|
183
|
+
self._session_id = result.get("sessionId")
|
|
184
|
+
if not self._session_id:
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"cursor ACP session/new returned no sessionId: {result!r}"
|
|
187
|
+
)
|
|
188
|
+
models = result.get("models")
|
|
189
|
+
if isinstance(models, dict):
|
|
190
|
+
self.session_models = models
|
|
191
|
+
self.current_model_id = models.get("currentModelId")
|
|
192
|
+
|
|
193
|
+
async def reset_session(self) -> str | None:
|
|
194
|
+
"""Start a FRESH ACP session (drops the current session's chat context)
|
|
195
|
+
without re-initializing. Used after the startup model probe so its
|
|
196
|
+
throwaway "capital of Hungary" turns never leak into the operator's
|
|
197
|
+
conversation. Returns the ABANDONED session id (so the caller can purge
|
|
198
|
+
its on-disk records, which cursor persists under $HOME and would
|
|
199
|
+
otherwise be snapshot-captured and rediscovered on resume). Best-effort:
|
|
200
|
+
on failure the existing session is kept and None is returned."""
|
|
201
|
+
old = self._session_id
|
|
202
|
+
try:
|
|
203
|
+
resp = await self._request("session/new", {
|
|
204
|
+
"cwd": self._cwd,
|
|
205
|
+
"mcpServers": self._mcp_servers,
|
|
206
|
+
})
|
|
207
|
+
except Exception: # noqa: BLE001 — a reset failure just keeps the session
|
|
208
|
+
_LOG.exception("cursor conversation: reset_session failed")
|
|
209
|
+
return None
|
|
210
|
+
result = (resp or {}).get("result") or {}
|
|
211
|
+
sid = result.get("sessionId")
|
|
212
|
+
if sid:
|
|
213
|
+
self._session_id = sid
|
|
214
|
+
models = result.get("models")
|
|
215
|
+
if isinstance(models, dict):
|
|
216
|
+
self.session_models = models
|
|
217
|
+
self.current_model_id = models.get("currentModelId")
|
|
218
|
+
return old if (old and old != self._session_id) else None
|
|
219
|
+
|
|
220
|
+
async def run_reader(self) -> None:
|
|
221
|
+
"""Drain stdout until EOF; route JSON-RPC messages. Owned by the
|
|
222
|
+
session body; ends when the subprocess ends."""
|
|
223
|
+
self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
|
|
224
|
+
try:
|
|
225
|
+
async for raw in self._handle.stdout:
|
|
226
|
+
line = (
|
|
227
|
+
raw.decode("utf-8", errors="replace")
|
|
228
|
+
if isinstance(raw, bytes) else str(raw)
|
|
229
|
+
).strip()
|
|
230
|
+
if not line:
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
obj = json.loads(line)
|
|
234
|
+
except ValueError:
|
|
235
|
+
_LOG.warning("cursor conversation: unparseable line: %.200s", line)
|
|
236
|
+
self._event_queue.put_nowait(
|
|
237
|
+
{"type": "x-optio-unparseable", "line": line},
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
self._route(obj)
|
|
241
|
+
finally:
|
|
242
|
+
await self._finish("process ended")
|
|
243
|
+
|
|
244
|
+
def _route(self, obj: dict) -> None:
|
|
245
|
+
rid = obj.get("id")
|
|
246
|
+
method = obj.get("method")
|
|
247
|
+
if method is None and rid is not None and ("result" in obj or "error" in obj):
|
|
248
|
+
# Response to one of OUR requests.
|
|
249
|
+
if rid in self._req_futures:
|
|
250
|
+
fut = self._req_futures.pop(rid)
|
|
251
|
+
if not fut.done():
|
|
252
|
+
fut.set_result(obj)
|
|
253
|
+
elif rid in self._prompt_ids:
|
|
254
|
+
# session/prompt response == turn end.
|
|
255
|
+
self._prompt_ids.discard(rid)
|
|
256
|
+
self._pending = max(0, self._pending - 1)
|
|
257
|
+
text = "".join(self._answer_parts)
|
|
258
|
+
self._answer_parts = []
|
|
259
|
+
self._fire_message(text)
|
|
260
|
+
elif method is not None and rid is not None:
|
|
261
|
+
# Agent -> client REQUEST that we must answer.
|
|
262
|
+
if method == "session/request_permission":
|
|
263
|
+
self._on_permission(obj)
|
|
264
|
+
else:
|
|
265
|
+
# Unadvertised capability (terminal/create, fs/*): decline so
|
|
266
|
+
# cursor falls back to running the tool itself.
|
|
267
|
+
asyncio.ensure_future(self._write_json({
|
|
268
|
+
"jsonrpc": "2.0", "id": rid,
|
|
269
|
+
"error": {"code": -32601,
|
|
270
|
+
"message": f"optio cursor client does not implement {method}"},
|
|
271
|
+
}))
|
|
272
|
+
elif method == "session/update":
|
|
273
|
+
self._on_session_update(obj)
|
|
274
|
+
# else: other agent notifications (plan, vendor-prefixed, …) — pass
|
|
275
|
+
# through only.
|
|
276
|
+
self._event_queue.put_nowait(obj)
|
|
277
|
+
|
|
278
|
+
def _on_session_update(self, obj: dict) -> None:
|
|
279
|
+
update = (obj.get("params") or {}).get("update") or {}
|
|
280
|
+
if update.get("sessionUpdate") == "agent_message_chunk":
|
|
281
|
+
text = ((update.get("content") or {}).get("text")) or ""
|
|
282
|
+
if text:
|
|
283
|
+
self._answer_parts.append(text)
|
|
284
|
+
|
|
285
|
+
# -- event fan-out -----------------------------------------------------
|
|
286
|
+
|
|
287
|
+
async def _dispatch_loop(self) -> None:
|
|
288
|
+
while True:
|
|
289
|
+
obj = await self._event_queue.get()
|
|
290
|
+
for handler in list(self._event_handlers):
|
|
291
|
+
await self._call_handler(handler, obj, "on_event")
|
|
292
|
+
|
|
293
|
+
async def _call_handler(self, handler, arg, label: str) -> None:
|
|
294
|
+
try:
|
|
295
|
+
result = handler(arg)
|
|
296
|
+
if asyncio.iscoroutine(result):
|
|
297
|
+
await result
|
|
298
|
+
except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
|
|
299
|
+
_LOG.exception("cursor conversation: %s handler raised", label)
|
|
300
|
+
|
|
301
|
+
def _fire_message(self, text: str) -> None:
|
|
302
|
+
for handler in list(self._message_handlers):
|
|
303
|
+
asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
|
|
304
|
+
|
|
305
|
+
# -- permission gate ----------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def _on_permission(self, obj: dict) -> None:
|
|
308
|
+
if not self._permission_gate:
|
|
309
|
+
_LOG.warning(
|
|
310
|
+
"cursor conversation: session/request_permission received with "
|
|
311
|
+
"permission_gate off; denying defensively",
|
|
312
|
+
)
|
|
313
|
+
asyncio.ensure_future(self._answer_permission_decision(
|
|
314
|
+
obj, PermissionDecision(
|
|
315
|
+
behavior="deny",
|
|
316
|
+
message="optio harness: permission gate not enabled",
|
|
317
|
+
),
|
|
318
|
+
))
|
|
319
|
+
return
|
|
320
|
+
if self._permission_handler is None:
|
|
321
|
+
# Queue until a handler is registered; the turn blocks agent-side,
|
|
322
|
+
# which closes the publish/registration race.
|
|
323
|
+
self._queued_permission_requests.append(obj)
|
|
324
|
+
return
|
|
325
|
+
asyncio.ensure_future(self._answer_permission(obj))
|
|
326
|
+
|
|
327
|
+
async def _answer_permission(self, obj: dict) -> None:
|
|
328
|
+
params = obj.get("params") or {}
|
|
329
|
+
tool_call = params.get("toolCall") or {}
|
|
330
|
+
request = PermissionRequest(
|
|
331
|
+
tool_name=tool_call.get("title") or tool_call.get("kind") or "",
|
|
332
|
+
input=tool_call.get("rawInput") or {},
|
|
333
|
+
raw=obj,
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
decision = await self._permission_handler(request)
|
|
337
|
+
except Exception: # noqa: BLE001
|
|
338
|
+
_LOG.exception("cursor conversation: permission handler raised; denying")
|
|
339
|
+
decision = PermissionDecision(
|
|
340
|
+
behavior="deny",
|
|
341
|
+
message="optio harness: permission handler failed",
|
|
342
|
+
)
|
|
343
|
+
await self._answer_permission_decision(obj, decision)
|
|
344
|
+
|
|
345
|
+
async def _answer_permission_decision(
|
|
346
|
+
self, obj: dict, decision: PermissionDecision,
|
|
347
|
+
) -> None:
|
|
348
|
+
params = obj.get("params") or {}
|
|
349
|
+
options = params.get("options") or []
|
|
350
|
+
wanted = _ALLOW_KINDS if decision.behavior == "allow" else _REJECT_KINDS
|
|
351
|
+
option_id = None
|
|
352
|
+
for opt in options:
|
|
353
|
+
if (opt.get("kind") or "").lower() in wanted:
|
|
354
|
+
option_id = opt.get("optionId")
|
|
355
|
+
break
|
|
356
|
+
if option_id is not None:
|
|
357
|
+
outcome = {"outcome": "selected", "optionId": option_id}
|
|
358
|
+
else:
|
|
359
|
+
# No matching option (e.g. deny with no reject_* option): cancelling
|
|
360
|
+
# the request is ACP's abort path.
|
|
361
|
+
outcome = {"outcome": "cancelled"}
|
|
362
|
+
await self._write_json({
|
|
363
|
+
"jsonrpc": "2.0", "id": obj.get("id"),
|
|
364
|
+
"result": {"outcome": outcome},
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
# -- Conversation protocol surface --------------------------------------
|
|
368
|
+
|
|
369
|
+
async def send(self, text: str) -> None:
|
|
370
|
+
if self._closed.is_set():
|
|
371
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
372
|
+
if self._session_id is None:
|
|
373
|
+
raise RuntimeError("CursorConversation.send before bootstrap() completed")
|
|
374
|
+
self._next_id += 1
|
|
375
|
+
rid = self._next_id
|
|
376
|
+
self._prompt_ids.add(rid)
|
|
377
|
+
self._pending += 1
|
|
378
|
+
try:
|
|
379
|
+
await self._write_json({
|
|
380
|
+
"jsonrpc": "2.0", "id": rid, "method": "session/prompt",
|
|
381
|
+
"params": {
|
|
382
|
+
"sessionId": self._session_id,
|
|
383
|
+
"prompt": [{"type": "text", "text": text}],
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
except Exception:
|
|
387
|
+
self._prompt_ids.discard(rid)
|
|
388
|
+
self._pending = max(0, self._pending - 1)
|
|
389
|
+
await self._finish("stdin write failed")
|
|
390
|
+
raise
|
|
391
|
+
|
|
392
|
+
def on_event(self, handler):
|
|
393
|
+
self._event_handlers.append(handler)
|
|
394
|
+
return lambda: self._event_handlers.remove(handler)
|
|
395
|
+
|
|
396
|
+
def on_message(self, handler):
|
|
397
|
+
self._message_handlers.append(handler)
|
|
398
|
+
return lambda: self._message_handlers.remove(handler)
|
|
399
|
+
|
|
400
|
+
def on_permission_request(self, handler):
|
|
401
|
+
self._permission_handler = handler
|
|
402
|
+
queued, self._queued_permission_requests = (
|
|
403
|
+
self._queued_permission_requests, [],
|
|
404
|
+
)
|
|
405
|
+
for obj in queued:
|
|
406
|
+
asyncio.ensure_future(self._answer_permission(obj))
|
|
407
|
+
|
|
408
|
+
def _unsub() -> None:
|
|
409
|
+
if self._permission_handler is handler:
|
|
410
|
+
self._permission_handler = None
|
|
411
|
+
return _unsub
|
|
412
|
+
|
|
413
|
+
def is_pending(self) -> bool:
|
|
414
|
+
return self._pending > 0
|
|
415
|
+
|
|
416
|
+
async def interrupt(self) -> None:
|
|
417
|
+
if self._closed.is_set():
|
|
418
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
419
|
+
if self._pending == 0 or self._session_id is None:
|
|
420
|
+
return
|
|
421
|
+
# session/cancel is a notification (no id); the in-flight prompt
|
|
422
|
+
# response carrying stopReason:"cancelled" is the completion signal.
|
|
423
|
+
await self._write_json({
|
|
424
|
+
"jsonrpc": "2.0", "method": "session/cancel",
|
|
425
|
+
"params": {"sessionId": self._session_id},
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
def request_model_change(self, model: str) -> None:
|
|
429
|
+
"""Switch model mid-conversation INLINE via a ``session/set_model``
|
|
430
|
+
ACP request (no process restart) — grok's live-pinned mechanism,
|
|
431
|
+
[grok-pinned, cursor runtime-unverified]; the method is present in
|
|
432
|
+
the cursor binary. See models.py for the probe record + the
|
|
433
|
+
restart-based fallback. Synchronous surface (the listener calls it
|
|
434
|
+
without await): schedules the ACP write and updates the model
|
|
435
|
+
optimistically."""
|
|
436
|
+
if self._closed.is_set():
|
|
437
|
+
raise ConversationClosed(self._close_reason or "conversation closed")
|
|
438
|
+
if self._session_id is None:
|
|
439
|
+
raise RuntimeError(
|
|
440
|
+
"CursorConversation.request_model_change before bootstrap() completed"
|
|
441
|
+
)
|
|
442
|
+
self.current_model_id = model
|
|
443
|
+
asyncio.ensure_future(self._set_model(model))
|
|
444
|
+
|
|
445
|
+
async def _set_model(self, model: str) -> None:
|
|
446
|
+
try:
|
|
447
|
+
await self._request("session/set_model", {
|
|
448
|
+
"sessionId": self._session_id, "modelId": model,
|
|
449
|
+
})
|
|
450
|
+
except ConversationClosed:
|
|
451
|
+
pass # a swap racing the close is a no-op
|
|
452
|
+
except Exception: # noqa: BLE001 — never let a set_model bug kill the driver
|
|
453
|
+
_LOG.exception("cursor conversation: session/set_model failed")
|
|
454
|
+
|
|
455
|
+
async def set_active_model(self, model: str) -> None:
|
|
456
|
+
"""Await a ``session/set_model`` round-trip so the NEXT prompt uses
|
|
457
|
+
``model``. Used by the startup model probe (model_probe.probe_models);
|
|
458
|
+
the interactive UI path uses the fire-and-forget request_model_change."""
|
|
459
|
+
await self._set_model(model)
|
|
460
|
+
self.current_model_id = model
|
|
461
|
+
|
|
462
|
+
async def close(self) -> None:
|
|
463
|
+
self.close_requested.set()
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def closed(self) -> bool:
|
|
467
|
+
return self._closed.is_set()
|
|
468
|
+
|
|
469
|
+
# -- internals -----------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
async def _request(self, method: str, params: dict) -> dict:
|
|
472
|
+
"""Send a client->agent request and await its response (handshake only)."""
|
|
473
|
+
self._next_id += 1
|
|
474
|
+
rid = self._next_id
|
|
475
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
476
|
+
self._req_futures[rid] = fut
|
|
477
|
+
await self._write_json({
|
|
478
|
+
"jsonrpc": "2.0", "id": rid, "method": method, "params": params,
|
|
479
|
+
})
|
|
480
|
+
return await fut
|
|
481
|
+
|
|
482
|
+
async def _write_json(self, obj: dict) -> None:
|
|
483
|
+
await self._write_bytes((json.dumps(obj) + "\n").encode("utf-8"))
|
|
484
|
+
|
|
485
|
+
async def _write_bytes(self, data: bytes) -> None:
|
|
486
|
+
async with self._write_lock:
|
|
487
|
+
stdin = self._handle.stdin
|
|
488
|
+
stdin.write(data)
|
|
489
|
+
drain = getattr(stdin, "drain", None)
|
|
490
|
+
if drain is not None:
|
|
491
|
+
await drain()
|
|
492
|
+
|
|
493
|
+
async def _finish(self, reason: str) -> None:
|
|
494
|
+
if self._closed.is_set():
|
|
495
|
+
return
|
|
496
|
+
self._closed.set()
|
|
497
|
+
self._close_reason = reason
|
|
498
|
+
# Fail any in-flight handshake requests.
|
|
499
|
+
for fut in self._req_futures.values():
|
|
500
|
+
if not fut.done():
|
|
501
|
+
fut.set_exception(ConversationClosed(reason))
|
|
502
|
+
self._req_futures.clear()
|
|
503
|
+
self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
|
|
504
|
+
# Stop the dispatcher, then drain whatever it left so subscribers are
|
|
505
|
+
# guaranteed to see the final x-optio-closed event.
|
|
506
|
+
if self._dispatcher_task is not None:
|
|
507
|
+
self._dispatcher_task.cancel()
|
|
508
|
+
try:
|
|
509
|
+
await self._dispatcher_task
|
|
510
|
+
except asyncio.CancelledError:
|
|
511
|
+
pass
|
|
512
|
+
self._dispatcher_task = None
|
|
513
|
+
while not self._event_queue.empty():
|
|
514
|
+
obj = self._event_queue.get_nowait()
|
|
515
|
+
for handler in list(self._event_handlers):
|
|
516
|
+
await self._call_handler(handler, obj, "on_event")
|