expluro 0.1.7__tar.gz → 0.2.0__tar.gz
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.
- {expluro-0.1.7 → expluro-0.2.0}/PKG-INFO +2 -1
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/PKG-INFO +2 -1
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/SOURCES.txt +5 -1
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/requires.txt +1 -0
- {expluro-0.1.7 → expluro-0.2.0}/pyproject.toml +2 -1
- {expluro-0.1.7 → expluro-0.2.0}/topo/auth.py +11 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/backend.py +11 -1
- {expluro-0.1.7 → expluro-0.2.0}/topo/capture.py +94 -4
- expluro-0.2.0/topo/drivers/_branch_detection.py +90 -0
- expluro-0.2.0/topo/drivers/_permissions.py +135 -0
- expluro-0.2.0/topo/drivers/acp.py +973 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/base.py +9 -8
- {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/claude.py +168 -124
- expluro-0.2.0/topo/drivers/kiro_session_files.py +167 -0
- expluro-0.2.0/topo/main.py +267 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/renderer.py +49 -37
- {expluro-0.1.7 → expluro-0.2.0}/topo/session.py +228 -17
- expluro-0.1.7/topo/main.py +0 -46
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/dependency_links.txt +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/entry_points.txt +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/top_level.txt +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/setup.cfg +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/__init__.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/constants.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/display.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/__init__.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/picker.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/session_files.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/snapshot.py +0 -0
- {expluro-0.1.7 → expluro-0.2.0}/topo/update_check.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expluro
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Expluro CLI — graph layer on top of your AI agent
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: claude-agent-sdk>=0.0.1
|
|
7
|
+
Requires-Dist: agent-client-protocol<0.10.0,>=0.9.0
|
|
7
8
|
Requires-Dist: httpx>=0.27
|
|
8
9
|
Requires-Dist: websockets>=12.0
|
|
9
10
|
Requires-Dist: rich>=13.7
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expluro
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Expluro CLI — graph layer on top of your AI agent
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: claude-agent-sdk>=0.0.1
|
|
7
|
+
Requires-Dist: agent-client-protocol<0.10.0,>=0.9.0
|
|
7
8
|
Requires-Dist: httpx>=0.27
|
|
8
9
|
Requires-Dist: websockets>=12.0
|
|
9
10
|
Requires-Dist: rich>=13.7
|
|
@@ -19,5 +19,9 @@ topo/session_files.py
|
|
|
19
19
|
topo/snapshot.py
|
|
20
20
|
topo/update_check.py
|
|
21
21
|
topo/drivers/__init__.py
|
|
22
|
+
topo/drivers/_branch_detection.py
|
|
23
|
+
topo/drivers/_permissions.py
|
|
24
|
+
topo/drivers/acp.py
|
|
22
25
|
topo/drivers/base.py
|
|
23
|
-
topo/drivers/claude.py
|
|
26
|
+
topo/drivers/claude.py
|
|
27
|
+
topo/drivers/kiro_session_files.py
|
|
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "expluro"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Expluro CLI — graph layer on top of your AI agent"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
11
11
|
"claude-agent-sdk>=0.0.1",
|
|
12
|
+
"agent-client-protocol>=0.9.0,<0.10.0",
|
|
12
13
|
"httpx>=0.27",
|
|
13
14
|
"websockets>=12.0",
|
|
14
15
|
"rich>=13.7",
|
|
@@ -94,6 +94,17 @@ def login_command() -> None:
|
|
|
94
94
|
console.print(Padding("[dim]Run [bold]expluro[/bold] in any project directory to start a session.[/dim]", (0, 0, 1, 5)))
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def get_auto_branch() -> bool:
|
|
98
|
+
return _load_config().get("auto_branch", True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_auto_branch(enabled: bool) -> None:
|
|
102
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
config = _load_config()
|
|
104
|
+
config["auto_branch"] = enabled
|
|
105
|
+
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
106
|
+
|
|
107
|
+
|
|
97
108
|
def _load_config() -> dict:
|
|
98
109
|
if not CONFIG_PATH.exists():
|
|
99
110
|
return {}
|
|
@@ -72,10 +72,17 @@ class BackendClient:
|
|
|
72
72
|
# Nodes
|
|
73
73
|
# ------------------------------------------------------------------
|
|
74
74
|
|
|
75
|
-
async def create_node(
|
|
75
|
+
async def create_node(
|
|
76
|
+
self,
|
|
77
|
+
graph_id: str,
|
|
78
|
+
parent_id: str | None = None,
|
|
79
|
+
tool: str | None = None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
76
81
|
payload: dict[str, Any] = {"graph": graph_id}
|
|
77
82
|
if parent_id:
|
|
78
83
|
payload["parent"] = parent_id
|
|
84
|
+
if tool:
|
|
85
|
+
payload["tool"] = tool
|
|
79
86
|
return await self._post("/api/nodes/", payload)
|
|
80
87
|
|
|
81
88
|
async def patch_node(self, node_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -99,6 +106,9 @@ class BackendClient:
|
|
|
99
106
|
async def append_delta_event(self, node_id: str, event: dict[str, Any]) -> dict[str, Any]:
|
|
100
107
|
return await self._post(f"/api/nodes/{node_id}/events/", event)
|
|
101
108
|
|
|
109
|
+
async def move_event(self, event_id: str, target_node_id: str) -> dict[str, Any]:
|
|
110
|
+
return await self._post(f"/api/nodes/events/{event_id}/move/", {"target_node_id": target_node_id})
|
|
111
|
+
|
|
102
112
|
# ------------------------------------------------------------------
|
|
103
113
|
# Internal helpers
|
|
104
114
|
# ------------------------------------------------------------------
|
|
@@ -66,6 +66,16 @@ class EventCapture:
|
|
|
66
66
|
# (e.g. when a tool is denied mid-response).
|
|
67
67
|
self._streamed_text_buffer: str = ""
|
|
68
68
|
self._text_complete_fired: bool = False
|
|
69
|
+
# Hook-driven persistence (pre/post_tool_use) fires synchronously
|
|
70
|
+
# from inside the driver's session_update handler, BEFORE the REPL
|
|
71
|
+
# loop drains the queued DriverEvent and gets a chance to flush a
|
|
72
|
+
# deferred user message. To preserve correct conversation order on
|
|
73
|
+
# the backend, we hold these events in a buffer while a user
|
|
74
|
+
# message is pending — flush_pending_user_message replays them in
|
|
75
|
+
# order after the user message is recorded. See session.py for the
|
|
76
|
+
# deferred-user-message lifecycle.
|
|
77
|
+
self._pending_user_message_text: str | None = None
|
|
78
|
+
self._buffered_hook_events: list[dict[str, Any]] = []
|
|
69
79
|
|
|
70
80
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
81
|
self._log_file = self._open_log(node_id)
|
|
@@ -114,15 +124,84 @@ class EventCapture:
|
|
|
114
124
|
)
|
|
115
125
|
await self._record(event)
|
|
116
126
|
|
|
127
|
+
def set_pending_user_message(self, text: str) -> None:
|
|
128
|
+
"""Mark a user message as pending durable persistence.
|
|
129
|
+
|
|
130
|
+
While a message is pending, hook-driven persistence (pre/post_tool_use
|
|
131
|
+
events) is buffered rather than written immediately. Flushing the
|
|
132
|
+
pending message replays the buffered events in order so the backend
|
|
133
|
+
sees correct conversation order: user → tool_use → tool_result → ai.
|
|
134
|
+
|
|
135
|
+
Only the first set_pending_user_message in a turn matters; subsequent
|
|
136
|
+
calls overwrite the text but don't reset the buffer.
|
|
137
|
+
"""
|
|
138
|
+
self._pending_user_message_text = text
|
|
139
|
+
|
|
117
140
|
async def record_user_message(self, text: str) -> None:
|
|
118
|
-
"""Record a user prompt
|
|
141
|
+
"""Record a user prompt as a durable event on the current node.
|
|
142
|
+
|
|
143
|
+
Called *after* the auto-branch decision is resolved (or after the first
|
|
144
|
+
non-branch_signal event arrives, when no branch fires). At that point
|
|
145
|
+
capture.node_id already points at the correct destination — parent if
|
|
146
|
+
no branch fired, child if it did — so the durable record always lands
|
|
147
|
+
on the node that actually owns this message.
|
|
148
|
+
|
|
149
|
+
Also flushes any hook events buffered while this message was pending,
|
|
150
|
+
preserving the order user → tool events on the backend.
|
|
151
|
+
|
|
152
|
+
For the live browser bubble, see broadcast_user_message_live() — that
|
|
153
|
+
runs *before* the driver send and is non-persistent.
|
|
154
|
+
"""
|
|
119
155
|
event = self._build_event(
|
|
120
156
|
event_type="message_user",
|
|
121
157
|
tool_name="",
|
|
122
158
|
tool_use_id="",
|
|
123
159
|
data={"text": text},
|
|
124
160
|
)
|
|
125
|
-
await self._record(event)
|
|
161
|
+
backend_id = await self._record(event)
|
|
162
|
+
# Use backend-assigned ID if available; fall back to CLI-generated UUID.
|
|
163
|
+
# The backend generates its own PK — the CLI UUID is never stored in the DB.
|
|
164
|
+
self._last_user_message_event_id = backend_id or event["id"]
|
|
165
|
+
|
|
166
|
+
# Done deferring; flush any hook events that fired while we waited.
|
|
167
|
+
self._pending_user_message_text = None
|
|
168
|
+
if self._buffered_hook_events:
|
|
169
|
+
buffered = self._buffered_hook_events
|
|
170
|
+
self._buffered_hook_events = []
|
|
171
|
+
for evt in buffered:
|
|
172
|
+
# Re-anchor each buffered event onto whatever event came last
|
|
173
|
+
# (the user message we just wrote, or a previous buffered tool
|
|
174
|
+
# event) by updating parent_event_id from current state.
|
|
175
|
+
evt["parent_event_id"] = self._last_event_id
|
|
176
|
+
evt_id = await self._record(evt)
|
|
177
|
+
if evt_id:
|
|
178
|
+
self._last_event_id = evt_id
|
|
179
|
+
|
|
180
|
+
async def broadcast_user_message_live(self, text: str) -> None:
|
|
181
|
+
"""Live (non-persistent) broadcast of the user's just-typed message.
|
|
182
|
+
|
|
183
|
+
Mirrors the message_ai_chunk pattern in record_driver_event: the backend
|
|
184
|
+
relays this through the channel layer to the browser but does NOT write
|
|
185
|
+
a DB row or local jsonl line. The durable record is written later via
|
|
186
|
+
record_user_message once we know which node owns the message.
|
|
187
|
+
|
|
188
|
+
Why split? With auto-branching, we don't know at send time whether the
|
|
189
|
+
message will land on the current node or a fresh child. Persisting it
|
|
190
|
+
eagerly forces a later move (mutates an append-only log). Deferring the
|
|
191
|
+
durable write avoids the move; this live broadcast keeps the browser
|
|
192
|
+
bubble appearing instantly so UX matches a non-branching session.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
await self._backend.append_delta_event(self._node_id, {
|
|
196
|
+
"type": "message_user_chunk",
|
|
197
|
+
"text": text,
|
|
198
|
+
})
|
|
199
|
+
except Exception:
|
|
200
|
+
pass # non-critical — only affects live display
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def last_user_message_event_id(self) -> str | None:
|
|
204
|
+
return getattr(self, '_last_user_message_event_id', None)
|
|
126
205
|
|
|
127
206
|
async def record_driver_event(self, event: "DriverEvent") -> None:
|
|
128
207
|
"""Record a DriverEvent from the response stream (text, result, etc.)."""
|
|
@@ -209,6 +288,11 @@ class EventCapture:
|
|
|
209
288
|
tool_use_id=payload.tool_use_id,
|
|
210
289
|
data={"tool_input": payload.tool_input},
|
|
211
290
|
)
|
|
291
|
+
if self._pending_user_message_text is not None:
|
|
292
|
+
# Hold this event back so the user message anchors first on the
|
|
293
|
+
# backend. record_user_message replays the buffer in order.
|
|
294
|
+
self._buffered_hook_events.append(event)
|
|
295
|
+
return
|
|
212
296
|
await self._record(event)
|
|
213
297
|
|
|
214
298
|
async def _on_post_tool(self, payload: "ToolHookPayload") -> None:
|
|
@@ -223,6 +307,9 @@ class EventCapture:
|
|
|
223
307
|
"is_side_effecting": is_side_effecting,
|
|
224
308
|
},
|
|
225
309
|
)
|
|
310
|
+
if self._pending_user_message_text is not None:
|
|
311
|
+
self._buffered_hook_events.append(event)
|
|
312
|
+
return
|
|
226
313
|
await self._record(event)
|
|
227
314
|
|
|
228
315
|
# ------------------------------------------------------------------
|
|
@@ -255,16 +342,19 @@ class EventCapture:
|
|
|
255
342
|
self._last_event_id = event_id
|
|
256
343
|
return event
|
|
257
344
|
|
|
258
|
-
async def _record(self, event: dict[str, Any]) -> None:
|
|
345
|
+
async def _record(self, event: dict[str, Any]) -> str | None:
|
|
346
|
+
"""Persist event locally then to backend. Returns backend-assigned event ID, or None on failure."""
|
|
259
347
|
# 1. Write locally first — crash safe
|
|
260
348
|
self._log_file.write(json.dumps(event) + "\n")
|
|
261
349
|
self._log_file.flush()
|
|
262
350
|
|
|
263
351
|
# 2. Send to backend — best effort, never crash the session on failure
|
|
264
352
|
try:
|
|
265
|
-
await self._backend.append_delta_event(self._node_id, event)
|
|
353
|
+
resp = await self._backend.append_delta_event(self._node_id, event)
|
|
354
|
+
return resp.get("id") if isinstance(resp, dict) else None
|
|
266
355
|
except Exception as exc:
|
|
267
356
|
logger.warning("Failed to send event to backend: %s", exc)
|
|
357
|
+
return None
|
|
268
358
|
|
|
269
359
|
|
|
270
360
|
def _truncate(value: Any, max_len: int = 500) -> Any:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Branch detection — shared instruction + tag parser used by every driver.
|
|
3
|
+
|
|
4
|
+
The model is asked to begin every response with `<!--BRANCH:X.XX-->`, where
|
|
5
|
+
X.XX is its 0-1 confidence that the user's last message is a fork point.
|
|
6
|
+
We parse the tag from the front of the streaming response, decide whether
|
|
7
|
+
to fork, and strip the tag before the user ever sees it.
|
|
8
|
+
|
|
9
|
+
Drivers wire this in two places:
|
|
10
|
+
- Inject `BRANCH_DETECTION_INSTRUCTION` so the model knows to emit the tag.
|
|
11
|
+
Claude does this via system_prompt; ACP-based agents (no protocol-level
|
|
12
|
+
system prompt) prepend it to the user's prompt text on each turn.
|
|
13
|
+
- Buffer the first chunk of streamed text per turn, call `parse_branch_flag`,
|
|
14
|
+
and emit a `branch_signal` event before the cleaned text.
|
|
15
|
+
|
|
16
|
+
A backend copy lives at backend/llm/branch_detection.py for the web playground.
|
|
17
|
+
Keep both in sync until the CLI and backend share a Python package.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
BRANCH_DETECTION_INSTRUCTION = (
|
|
24
|
+
"\n\nIMPORTANT — OUTPUT FORMAT (overrides all other formatting rules):\n"
|
|
25
|
+
"Every response must begin with <!--BRANCH:X.XX--> on its own line, followed "
|
|
26
|
+
"by a newline, before any other character. Never skip, explain, or mention "
|
|
27
|
+
"this tag. If you're about to write anything else first, stop and output the tag.\n\n"
|
|
28
|
+
"X.XX is your confidence (0.00–1.00) that the user's most recent message is "
|
|
29
|
+
"a fork point relative to the prior conversation.\n\n"
|
|
30
|
+
"What a fork point is\n"
|
|
31
|
+
"A fork point is a turn where — if the user later replaced their message with "
|
|
32
|
+
"a different one — the resulting conversation would go somewhere meaningfully "
|
|
33
|
+
"different. It's a decision moment worth being able to revisit.\n\n"
|
|
34
|
+
"Judge the semantic shift, not the wording. Words like 'fork', 'pivot', "
|
|
35
|
+
"'different', or 'alternative' are not triggers on their own — 'let's fork into "
|
|
36
|
+
"option B' and 'let's try option B' are the same signal.\n\n"
|
|
37
|
+
"Two kinds of fork points\n"
|
|
38
|
+
"1. Option selection — the user picks one item from a set the assistant raised "
|
|
39
|
+
"(or that's implicit in the thread). E.g. assistant listed formal/natural/social "
|
|
40
|
+
"sciences, user says 'let's go deep on formal sciences.'\n"
|
|
41
|
+
"2. Topic or direction change — the user's message shifts to a substantially "
|
|
42
|
+
"different subject, goal, or approach. Options don't need to have been presented. "
|
|
43
|
+
"E.g. prior turn discussed Premier League, next message is 'when did World War I start?'\n\n"
|
|
44
|
+
"Not fork points\n"
|
|
45
|
+
"- Follow-up, clarification, or drill-down on the current thread.\n"
|
|
46
|
+
"- Correction, bug fix, or refinement of the current output.\n"
|
|
47
|
+
"- Incremental elaboration on the same topic.\n\n"
|
|
48
|
+
"Confidence bands\n"
|
|
49
|
+
"- 0.00–0.20 — follow-up, clarification, refinement\n"
|
|
50
|
+
"- 0.20–0.50 — noticeable shift but on the same general topic\n"
|
|
51
|
+
"- 0.50–0.70 — meaningful shift, ambiguous whether it's a fork\n"
|
|
52
|
+
"- 0.70–1.00 — clear fork: option chosen, or subject/direction changed substantially\n\n"
|
|
53
|
+
"Guardrails\n"
|
|
54
|
+
"- If fewer than 2 prior assistant responses exist, output ≤ 0.10 (not enough "
|
|
55
|
+
"context to fork from).\n"
|
|
56
|
+
"- When uncertain, bias downward. A missed fork is recoverable; a false positive "
|
|
57
|
+
"fragments the graph.\n\n"
|
|
58
|
+
"Examples\n"
|
|
59
|
+
"- Assistant listed options, user says 'dig into option A' → 0.90\n"
|
|
60
|
+
"- Prior turn on Ethiopian football, user asks 'when did WWII happen?' → 0.85\n"
|
|
61
|
+
"- 'Let's try a completely different approach' on the current task → 0.85\n"
|
|
62
|
+
"- Conversation was about database schema, user asks 'how should we handle "
|
|
63
|
+
"migrations?' (related but distinct decision scope) → 0.70\n"
|
|
64
|
+
"- User shifts from implementation details to architecture of the same solution → 0.40\n"
|
|
65
|
+
"- User asks 'how does this work?' on what was just explained → 0.05\n"
|
|
66
|
+
"- 'This is broken, fix the null case' → 0.10\n"
|
|
67
|
+
"- 'I just forked the GitHub repo to experiment locally' (mentions 'fork' "
|
|
68
|
+
"but is describing an action, not expressing branching intent) → 0.10\n"
|
|
69
|
+
"- First message of the session → 0.00\n\n"
|
|
70
|
+
"After the tag, on the next line, write your normal response."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
BRANCH_FLAG_START_RE = re.compile(r"^<!--BRANCH:([\d.]+)-->\r?\n?")
|
|
74
|
+
|
|
75
|
+
# Flush the head buffer once we have this many chars without a newline —
|
|
76
|
+
# protects against a model that ignores the instruction and never emits one.
|
|
77
|
+
HEAD_BUFFER_MAX = 40
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
|
|
81
|
+
"""Parse the BRANCH tag from a buffered head chunk.
|
|
82
|
+
|
|
83
|
+
Returns (confidence, remaining_text). When the head doesn't start with
|
|
84
|
+
a valid tag, confidence is None and the entire buffered_head is
|
|
85
|
+
returned as remaining_text — caller should treat it as regular content.
|
|
86
|
+
"""
|
|
87
|
+
m = BRANCH_FLAG_START_RE.match(buffered_head)
|
|
88
|
+
if m:
|
|
89
|
+
return float(m.group(1)), buffered_head[m.end():]
|
|
90
|
+
return None, buffered_head
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared interactive permission UI used by every driver (Claude native,
|
|
3
|
+
ACP-based agents, etc.).
|
|
4
|
+
|
|
5
|
+
The agent-side details differ per driver: Claude uses a PreToolUse hook,
|
|
6
|
+
ACP uses session/request_permission. Both surfaces converge on the same
|
|
7
|
+
question — does the user want to let this tool run? — and the same UI:
|
|
8
|
+
an arrow-key picker offering Allow / Always allow this tool / Deny /
|
|
9
|
+
Bypass all.
|
|
10
|
+
|
|
11
|
+
Drivers own their own PermissionState (the always-allowed set and
|
|
12
|
+
bypass flag) so each agent has its own trust memory. The helper here
|
|
13
|
+
just shows the picker and tells the driver which choice was made; the
|
|
14
|
+
driver maps that decision to its native protocol response.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PermissionDecision(Enum):
|
|
27
|
+
"""What the user picked. Drivers translate this to their protocol's response."""
|
|
28
|
+
ALLOW_ONCE = "allow_once"
|
|
29
|
+
ALLOW_ALWAYS = "allow_always"
|
|
30
|
+
DENY = "deny"
|
|
31
|
+
BYPASS_ALL = "bypass_all"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PermissionState:
|
|
36
|
+
"""Per-driver memory for permission shortcuts.
|
|
37
|
+
|
|
38
|
+
`always_allowed` survives across branches because the user has explicitly
|
|
39
|
+
granted persistent trust to that tool. `bypass_all` does NOT survive a
|
|
40
|
+
branch switch — it's too broad to silently inherit on a new branch.
|
|
41
|
+
Drivers reset `bypass_all` at branch boundaries themselves; this struct
|
|
42
|
+
just stores it.
|
|
43
|
+
"""
|
|
44
|
+
always_allowed: set[str] = field(default_factory=set)
|
|
45
|
+
bypass_all: bool = False
|
|
46
|
+
|
|
47
|
+
def fast_allow(self, tool_name: str) -> bool:
|
|
48
|
+
"""Skip the picker entirely if a prior choice already covers this tool."""
|
|
49
|
+
return self.bypass_all or tool_name in self.always_allowed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def ask_user_for_permission(
|
|
53
|
+
tool_name: str,
|
|
54
|
+
summary: str,
|
|
55
|
+
*,
|
|
56
|
+
state: PermissionState,
|
|
57
|
+
) -> PermissionDecision:
|
|
58
|
+
"""Show the interactive picker. Mutates `state` on always/bypass choices.
|
|
59
|
+
|
|
60
|
+
Caller should check `state.fast_allow(tool_name)` first and skip this
|
|
61
|
+
function entirely if it returns True. We always show the picker if we
|
|
62
|
+
get here; we don't second-guess the caller.
|
|
63
|
+
|
|
64
|
+
`tool_name` is the short label shown in the header (e.g. "Bash", "Read",
|
|
65
|
+
or for ACP a generic "execute" / "edit" / etc).
|
|
66
|
+
|
|
67
|
+
`summary` is the optional one-line detail shown under the header (e.g.
|
|
68
|
+
the actual command, file path, query). Empty string if no detail.
|
|
69
|
+
|
|
70
|
+
KeyboardInterrupt during the picker is treated as deny — same as the
|
|
71
|
+
user picking "Deny" — so Ctrl+C is a fast-path no.
|
|
72
|
+
"""
|
|
73
|
+
# Late imports to avoid cycles with picker / renderer at module-load time.
|
|
74
|
+
from ..picker import pick_option
|
|
75
|
+
from ..renderer import pause_active_spinner, resume_active_spinner
|
|
76
|
+
|
|
77
|
+
summary_line = (
|
|
78
|
+
f"\n <ansibrightblack> {summary}</ansibrightblack>" if summary else ""
|
|
79
|
+
)
|
|
80
|
+
header = (
|
|
81
|
+
f" <ansicyan>⚙ {tool_name}</ansicyan> "
|
|
82
|
+
f"<ansibrightblack>wants to run:</ansibrightblack>{summary_line}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
options: list[tuple[str, str]] = [
|
|
86
|
+
("Allow", "allow"),
|
|
87
|
+
(f"Always allow {tool_name}", "always"),
|
|
88
|
+
("Deny", "deny"),
|
|
89
|
+
("Bypass all checks", "bypass"),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
pause_active_spinner()
|
|
93
|
+
try:
|
|
94
|
+
result = await pick_option(header, options)
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
resume_active_spinner()
|
|
97
|
+
logger.info("Permission: cancelled for %s", tool_name)
|
|
98
|
+
return PermissionDecision.DENY
|
|
99
|
+
resume_active_spinner()
|
|
100
|
+
|
|
101
|
+
if result == "always":
|
|
102
|
+
state.always_allowed.add(tool_name)
|
|
103
|
+
logger.info("Permission: always allow %s", tool_name)
|
|
104
|
+
return PermissionDecision.ALLOW_ALWAYS
|
|
105
|
+
if result == "bypass":
|
|
106
|
+
state.bypass_all = True
|
|
107
|
+
logger.info("Permission: bypass all remaining checks")
|
|
108
|
+
return PermissionDecision.BYPASS_ALL
|
|
109
|
+
if result == "allow":
|
|
110
|
+
return PermissionDecision.ALLOW_ONCE
|
|
111
|
+
|
|
112
|
+
logger.info("Permission: user denied %s", tool_name)
|
|
113
|
+
return PermissionDecision.DENY
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def decision_is_allow(decision: PermissionDecision) -> bool:
|
|
117
|
+
"""Convenience for drivers that just want a yes/no signal."""
|
|
118
|
+
return decision in (
|
|
119
|
+
PermissionDecision.ALLOW_ONCE,
|
|
120
|
+
PermissionDecision.ALLOW_ALWAYS,
|
|
121
|
+
PermissionDecision.BYPASS_ALL,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Type-only re-export so pydantic / drivers can annotate cleanly.
|
|
126
|
+
__all__ = [
|
|
127
|
+
"PermissionDecision",
|
|
128
|
+
"PermissionState",
|
|
129
|
+
"ask_user_for_permission",
|
|
130
|
+
"decision_is_allow",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Avoid unused imports linter noise:
|
|
135
|
+
_: Any = None
|