@widgex/wgx 0.0.0-stage → 0.4.1

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 (130) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4802 -0
  4. package/package.json +29 -4
  5. package/python/XACTOR.md +146 -0
  6. package/python/browser_actor_host.py +456 -0
  7. package/python/cdp_page.py +363 -0
  8. package/python/harness_check.py +77 -0
  9. package/python/requirements.txt +1 -0
  10. package/python/test_line_endings.py +185 -0
  11. package/python/xactor/__init__.py +18 -0
  12. package/python/xactor/__main__.py +226 -0
  13. package/python/xactor/api.py +882 -0
  14. package/python/xactor/bridge.py +784 -0
  15. package/python/xactor/dom.py +117 -0
  16. package/python/xactor/man/add_card.md +89 -0
  17. package/python/xactor/man/agent-vs-fork.md +108 -0
  18. package/python/xactor/man/agent.md +333 -0
  19. package/python/xactor/man/ask_model.md +110 -0
  20. package/python/xactor/man/ask_reference.md +56 -0
  21. package/python/xactor/man/ask_user.md +103 -0
  22. package/python/xactor/man/attach_app.md +595 -0
  23. package/python/xactor/man/attach_ide.md +65 -0
  24. package/python/xactor/man/attach_ide_surface.md +31 -0
  25. package/python/xactor/man/await_user.md +214 -0
  26. package/python/xactor/man/bash.md +142 -0
  27. package/python/xactor/man/browser-driver.md +137 -0
  28. package/python/xactor/man/browser_activity.md +53 -0
  29. package/python/xactor/man/browser_attach.md +84 -0
  30. package/python/xactor/man/browser_close_tab.md +50 -0
  31. package/python/xactor/man/browser_detach.md +50 -0
  32. package/python/xactor/man/browser_exec.md +66 -0
  33. package/python/xactor/man/browser_focus_tab.md +47 -0
  34. package/python/xactor/man/browser_hybrid.md +47 -0
  35. package/python/xactor/man/browser_open.md +50 -0
  36. package/python/xactor/man/browser_open_tab.md +58 -0
  37. package/python/xactor/man/browser_plumbing.md +107 -0
  38. package/python/xactor/man/browser_screenshot.md +56 -0
  39. package/python/xactor/man/browser_snapshot.md +52 -0
  40. package/python/xactor/man/browser_tabs.md +67 -0
  41. package/python/xactor/man/bulletin.md +88 -0
  42. package/python/xactor/man/capture_page.md +54 -0
  43. package/python/xactor/man/cd.md +64 -0
  44. package/python/xactor/man/chess_move.md +53 -0
  45. package/python/xactor/man/children.md +89 -0
  46. package/python/xactor/man/close_task.md +14 -0
  47. package/python/xactor/man/compact.md +75 -0
  48. package/python/xactor/man/drive.md +140 -0
  49. package/python/xactor/man/drive_space.md +72 -0
  50. package/python/xactor/man/driving.md +168 -0
  51. package/python/xactor/man/edit_file.md +112 -0
  52. package/python/xactor/man/finding.md +194 -0
  53. package/python/xactor/man/fork_agent.md +45 -0
  54. package/python/xactor/man/glob.md +83 -0
  55. package/python/xactor/man/grep.md +115 -0
  56. package/python/xactor/man/harness.md +165 -0
  57. package/python/xactor/man/harness_check.md +70 -0
  58. package/python/xactor/man/harness_format.md +257 -0
  59. package/python/xactor/man/highlight.md +66 -0
  60. package/python/xactor/man/instruct.md +111 -0
  61. package/python/xactor/man/ls.md +63 -0
  62. package/python/xactor/man/man.md +86 -0
  63. package/python/xactor/man/merge_wiz.md +40 -0
  64. package/python/xactor/man/notebook.md +98 -0
  65. package/python/xactor/man/overview.md +89 -0
  66. package/python/xactor/man/permissions.md +137 -0
  67. package/python/xactor/man/phase.md +150 -0
  68. package/python/xactor/man/powershell.md +74 -0
  69. package/python/xactor/man/profiles.md +60 -0
  70. package/python/xactor/man/project_drive.md +49 -0
  71. package/python/xactor/man/read_bulletin.md +89 -0
  72. package/python/xactor/man/read_file.md +90 -0
  73. package/python/xactor/man/relay.md +216 -0
  74. package/python/xactor/man/reply.md +72 -0
  75. package/python/xactor/man/reply_message.md +78 -0
  76. package/python/xactor/man/report_bug.md +118 -0
  77. package/python/xactor/man/request_answer.md +142 -0
  78. package/python/xactor/man/request_merge_decisions.md +121 -0
  79. package/python/xactor/man/request_operator_action.md +152 -0
  80. package/python/xactor/man/request_review.md +122 -0
  81. package/python/xactor/man/request_user_action.md +126 -0
  82. package/python/xactor/man/return_card.md +71 -0
  83. package/python/xactor/man/run.md +99 -0
  84. package/python/xactor/man/sandbox.md +144 -0
  85. package/python/xactor/man/session_fork.md +74 -0
  86. package/python/xactor/man/session_harness.md +109 -0
  87. package/python/xactor/man/session_join.md +102 -0
  88. package/python/xactor/man/session_land.md +73 -0
  89. package/python/xactor/man/session_nudge.md +73 -0
  90. package/python/xactor/man/session_rest.md +73 -0
  91. package/python/xactor/man/session_scope.md +84 -0
  92. package/python/xactor/man/session_terminate.md +58 -0
  93. package/python/xactor/man/session_token.md +78 -0
  94. package/python/xactor/man/sessions.md +93 -0
  95. package/python/xactor/man/set_harness.md +15 -0
  96. package/python/xactor/man/spawn_swarm.md +80 -0
  97. package/python/xactor/man/submit_evidence.md +54 -0
  98. package/python/xactor/man/swarm_brief.md +30 -0
  99. package/python/xactor/man/task_status.md +82 -0
  100. package/python/xactor/man/tasks.md +47 -0
  101. package/python/xactor/man/todo_write.md +104 -0
  102. package/python/xactor/man/update_card.md +70 -0
  103. package/python/xactor/man/use_profile.md +90 -0
  104. package/python/xactor/man/use_widget.md +82 -0
  105. package/python/xactor/man/wait_for_task.md +105 -0
  106. package/python/xactor/man/widget-model.md +284 -0
  107. package/python/xactor/man/widgets.md +59 -0
  108. package/python/xactor/man/write_file.md +76 -0
  109. package/python/xactor/man/writing-a-widget.md +142 -0
  110. package/python/xactor/man/xylon.md +120 -0
  111. package/python/xactor/man/xylon_blame.md +80 -0
  112. package/python/xactor/man/xylon_read.md +131 -0
  113. package/python/xactor/man/xylon_search.md +125 -0
  114. package/python/xactor/manual.py +195 -0
  115. package/python/xactor/parent_watch.py +115 -0
  116. package/python/xactor/plan_mode.py +350 -0
  117. package/python/xactor/sandbox.py +856 -0
  118. package/python/xactor/seals.py +438 -0
  119. package/python/xactor/shell_hints.py +210 -0
  120. package/python/xactor/states/__init__.py +13 -0
  121. package/python/xactor/states/base.py +523 -0
  122. package/python/xactor/states/registry.py +274 -0
  123. package/python/xactor/stdlib_api.py +6345 -0
  124. package/python/xactor/widgets/__init__.py +1 -0
  125. package/python/xactor/widgets/github_tokens.py +314 -0
  126. package/python/xactor/widgets/google_aistudio.py +365 -0
  127. package/settings-schema.json +403 -0
  128. package/viewer/index.css +160 -0
  129. package/viewer/index.js +15 -0
  130. package/wgx.mjs +273753 -0
