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.
Files changed (30) hide show
  1. {expluro-0.1.7 → expluro-0.2.0}/PKG-INFO +2 -1
  2. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/PKG-INFO +2 -1
  3. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/SOURCES.txt +5 -1
  4. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/requires.txt +1 -0
  5. {expluro-0.1.7 → expluro-0.2.0}/pyproject.toml +2 -1
  6. {expluro-0.1.7 → expluro-0.2.0}/topo/auth.py +11 -0
  7. {expluro-0.1.7 → expluro-0.2.0}/topo/backend.py +11 -1
  8. {expluro-0.1.7 → expluro-0.2.0}/topo/capture.py +94 -4
  9. expluro-0.2.0/topo/drivers/_branch_detection.py +90 -0
  10. expluro-0.2.0/topo/drivers/_permissions.py +135 -0
  11. expluro-0.2.0/topo/drivers/acp.py +973 -0
  12. {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/base.py +9 -8
  13. {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/claude.py +168 -124
  14. expluro-0.2.0/topo/drivers/kiro_session_files.py +167 -0
  15. expluro-0.2.0/topo/main.py +267 -0
  16. {expluro-0.1.7 → expluro-0.2.0}/topo/renderer.py +49 -37
  17. {expluro-0.1.7 → expluro-0.2.0}/topo/session.py +228 -17
  18. expluro-0.1.7/topo/main.py +0 -46
  19. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/dependency_links.txt +0 -0
  20. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/entry_points.txt +0 -0
  21. {expluro-0.1.7 → expluro-0.2.0}/expluro.egg-info/top_level.txt +0 -0
  22. {expluro-0.1.7 → expluro-0.2.0}/setup.cfg +0 -0
  23. {expluro-0.1.7 → expluro-0.2.0}/topo/__init__.py +0 -0
  24. {expluro-0.1.7 → expluro-0.2.0}/topo/constants.py +0 -0
  25. {expluro-0.1.7 → expluro-0.2.0}/topo/display.py +0 -0
  26. {expluro-0.1.7 → expluro-0.2.0}/topo/drivers/__init__.py +0 -0
  27. {expluro-0.1.7 → expluro-0.2.0}/topo/picker.py +0 -0
  28. {expluro-0.1.7 → expluro-0.2.0}/topo/session_files.py +0 -0
  29. {expluro-0.1.7 → expluro-0.2.0}/topo/snapshot.py +0 -0
  30. {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.1.7
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.1.7
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
@@ -1,4 +1,5 @@
1
1
  claude-agent-sdk>=0.0.1
2
+ agent-client-protocol<0.10.0,>=0.9.0
2
3
  httpx>=0.27
3
4
  websockets>=12.0
4
5
  rich>=13.7
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.1.7"
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(self, graph_id: str, parent_id: str | None = None) -> dict[str, Any]:
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 before it's sent to the driver."""
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