@@ -0,0 +1,350 @@
1
+ """What Plan mode permits, and the refusal for everything else.
2
+
3
+ Plan mode is read-only, and the enforcement is here rather than at the
4
+ shell-exec boundary for the reason the whole permission model is per-call:
5
+ gating the enclosing exec cannot tell `api.read_file` from `api.write_file`,
6
+ so it asked about both. That made planning an approval loop, which is the
7
+ complaint this module exists to answer.
8
+
9
+ **An allowlist, not a denylist**, for a reason the codebase already
10
+ demonstrates. `_gate_api_methods` iterates `vars(cls)` at class-definition
11
+ time and therefore silently omits the 34 verbs `_graft_browser_l1` attaches
12
+ later — a denylist would inherit exactly that shape of bug, where a new verb
13
+ is dangerous by default and safe only if someone remembers. Inverted, a new
14
+ verb is *broken in Plan mode* until someone opts it in: loud, immediate, and
15
+ caught by the coverage sweep in `test/planModeApiSurface.test.mjs` rather
16
+ than by a user discovering their plan edited something.
17
+
18
+ The cost is real and worth naming: a genuinely read-only api method added
19
+ later is refused in Plan mode until it is listed here. That sweep test fails
20
+ with the unclassified name printed, so the fix is one line and obvious.
21
+
22
+ The membership criterion, stated so future entries are decided rather than
23
+ argued: **a call is Plan-safe when it changes nothing that survives the plan
24
+ being rejected.** Session-scoped UI state dies with the session and is fine.
25
+ Anything durable, anything outside this process, and anything that acts on a
26
+ page, a process, another session or the human's screen is not.
27
+
28
+ Deliberately *not* "does it read secrets". Plan mode permits `read_file`, so
29
+ confinement is the sandbox's job — see `sandbox.guarded_os`.
30
+ """
31
+
32
+ import functools
33
+ import inspect
34
+ from typing import Any, Callable
35
+
36
+ from . import bridge
37
+
38
+ #: Calls Plan mode runs without asking. Everything else is refused.
39
+ #:
40
+ #: Grouped by why, because every group was a judgment call:
41
+ PLAN_SAFE = frozenset({
42
+ # ── Reads. The reason this module exists. ──────────────────────────
43
+ "read_file", "glob", "grep", "ls", "man",
44
+ # Checking a harness file reads it and says what is wrong. A plan that
45
+ # proposes a harness is better for having been checked first.
46
+ "harness_check",
47
+ # Reading what an *already approved* background command is doing. The
48
+ # command was gated when it started; watching it is not a second act.
49
+ "bash_output", "bash_list",
50
+
51
+ # ── Session and tree reads ─────────────────────────────────────────
52
+ "children", "tasks", "session_join", "wait_for_task",
53
+ # Asking a conversation the person attached as a reference. It starts an
54
+ # agent, which nothing else here does — but that agent is handed the
55
+ # asker's own mode, so under a plan it can read and answer and no more,
56
+ # and what an earlier task decided is exactly what a plan should rest on.
57
+ "ask_reference",
58
+ # Saying where your *own* task has got to. Not a read, and admitted
59
+ # anyway: it writes one line of workspace state and nothing in the
60
+ # project, and a planning agent is exactly the one whose progress is
61
+ # worth seeing — it is reading code for minutes with nothing else to
62
+ # show for it. Refusing it would make Plan mode the one mode whose
63
+ # cards never say anything.
64
+ "task_status",
65
+ # A swarm member's progress report, on the same terms: one line into the
66
+ # swarm's stream and nothing in the project. `spawn_swarm` is not here —
67
+ # it starts agents that write, which is what planning is not.
68
+ "swarm_brief",
69
+ # The user's `/chess` game: a move on a board in wgx's memory, and a read
70
+ # of it. Nothing in the project, and the person started the game.
71
+ "chess_move", "chess_state",
72
+ # Clearing your own context. It writes nothing in the project, and a
73
+ # long planning session is as likely as any to need it.
74
+ "compact",
75
+ "xylon_list_trees", "xylon_get_tree", "xylon_get_node", "xylon_read_messages",
76
+ "xylon_search", "xylon_read_turns", "xylon_blame",
77
+ # A drive space's listing, and where a review stands. Both read; neither
78
+ # fetches a file or opens anything.
79
+ "drive_tree", "drive_review_status",
80
+ # The project Drive's documents and what differs in this machine's copy.
81
+ # It brings the copy up to date with what teammates landed, which writes
82
+ # under ~/.widgex and nothing in the project; suggesting is not here.
83
+ "project_drive",
84
+ # `widgets` and `profiles` are absent from PERMISSION_REQUIRED for the
85
+ # reason given there — listing what is installed opens nothing and
86
+ # reveals nothing. The same reasoning admits them here.
87
+ "widgets", "profiles",
88
+ # Reads of a browser someone already attached. Reaching a *new* site
89
+ # still needs `drive` / `use_widget` / `browser_open_tab`, all gated.
90
+ "browser_tabs", "browser_hybrid",
91
+
92
+ # ── Talking ────────────────────────────────────────────────────────
93
+ # Exactly the set `COMMUNICATION_CALLS` (src/providers/tools.ts) admits
94
+ # in Read-only, which is the stricter mode. Plan must not be stricter
95
+ # than Read-only about answering a question.
96
+ #
97
+ # Known escape hatch, accepted knowingly: `relay_send` can ask another
98
+ # agent to do the work. That is pre-existing and deliberate — it is the
99
+ # same hatch Read-only has — not something Plan mode opens.
100
+ "read_bulletin",
101
+ "relay_connect", "relay_accept", "relay_decline",
102
+ "relay_send", "relay_inbox", "relay_close",
103
+ # Asking the human, and asking another model for text. `ask_user` is the
104
+ # single most valuable call a planner has when the requirements are
105
+ # ambiguous; refusing it would make Plan mode worse at planning than any
106
+ # other mode is. `ask_model` is the sanctioned alternative to `agent`
107
+ # for a second opinion — no tools, no subagent, no session.
108
+ "ask_user", "ask_model",
109
+
110
+ # ── Session-scoped UI. Dies with the session. ──────────────────────
111
+ # `todo_write` is how the user watches the plan being built.
112
+ "todo_write", "session_scope",
113
+ # `phase` only reads: where the plan being written will land, and what came before.
114
+ "phase",
115
+
116
+ # ── Durable, and permitted anyway, by explicit decision ────────────
117
+ # Both write something that outlives the plan, so the criterion above
118
+ # argues against them. Allowed because the alternative is worse: an
119
+ # agent that hits a WidgeX defect while planning and cannot report it,
120
+ # or notices follow-up work and has to hold it in prose until the plan
121
+ # is approved.
122
+ "report_bug", "add_card",
123
+
124
+ # ── Argument-dependent. See StdlibAPI._plan_safe_agent. ────────────
125
+ "agent",
126
+
127
+ # ── Grafted browser verbs: the ones that only read the page ────────
128
+ "find", "search", "one", "outline", "snapshot", "digest",
129
+ "get_state", "here", "wait_for", "wait_stable",
130
+
131
+ # ── Grafted: withdrawn stubs that only raise a redirect ────────────
132
+ # `api.query()` and friends do nothing but raise "use api.search(...)".
133
+ # Refusing them would replace a genuinely helpful hint with a mode
134
+ # refusal, for a call that could not have done anything either way.
135
+ "query", "query_one", "hybrid_snapshot",
136
+ })
137
+
138
+
139
+ #: What the Support Sprite may do *beyond* reading: drive the IDE it is
140
+ #: attached to.
141
+ #:
142
+ #: Support mode is Plan mode's reads plus these. The sprite answers by pointing
143
+ #: at things in the user's own window — ringing a control, captioning a step,
144
+ #: clicking through a menu with them watching — so the page verbs are the job,
145
+ #: not an escape from it. What stays refused is everything that acts *off*
146
+ #: screen: files, shell, other sessions, other sites.
147
+ #:
148
+ #: Deliberately absent: `evaluate` (arbitrary script in the workbench, which can
149
+ #: reach far more than the screen shows), `navigate` (moving a webview away from
150
+ #: the IDE's own page breaks it), and every `request_*` handoff.
151
+ SUPPORT_DRIVE = frozenset({
152
+ "click", "double_click", "type_text", "clear_and_type", "hover",
153
+ "scroll_to", "select", "focus", "press", "dom_click", "dom_fill", "drain",
154
+ "screenshot",
155
+ # The guide verbs: the reason the sprite exists.
156
+ "highlight", "instruct", "reply", "await_user",
157
+ # Re-pointing at another part of its own window.
158
+ "attach_ide",
159
+ })
160
+
161
+ #: Everything a support session may call without being refused.
162
+ SUPPORT_SAFE = PLAN_SAFE | SUPPORT_DRIVE
163
+
164
+ #: The most a grant can ever add to support mode, whatever the exec meta says.
165
+ #: Mirrors SUPPORT_GRANT_CALLS in src/ide-widgets/supportSprite/supportGrant.ts.
166
+ #: Three of the four ask permission, and the host checks their arguments.
167
+ SUPPORT_GRANTABLE = frozenset({
168
+ "drive", "request_operator_action", "write_file", "edit_file",
169
+ })
170
+
171
+ #: The permission mode the Support Sprite runs in. See ClaudeSessionService.
172
+ SUPPORT_MODE = "support"
173
+
174
+ #: A read-only copy's mode: a conversation copied to answer one question about
175
+ #: it, which is how an @-mentioned agent answers in a channel. See
176
+ #: ClaudeSessionService.READONLY_MODE.
177
+ COPY_MODE = "readonly"
178
+
179
+ #: Everything a read-only copy may call. Narrower than Plan on purpose: Plan
180
+ #: is somebody planning their own work, and admits asking the user, starting
181
+ #: an agent, filing a card and writing todos. A copy answers somebody else's
182
+ #: question about somebody else's work; it reads, and that is all. The
183
+ #: criterion is stricter than Plan's too: **a call is copy-safe when it
184
+ #: changes nothing at all**, durable or not, here or anywhere.
185
+ #:
186
+ #: The page-reading verbs are here only because `guard` hands Plan-safe
187
+ #: grafted verbs back unwrapped, so a copy could not be refused them anyway;
188
+ #: listing them says so rather than leaving it implicit. They read a page
189
+ #: someone already attached, and a copy attaches nothing.
190
+ COPY_SAFE = frozenset({
191
+ "read_file", "glob", "grep", "ls", "man",
192
+ "tasks", "read_bulletin",
193
+ "xylon_list_trees", "xylon_get_tree", "xylon_get_node", "xylon_read_messages",
194
+ "xylon_search", "xylon_read_turns", "xylon_blame",
195
+ "drive_tree", "drive_review_status",
196
+ "harness_check",
197
+ "find", "search", "one", "outline", "snapshot", "digest",
198
+ "get_state", "here", "wait_for", "wait_stable",
199
+ "query", "query_one", "hybrid_snapshot",
200
+ })
201
+
202
+
203
+ def _message(name: str) -> str:
204
+ """The refusal an agent reads.
205
+
206
+ Written against the two things a model does when a call is refused, both
207
+ of which cost turns and neither of which can work here:
208
+
209
+ 1. It retries, on the theory that the user might answer differently.
210
+ 2. It looks for another spelling — `click` refused, so `dom_click`, then
211
+ `evaluate("...click()")`, then `bash("osascript ...")`. That instinct
212
+ is real enough that `_browser_intent_hint` exists to catch it.
213
+
214
+ So the message says outright that it is the mode and not the user, that
215
+ no other call does this, and what *does* still work — a refusal that only
216
+ says no is a refusal the model argues with.
217
+ """
218
+ return (
219
+ f"Plan mode does not permit api.{name}().\n"
220
+ "\n"
221
+ "It is the mode refusing, not the user — there is nothing to retry, "
222
+ "and no other call does this without the same refusal. What still "
223
+ "works: reads (read_file, glob, grep, ls), the page-reading verbs "
224
+ "(search, outline, one), the xylon tree, relay and the bulletin, "
225
+ "ask_user, ask_model, todo_write, man.\n"
226
+ "\n"
227
+ "Write the plan, then call ExitPlanMode. The user approves it, the "
228
+ "mode changes, and this call runs. If ExitPlanMode answers \"You are "
229
+ "not in plan mode\", WidgeX and the harness disagree about the mode: "
230
+ "call EnterPlanMode and then ExitPlanMode, so both see a fresh approval."
231
+ )
232
+
233
+
234
+ def _support_message(name: str) -> str:
235
+ """The refusal the Support Sprite reads.
236
+
237
+ **Duplicated in ClaudeSessionService.SUPPORT_DENIAL**, which refuses the
238
+ same calls from the other side of the bridge.
239
+ """
240
+ return (
241
+ f"Support mode does not permit api.{name}().\n"
242
+ "\n"
243
+ "You are read-only: you can read, look things up, and drive the IDE on "
244
+ "screen, but not edit files or run commands yourself. It is the mode "
245
+ "refusing, not the user — nothing will make this call succeed. Show the "
246
+ "user how to do it instead: point at the control with api.highlight or "
247
+ "api.instruct, and let them press it."
248
+ )
249
+
250
+
251
+ def _copy_message(name: str) -> str:
252
+ """The refusal a read-only copy reads.
253
+
254
+ **Duplicated in ClaudeSessionService.READONLY_DENIAL**, which refuses the
255
+ same calls from the other side of the bridge.
256
+ """
257
+ return (
258
+ f"A read-only copy does not permit api.{name}().\n"
259
+ "\n"
260
+ "You are a copy of a conversation, made to answer a question about it. "
261
+ "You can read files, search them and read the project, and nothing "
262
+ "else. It is the mode refusing, not the user, and nothing will make "
263
+ "this call succeed. Answer from what you can read."
264
+ )
265
+
266
+
267
+ def refuse_if_planning(name: str, allowed: bool | None = None) -> None:
268
+ """Raise when `name` is not something Plan mode permits. No-op otherwise.
269
+
270
+ `allowed` is the verdict of a `_plan_safe_<name>` sibling, for the calls
271
+ whose answer depends on their arguments. It **overrides** membership in
272
+ both directions, and it has to: `agent` is listed in `PLAN_SAFE` — the
273
+ coverage sweep reads that list to know the call has been classified at all
274
+ — while only one of its shapes is actually safe. Reading membership first
275
+ and returning would have made every `agent(wait=False)` plan-safe, which
276
+ is precisely the escape this module exists to close.
277
+
278
+ The hot path — a loop of `read_file`, which is what planning is — costs one
279
+ contextvar read and one frozenset lookup. The mode is read first because
280
+ support mode has its own list: it refuses `agent`, which Plan admits.
281
+
282
+ **A missing mode means unrestricted, deliberately.** Tests that spawn a
283
+ bare daemon and headless use send no `permissionMode`, and this mirrors
284
+ `_require_permission`'s own "no bridge means nobody to ask, so proceed".
285
+ The *allowlist* fails closed within Plan mode; the *mode* fails open when
286
+ unstated, and what makes that acceptable is that exactly one place stamps
287
+ it — `StdlibShell.exec` — rather than each of the four call sites that
288
+ assemble exec meta.
289
+ """
290
+ meta = bridge.exec_meta.get()
291
+ mode = meta.get("permissionMode")
292
+ # First, because a copy is narrower than Plan in both directions: the
293
+ # Plan-safe verdict (`agent` with wait=True, say) does not admit anything
294
+ # here, and nothing outside COPY_SAFE runs whatever its arguments.
295
+ if mode == COPY_MODE:
296
+ if name in COPY_SAFE:
297
+ return
298
+ raise PermissionError(_copy_message(name))
299
+ if mode != SUPPORT_MODE and (allowed if allowed is not None else (name in PLAN_SAFE)):
300
+ return
301
+ if mode == SUPPORT_MODE:
302
+ # The sprite's own allowlist. `allowed` is a planning verdict and is
303
+ # not consulted: support mode is narrower than Plan about subagents.
304
+ if name in SUPPORT_SAFE and name != "agent":
305
+ return
306
+ # A Walter opened for one job (drive setup) may also make the calls
307
+ # that job needs. Names only, stamped by StdlibShell.exec; which url
308
+ # and which file are checked by the host when the call asks.
309
+ if name in SUPPORT_GRANTABLE and name in (meta.get("supportGrants") or ()):
310
+ return
311
+ raise PermissionError(_support_message(name))
312
+ if mode != "plan":
313
+ return
314
+ raise PermissionError(_message(name))
315
+
316
+
317
+ def guard(name: str, fn: Callable) -> Callable:
318
+ """Wrap a grafted browser verb so Plan mode can refuse it.
319
+
320
+ `_graft_browser_l1` attaches these with `setattr` *after* the class body
321
+ has run, so `_gate_api_methods` never sees them and `_api_method` never
322
+ wraps them. This is the second of the two call sites enforcing PLAN_SAFE.
323
+
324
+ **The sync branch is load-bearing, not tidiness.** Ten of these verbs —
325
+ `click`, `type_text`, `press`, `hover`, `select`, … — are plain `def`.
326
+ They call `bridge.call(...)` eagerly and hand the coroutine to `_enqueue`,
327
+ which does `asyncio.ensure_future`, so *the request goes out on the next
328
+ tick whether or not the agent awaits it*. An `async def` wrapper on one of
329
+ those would return a coroutine nobody awaits: the guard would never run,
330
+ the agent would see no error, and the only trace would be a RuntimeWarning
331
+ on a stderr nobody reads. The refusal has to happen at the call site.
332
+
333
+ Allowlisted verbs are returned unwrapped rather than wrapped-and-allowed,
334
+ so `outline()` and `search()` are byte-identical to before this existed.
335
+ """
336
+ if name in PLAN_SAFE:
337
+ return fn
338
+
339
+ if inspect.iscoroutinefunction(fn):
340
+ @functools.wraps(fn)
341
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
342
+ refuse_if_planning(name)
343
+ return await fn(*args, **kwargs)
344
+ else:
345
+ @functools.wraps(fn)
346
+ def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
347
+ refuse_if_planning(name)
348
+ return fn(*args, **kwargs)
349
+
350
+ return wrapper