@widgex/wgx 0.0.0-stage → 0.4.2

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 (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. package/wgx.mjs +290659 -0
@@ -0,0 +1,784 @@
1
+ """Bidirectional JSON-RPC bridge over stdin/stdout.
2
+
3
+ Protocol writes use ``_original_stdout`` so that exec's stdout capture
4
+ never corrupts the wire protocol.
5
+
6
+ Requires Python 3.10+: the ``X | None`` annotations below are evaluated at
7
+ definition time, so an older interpreter fails at import with a TypeError
8
+ before this module can report anything. Do not soften them back to
9
+ ``Optional[...]`` — the host picks a qualifying interpreter (see
10
+ ``resolvePythonBin``) rather than making this tree run on an EOL Python.
11
+ """
12
+
13
+ import ast
14
+ import asyncio
15
+ import builtins
16
+ import contextvars
17
+ import inspect
18
+ import json
19
+ import sys
20
+ import traceback
21
+ from io import StringIO
22
+ from typing import Any
23
+
24
+ from . import sandbox
25
+
26
+ # Capture the real stdout before anything can redirect it.
27
+ _original_stdout = sys.stdout
28
+ _original_stderr = sys.stderr
29
+
30
+ # req_id → (future, method). The method rides along purely so a failure can say
31
+ # which call failed: the reader below sees only an id, and the traceback an
32
+ # agent gets back is raised inside the reader, not at the await, so without this
33
+ # the name of the operation is nowhere in the error.
34
+ _pending: dict[int, tuple[asyncio.Future, str]] = {}
35
+ _next_id = 1
36
+ _exec_scope: dict[str, Any] = {}
37
+
38
+ # Per-exec metadata from TypeScript (e.g. toolUseId). Each exec runs in its
39
+ # own task, so the contextvar isolates concurrent execs from each other.
40
+ exec_meta: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar("exec_meta", default={})
41
+
42
+ # exec_id → the task running it, so a cancel arriving from TypeScript has
43
+ # something to name. Without this the task from ensure_future is dropped on the
44
+ # floor and a running exec cannot be reached by anything short of killing the
45
+ # process — which serves the whole session and all its delegates.
46
+ _running_execs: dict[str, asyncio.Task] = {}
47
+
48
+ # Max bytes in a single protocol line. asyncio.StreamReader defaults to 64 KiB,
49
+ # which a screenshot clears by an order of magnitude: the capture cap is 4 MB
50
+ # (MAX_SCREENSHOT_BYTES in WidgeXactorWidget.ts) and base64 adds a third, so a
51
+ # reply can reach ~5.3 MB. The default silently turned every screenshot into a
52
+ # hung session. Sized well above that so the limit is a backstop against a
53
+ # runaway writer, not something normal traffic can reach.
54
+ _READ_LIMIT = 32 * 1024 * 1024
55
+
56
+
57
+ def _dbg(msg: str) -> None:
58
+ """Write debug message to stderr (never contaminates JSON-RPC stdout)."""
59
+ _original_stderr.write(f"[xactor:bridge] {msg}\n")
60
+ _original_stderr.flush()
61
+
62
+
63
+ def _emit(msg: dict) -> None:
64
+ """Write a JSON message + newline to the protocol stream and flush."""
65
+ _original_stdout.write(json.dumps(msg) + "\n")
66
+ _original_stdout.flush()
67
+
68
+
69
+ def notify(method: str, **params: Any) -> None:
70
+ """Send a fire-and-forget message. No ``id``, so no response is expected.
71
+
72
+ Used for progress reporting, where blocking the API call on a UI round
73
+ trip would double every call's latency for no benefit.
74
+ """
75
+ _emit({"method": method, "params": params})
76
+
77
+
78
+ def _fail_all_pending(reason: str) -> None:
79
+ """Reject every in-flight call. Used when a read fault loses a response.
80
+
81
+ Pending futures are the only record that anyone is waiting; if the reader
82
+ cannot say which call a lost frame belonged to, the choice is between
83
+ failing all of them and hanging all of them.
84
+ """
85
+ if not _pending:
86
+ return
87
+ _dbg(f"failing {len(_pending)} pending call(s): {reason}")
88
+ for req_id, (future, method) in list(_pending.items()):
89
+ _pending.pop(req_id, None)
90
+ if not future.done():
91
+ future.set_exception(RuntimeError(f"{method}: {reason}"))
92
+
93
+
94
+ def _read_loop_stopped(task: "asyncio.Task") -> None:
95
+ """Done-callback for the read loop. Nothing reads stdin after this point.
96
+
97
+ A normal stop is EOF — TypeScript closed the pipe, the process is on its way
98
+ out, and there is nothing to say. Any other ending is a fault that would
99
+ otherwise show up only as an unretrieved-exception warning on stderr, long
100
+ after every call in flight had already hung.
101
+
102
+ Either way this process is finished, and says so by leaving. It used to
103
+ assume it was "on its way out" and go on running ``loop.run_forever()``
104
+ instead — true only when the host had sent SIGTERM, which is the one case
105
+ where the assumption did not matter. When the host died without it, the
106
+ daemon was reparented to launchd and stayed there with nothing to read and
107
+ no one to answer. They accumulated: one was found alive after seven days.
108
+ """
109
+ if task.cancelled():
110
+ return
111
+ exc = task.exception()
112
+ if exc is None:
113
+ _dbg("read loop stopped at EOF")
114
+ else:
115
+ _dbg(f"read loop died: {exc!r}\n{''.join(traceback.format_exception(exc))}")
116
+ _fail_all_pending("the bridge read loop stopped; no response can arrive")
117
+
118
+ # Stopping the loop returns control to `main`, which returns — so the
119
+ # interpreter shuts down normally, running whatever cleanup is registered,
120
+ # rather than being torn out from under itself by os._exit.
121
+ try:
122
+ asyncio.get_running_loop().stop()
123
+ except RuntimeError:
124
+ # No loop to stop, which means nothing is keeping us alive anyway.
125
+ pass
126
+
127
+
128
+ async def call(method: str, **params: Any) -> Any:
129
+ """Send a JSON-RPC request and await the response."""
130
+ global _next_id
131
+ req_id = _next_id
132
+ _next_id += 1
133
+
134
+ future: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
135
+ _pending[req_id] = (future, method)
136
+
137
+ _dbg(f"call → id={req_id} method={method} params_keys={list(params.keys())}")
138
+ _emit({"id": req_id, "method": method, "params": params})
139
+ result = await future
140
+ _dbg(f"call ← id={req_id} method={method} result_type={type(result).__name__}")
141
+ return result
142
+
143
+
144
+ # Builtins withheld from agent code, mapped to what to use instead. The audit
145
+ # hook denies the underlying operations too; this layer exists to fail early
146
+ # with a message that names the replacement.
147
+ #
148
+ # `__import__` is not here: it is replaced rather than withheld, by
149
+ # `sandbox.sandboxed_import`, which lets pure standard-library modules through.
150
+ _DENIED_BUILTINS = {
151
+ "open": "use api.read_file() / api.write_file() / api.edit_file()",
152
+ "eval": "eval() is unavailable",
153
+ "exec": "exec() is unavailable",
154
+ "compile": "compile() is unavailable",
155
+ "input": "the shell is non-interactive",
156
+ "breakpoint": "the shell is non-interactive",
157
+ }
158
+
159
+
160
+ def _restricted_builtins() -> dict[str, Any]:
161
+ """A builtins mapping with the direct-OS escape hatches removed."""
162
+ safe = {k: v for k, v in vars(builtins).items() if k not in _DENIED_BUILTINS}
163
+
164
+ def _deny(name: str, hint: str) -> Any:
165
+ def denier(*_args: Any, **_kwargs: Any) -> Any:
166
+ raise PermissionError(f"Sandbox: {name}() is not allowed from shell code — {hint}.")
167
+ denier.__name__ = name
168
+ return denier
169
+
170
+ for name, hint in _DENIED_BUILTINS.items():
171
+ safe[name] = _deny(name, hint)
172
+ safe["__import__"] = sandbox.sandboxed_import
173
+ return safe
174
+
175
+
176
+ def set_exec_scope(scope: dict[str, Any], restrict_builtins: bool = False) -> None:
177
+ """Set the namespace that exec commands run in.
178
+
179
+ Args:
180
+ scope: Names made available to exec'd code.
181
+ restrict_builtins: Withhold the direct-OS builtins (stdlib backend).
182
+ """
183
+ global _exec_scope
184
+ _exec_scope = dict(scope)
185
+ if restrict_builtins:
186
+ _exec_scope["__builtins__"] = _restricted_builtins()
187
+
188
+
189
+ def compile_exec(code: str):
190
+ """Compile agent code into an ``async def __exec__()`` without rewriting it.
191
+
192
+ The agent's code needs a function around it — `await` and `return` are both
193
+ legal in what it sends, and neither is legal at module level. The obvious
194
+ way to get one is to indent every line and prepend a header, and that is
195
+ what this did for a long time.
196
+
197
+ Indenting is a *textual* operation and a triple-quoted string is text, so
198
+ it edits the agent's data as readily as its code:
199
+
200
+ old = \"\"\"foo becomes 'foo\\n bar'
201
+ bar\"\"\"
202
+
203
+ Four spaces, on every line after the first, inside a value the agent
204
+ believes it copied verbatim. It never announces itself. What it produces is
205
+ `api.edit_file` reporting `old_string not found` for a block the agent just
206
+ read out of the file — four separate bug reports over eight days, each
207
+ filed against `edit_file` — and a bash heredoc whose terminator no longer
208
+ sits at column zero, so the shell swallows the rest of the script. Every
209
+ one of those looked like a bug in the call being made rather than in the
210
+ transport under it, which is why it survived so long.
211
+
212
+ Wrapping on the AST cannot have that failure: `ast.parse` has already
213
+ turned the literal into a value by the time the body is moved, so nothing
214
+ downstream is in a position to reinterpret its whitespace.
215
+
216
+ The `async def` is parsed from a template rather than built as an
217
+ `ast.AsyncFunctionDef` by hand. The node's field set is not stable across
218
+ the versions this runs on — 3.12 added `type_params` — and a constructor
219
+ call that names the fields would have to know which interpreter it is on.
220
+
221
+ Line numbers come out *better* than before, incidentally: the body keeps
222
+ its original `lineno`, where the old header shifted every traceback down by
223
+ one.
224
+
225
+ Being on the AST is also what lets three traps be closed here rather than
226
+ explained afterwards — see `_ExecRewriter`. None of them changes what a
227
+ correct program does.
228
+ """
229
+ body = ast.parse(code).body or [ast.Pass()]
230
+ module = ast.parse(_EXEC_TEMPLATE)
231
+ # Before the rewrite, which replaces the call nodes the lint looks for.
232
+ lint = _shell_literal_lint(code, body)
233
+ if lint:
234
+ module.body.insert(0, ast.parse(f"__xactor_lint__ = {lint!r}").body[0])
235
+ exec_def = module.body[-1]
236
+ exec_def.body = body
237
+ _ExecRewriter(_top_level_bindings(body)).visit(exec_def)
238
+ ast.fix_missing_locations(module)
239
+ return compile(module, "<xactor-exec>", "exec")
240
+
241
+
242
+ _SHELL_CALLS = {"bash": ("command", "input"), "run": ("argv", "input"), "powershell": ("script",)}
243
+
244
+
245
+ def _shell_literal_lint(code: str, body: list[ast.stmt]) -> dict[int, str]:
246
+ """Line → note, for shell calls handed a non-raw literal Python un-escaped.
247
+
248
+ By the time `api.bash` runs, the backslash is already gone: `'\\''` in a
249
+ normal string reaches bash as `'''`. Only the source still shows what was
250
+ written, so the comparison happens here, and the note waits in the module
251
+ for `caller_lint` to fetch if the command fails. It is never shown for a
252
+ command that worked: `"a\\\\|b"` for grep is a correct use of exactly this.
253
+
254
+ Follows a plain name one step back to its assignment, since `cmd = "…"`
255
+ then `api.bash(cmd)` is the commoner shape for anything long.
256
+ """
257
+ from . import shell_hints
258
+
259
+ assigned: dict[str, ast.AST] = {}
260
+ for node in ast.walk(ast.Module(body=body, type_ignores=[])):
261
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
262
+ assigned[node.targets[0].id] = node.value
263
+
264
+ def literals(node: ast.AST, depth: int = 0):
265
+ if isinstance(node, ast.Name) and depth == 0 and node.id in assigned:
266
+ yield from literals(assigned[node.id], 1)
267
+ return
268
+ if isinstance(node, ast.JoinedStr) or (isinstance(node, ast.Constant) and isinstance(node.value, str)):
269
+ yield node
270
+ return
271
+ for child in ast.iter_child_nodes(node):
272
+ yield from literals(child, depth)
273
+
274
+ lint: dict[int, str] = {}
275
+ for node in ast.walk(ast.Module(body=body, type_ignores=[])):
276
+ if not (_ExecRewriter._is_api_call(node) and node.func.attr in _SHELL_CALLS):
277
+ continue
278
+ call = node.func.attr
279
+ params = _SHELL_CALLS[call]
280
+ args = list(node.args[:len(params)]) + [k.value for k in node.keywords if k.arg in params]
281
+ for arg in args:
282
+ for lit in literals(arg):
283
+ segment = ast.get_source_segment(code, lit)
284
+ if not segment:
285
+ continue
286
+ value = lit.value if isinstance(lit, ast.Constant) else None
287
+ note = shell_hints.literal_note(segment, value, lit.lineno, call)
288
+ if note:
289
+ for line in range(node.lineno, (node.end_lineno or node.lineno) + 1):
290
+ lint.setdefault(line, note)
291
+ break
292
+ return lint
293
+
294
+
295
+ # Notes the shell attaches to this exec's output, whatever the script printed.
296
+ # A hint inside a result dict reaches nobody who printed only `r["stdout"]` —
297
+ # which is most scripts — so they go where the agent always looks.
298
+ _exec_notes: contextvars.ContextVar[list[str] | None] = contextvars.ContextVar("exec_notes", default=None)
299
+
300
+
301
+ # When the host will cut this exec off, on the loop's clock. None when the host
302
+ # did not say, which is every caller but the real one.
303
+ _exec_deadline: contextvars.ContextVar[float | None] = contextvars.ContextVar("exec_deadline", default=None)
304
+
305
+ # The longest an exec may be given — `timeout_seconds` is capped here on the
306
+ # host side (see `terminalTimeoutMs`). Any per-call timeout above it cannot
307
+ # have been meant as seconds.
308
+ MAX_EXEC_SECONDS = 3900
309
+
310
+
311
+ def seconds_left() -> float | None:
312
+ """Seconds until the host stops the running exec, or None if unknown."""
313
+ deadline = _exec_deadline.get()
314
+ if deadline is None:
315
+ return None
316
+ return max(0.0, deadline - asyncio.get_running_loop().time())
317
+
318
+
319
+ def add_note(text: str) -> None:
320
+ """Attach one line to the running exec's output. Repeats are dropped."""
321
+ notes = _exec_notes.get()
322
+ if notes is not None and text not in notes:
323
+ notes.append(text)
324
+
325
+
326
+ def caller_lint() -> str | None:
327
+ """The literal-lint note for the agent line that is running now, if any."""
328
+ frame = sys._getframe(1)
329
+ while frame is not None:
330
+ if frame.f_code.co_filename == "<xactor-exec>":
331
+ table = frame.f_globals.get("__xactor_lint__") or {}
332
+ return table.get(frame.f_lineno)
333
+ frame = frame.f_back
334
+ return None
335
+
336
+
337
+ # The module every exec is compiled into. The helpers live *in* it rather than
338
+ # in the scope the caller builds, so any scope that runs the code has them —
339
+ # `_handle_exec`'s and a test's alike — and an exec's list of un-awaited calls
340
+ # is per-exec by construction, because the scope dict is.
341
+ #
342
+ # `__xactor_await__` is a plain function on purpose: it hands back the very
343
+ # awaitable it was given, so an exception from an api call carries no extra
344
+ # frame between the agent's line and the call that raised.
345
+ _EXEC_TEMPLATE = '''\
346
+ __xactor_unawaited__ = []
347
+ class __XactorReady__:
348
+ __slots__ = ("value",)
349
+ def __init__(self, value):
350
+ self.value = value
351
+ def __await__(self):
352
+ return self.value
353
+ yield
354
+ def __xactor_await__(value):
355
+ if hasattr(type(value), "__await__"):
356
+ return value
357
+ code = getattr(value, "gi_code", None)
358
+ if code is not None and code.co_flags & 0x100:
359
+ return value # a generator-based coroutine (@types.coroutine)
360
+ return __XactorReady__(value)
361
+ def __xactor_track__(value, name, line):
362
+ if type(value).__name__ == "coroutine":
363
+ __xactor_unawaited__.append((value, name, line))
364
+ return value
365
+ async def __exec__():
366
+ pass
367
+ '''
368
+
369
+
370
+ def _top_level_bindings(body: list[ast.stmt]) -> set[str]:
371
+ """Names the exec body binds in its own scope — i.e. `__exec__`'s locals.
372
+
373
+ Descends through blocks (`if`, `for`, `try`, `with`) but not into nested
374
+ functions, lambdas, classes or comprehensions, whose bindings are their own.
375
+ Names the body itself declares `global` are excluded: those really do live
376
+ in the scope dict.
377
+ """
378
+ bound: set[str] = set()
379
+ declared_global: set[str] = set()
380
+
381
+ def walk(node: ast.AST) -> None:
382
+ for child in ast.iter_child_nodes(node):
383
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
384
+ bound.add(child.name)
385
+ continue
386
+ if isinstance(child, (ast.Lambda, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
387
+ continue
388
+ if isinstance(child, ast.Global):
389
+ declared_global.update(child.names)
390
+ elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store):
391
+ bound.add(child.id)
392
+ elif isinstance(child, ast.alias):
393
+ bound.add((child.asname or child.name).split(".")[0])
394
+ elif isinstance(child, ast.ExceptHandler) and child.name:
395
+ bound.add(child.name)
396
+ walk(child)
397
+
398
+ walk(ast.Module(body=body, type_ignores=[]))
399
+ return bound - declared_global
400
+
401
+
402
+ class _ExecRewriter(ast.NodeTransformer):
403
+ """Three traps the `async def` wrapper set, closed where they were set.
404
+
405
+ **An un-awaited api call ran nothing and said nothing.** `api.bash`,
406
+ `api.man`, `api.write_file` and `api.todo_write` are coroutines; the page
407
+ verbs are not. A bare `api.todo_write([...])` was dropped without a word,
408
+ `print(api.man(x))` printed `<coroutine object …>`, and `api.bash(c)["stdout"]`
409
+ raised "'coroutine' object is not subscriptable" — about forty times, and
410
+ three of three rebase children made it on their first call. Each api call
411
+ that is not the operand of an `await` is now passed through
412
+ `__xactor_track__`, which records it if it is a coroutine; `_handle_exec`
413
+ fails the exec, by name and line, for any that never ran.
414
+
415
+ **`await` on a page verb raised.** `await api.find(...)` gave "object
416
+ FindSpec can't be used in 'await' expression". The tool description's rule
417
+ is "use await for all calls", so the rule is now true: `await x` on
418
+ something that is not awaitable is `x`.
419
+
420
+ **`global` in a nested function could not see the exec's own names.** Those
421
+ names are locals of `__exec__`, not globals, so `global inp` found nothing
422
+ and the NameError that followed was explained as the per-exec scope — the
423
+ wrong cause. In a nested function, `global n` for a name the body binds is
424
+ rewritten to `nonlocal n`, which is what the agent meant.
425
+ """
426
+
427
+ def __init__(self, top_level: set[str]) -> None:
428
+ self._top_level = top_level
429
+ self._scopes: list[set[str]] = [] # locals of each enclosing function, __exec__ first
430
+
431
+ # ── await ──
432
+ def visit_Await(self, node: ast.Await) -> ast.AST:
433
+ operand = node.value
434
+ # The operand itself is awaited, so it is not tracked — only what is
435
+ # inside it is visited.
436
+ if self._is_api_call(operand):
437
+ self.generic_visit(operand)
438
+ else:
439
+ node.value = self.visit(operand)
440
+ node.value = ast.copy_location(
441
+ ast.Call(func=ast.Name("__xactor_await__", ast.Load()), args=[node.value], keywords=[]),
442
+ node.value,
443
+ )
444
+ return node
445
+
446
+ # ── bare api calls ──
447
+ def visit_Call(self, node: ast.Call) -> ast.AST:
448
+ self.generic_visit(node)
449
+ if not self._is_api_call(node):
450
+ return node
451
+ return ast.copy_location(
452
+ ast.Call(
453
+ func=ast.Name("__xactor_track__", ast.Load()),
454
+ args=[node, ast.Constant(node.func.attr), ast.Constant(node.lineno)],
455
+ keywords=[],
456
+ ),
457
+ node,
458
+ )
459
+
460
+ @staticmethod
461
+ def _is_api_call(node: ast.AST) -> bool:
462
+ return (
463
+ isinstance(node, ast.Call)
464
+ and isinstance(node.func, ast.Attribute)
465
+ and isinstance(node.func.value, ast.Name)
466
+ and node.func.value.id == "api"
467
+ )
468
+
469
+ # ── global in nested functions ──
470
+ def _visit_function(self, node: ast.AST) -> ast.AST:
471
+ # Each function's own locals, so `visit_Global` can tell whether a
472
+ # function between it and the exec binds the same name — in which case
473
+ # `nonlocal` would reach *that* one, and `global` was never about the
474
+ # exec's name at all.
475
+ own = _top_level_bindings(node.body) | {
476
+ a.arg for a in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)
477
+ } | {a.arg for a in (node.args.vararg, node.args.kwarg) if a is not None}
478
+ self._scopes.append(own)
479
+ try:
480
+ self.generic_visit(node)
481
+ finally:
482
+ self._scopes.pop()
483
+ return node
484
+
485
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
486
+ return self._visit_function(node)
487
+
488
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
489
+ # The outermost one is `__exec__` itself; its own `global` statements
490
+ # mean what they say.
491
+ return self._visit_function(node)
492
+
493
+ def visit_Global(self, node: ast.Global) -> ast.AST | list[ast.stmt]:
494
+ if len(self._scopes) < 2:
495
+ return node
496
+ # Functions strictly between `__exec__` (first) and this one (last).
497
+ between = set().union(*self._scopes[1:-1]) if len(self._scopes) > 2 else set()
498
+ local = [n for n in node.names if n in self._top_level and n not in between]
499
+ if not local:
500
+ return node
501
+ rest = [n for n in node.names if n not in local]
502
+ out: list[ast.stmt] = [ast.copy_location(ast.Nonlocal(names=local), node)]
503
+ if rest:
504
+ out.append(ast.copy_location(ast.Global(names=rest), node))
505
+ return out
506
+
507
+
508
+ async def unawaited_report(scope: dict[str, Any]) -> str:
509
+ """Name every api call in this exec that was created and never ran.
510
+
511
+ A coroutine some task holds is not reported: it was handed to
512
+ `create_task` and will run. Nor is one a task will reach in a moment —
513
+ `create_task(go(api.bash(x)))` holds `go`, not the bash call, and `go`
514
+ has not started yet when the exec returns — so the loop is given two turns
515
+ first, which is enough for such a task to reach its `await`.
516
+
517
+ Nothing is closed. Closing would silence Python's own "never awaited"
518
+ warning, but a report that is wrong — something will await it after all —
519
+ would then break the task that does, rather than merely being wrong.
520
+ """
521
+ pending = scope.get("__xactor_unawaited__") or []
522
+ if not pending:
523
+ return ""
524
+ for _ in range(2):
525
+ await asyncio.sleep(0)
526
+ try:
527
+ held = {id(t.get_coro()) for t in asyncio.all_tasks()}
528
+ except RuntimeError:
529
+ held = set()
530
+ lines = []
531
+ for coro, name, line in pending:
532
+ if id(coro) in held or inspect.getcoroutinestate(coro) != inspect.CORO_CREATED:
533
+ continue
534
+ lines.append(f"api.{name}(...) on line {line} was never awaited, so it did not run")
535
+ if not lines:
536
+ return ""
537
+ return (
538
+ "\n".join(lines)
539
+ + ".\nWrite `await api.<name>(...)`. Every api call that touches files, "
540
+ "commands, sessions or the IDE is async; awaiting one that is not is "
541
+ "harmless, so when in doubt, await.\n"
542
+ )
543
+
544
+
545
+ def hermetic_hint(err: BaseException, trace: str) -> str:
546
+ """Explain a NameError that is really the scope boundary between execs.
547
+
548
+ Every exec gets a fresh `dict(_exec_scope)` and the agent's code is wrapped
549
+ in an `async def`, so a name it bound last call is gone twice over: the
550
+ assignment was a function local that never reached the scope dict, and the
551
+ dict itself was rebuilt. `globals()["x"] = ...` does not survive either,
552
+ which is the part that costs people a second round trip after the first one
553
+ taught them the wrong lesson.
554
+
555
+ That isolation is deliberate and should stay. A delegate runs its execs
556
+ through its *parent's* shell, so a scope that persisted would let a subagent
557
+ read and overwrite the variables of the session that called it.
558
+
559
+ What was not deliberate is that it is invisible. A bare `NameError: name 'r'
560
+ is not defined` reads as a typo, so the next thing anybody does is send the
561
+ same code again. Observed four times in one session, including once by an
562
+ agent that then reasoned its way to `globals()` — a fix that cannot work,
563
+ because the traceback shows the wrapper but not the rebuilt dict.
564
+
565
+ Every NameError gets it, and a narrower guard was tried first. Checking for
566
+ `<xactor-exec>` in the traceback looks like it would spare an unrelated
567
+ NameError raised inside `api` internals — and it discriminates nothing,
568
+ because the agent's frame is the entry point and is therefore in the chain
569
+ of every traceback it can see. A condition that is always true is worse
570
+ than none: it reads as a safeguard.
571
+ """
572
+ del trace # kept in the signature; see above for why it cannot help
573
+ if not isinstance(err, NameError):
574
+ return ""
575
+ return (
576
+ "\n"
577
+ "Each exec runs in its own scope. Nothing you assign survives to the next "
578
+ "call — not a plain name, and not globals()[...] either, because the scope "
579
+ "is rebuilt every time. It is deliberate: a subagent shares its parent's "
580
+ "shell, and persisting would let the two read each other's variables.\n"
581
+ "So make the call self-contained — do the whole chain in one exec — or put "
582
+ "what you need on disk and read it back.\n"
583
+ )
584
+
585
+
586
+ def escape_hint(err: BaseException) -> str:
587
+ """Explain the SyntaxError a Windows path in a normal string produces.
588
+
589
+ `"C:\\Users\\x"` does not compile: `\\U` begins an eight-digit unicode
590
+ escape, and the error says so in codec vocabulary — "truncated
591
+ \\UXXXXXXXX escape" — that names neither the path nor the fix. On Windows,
592
+ where every home directory starts `C:\\Users`, it is among the first
593
+ things an agent writes.
594
+ """
595
+ if not isinstance(err, SyntaxError) or "unicodeescape" not in str(err):
596
+ return ""
597
+ return (
598
+ "\nA backslash in a normal string starts an escape, and `\\U`, `\\N` and "
599
+ "`\\x` must be followed by a code — so a Windows path like \"C:\\Users\" "
600
+ "does not compile. Write r\"C:\\Users\" or \"C:/Users\"; every api call "
601
+ "accepts forward slashes.\n"
602
+ )
603
+
604
+
605
+ async def _handle_exec(
606
+ code: str,
607
+ exec_id: str,
608
+ meta: dict[str, Any] | None = None,
609
+ timeout_ms: float | None = None,
610
+ ) -> None:
611
+ """Execute Python code sent from TypeScript, capturing stdout."""
612
+ exec_meta.set(meta or {})
613
+ _exec_deadline.set(
614
+ asyncio.get_running_loop().time() + timeout_ms / 1000
615
+ if isinstance(timeout_ms, (int, float)) and timeout_ms > 0 else None
616
+ )
617
+ notes: list[str] = []
618
+ _exec_notes.set(notes)
619
+
620
+ captured = StringIO()
621
+ old_stdout = sys.stdout
622
+ sys.stdout = captured
623
+
624
+ error_text: str | None = None
625
+ result_value: Any = None
626
+ cancelled = False
627
+ local_scope: dict[str, Any] = {}
628
+
629
+ try:
630
+ # Wrapped in an async def so that await works inside exec'd code — see
631
+ # compile_exec for why that wrapping happens on the AST and not on the
632
+ # source text.
633
+ local_scope = dict(_exec_scope)
634
+ exec(compile_exec(code), local_scope)
635
+ # Only the agent's own frames run under the strict scope — compiling
636
+ # the wrapper above and formatting the traceback below must not.
637
+ with sandbox.user_code_scope():
638
+ result_value = await local_scope["__exec__"]()
639
+ except asyncio.CancelledError:
640
+ # The user stopped this call. Caught ahead of `except Exception` and not
641
+ # re-raised, both deliberately: CancelledError derives from
642
+ # BaseException, so letting it past this frame would kill the task
643
+ # without ever emitting a response — and TypeScript would wait on a
644
+ # promise nobody is going to resolve. Swallowing it here means the task
645
+ # finishes normally, having reported what the agent got done first.
646
+ cancelled = True
647
+ except Exception as err:
648
+ error_text = traceback.format_exc()
649
+ # A scope-boundary NameError reads as a typo, so say what it really is.
650
+ error_text += hermetic_hint(err, error_text)
651
+ error_text += escape_hint(err)
652
+ finally:
653
+ sys.stdout = old_stdout
654
+ _running_execs.pop(exec_id, None)
655
+
656
+ # An api call that was never awaited did not run, whatever else happened.
657
+ # After a crash it is usually the cause ("'coroutine' object is not
658
+ # subscriptable"); after a clean finish it is a silent no-op — a todo list
659
+ # never written, a file never saved — and so it is an error either way.
660
+ if not cancelled:
661
+ dropped = await unawaited_report(local_scope)
662
+ if dropped:
663
+ error_text = (error_text + "\n" if error_text else "") + dropped
664
+
665
+ output = captured.getvalue()
666
+ if notes:
667
+ if output and not output.endswith("\n"):
668
+ output += "\n"
669
+ output += "".join(f"[shell] {n}\n" for n in notes)
670
+
671
+ response: dict[str, Any] = {"exec_id": exec_id, "output": output}
672
+ if cancelled:
673
+ response["cancelled"] = True
674
+ elif error_text is not None:
675
+ response["error"] = error_text
676
+ else:
677
+ # Serialize the result if it's JSON-friendly, else str()
678
+ try:
679
+ json.dumps(result_value)
680
+ response["result"] = result_value
681
+ except (TypeError, ValueError):
682
+ response["result"] = str(result_value)
683
+
684
+ _emit(response)
685
+
686
+
687
+ async def _read_loop() -> None:
688
+ """Background task: read stdin and dispatch messages."""
689
+ loop = asyncio.get_event_loop()
690
+
691
+ if sys.platform == "win32":
692
+ import concurrent.futures
693
+
694
+ _stdin_executor = concurrent.futures.ThreadPoolExecutor(
695
+ max_workers=1, thread_name_prefix="stdin"
696
+ )
697
+
698
+ def _read_line() -> bytes:
699
+ return sys.stdin.buffer.readline()
700
+
701
+ async def _readline() -> bytes:
702
+ return await loop.run_in_executor(_stdin_executor, _read_line)
703
+ else:
704
+ reader = asyncio.StreamReader(limit=_READ_LIMIT)
705
+ protocol = asyncio.StreamReaderProtocol(reader)
706
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
707
+
708
+ async def _readline() -> bytes:
709
+ return await reader.readline()
710
+
711
+ while True:
712
+ try:
713
+ raw = await _readline()
714
+ except ValueError as exc:
715
+ # A frame longer than the reader's limit. StreamReader raises rather
716
+ # than truncating, and this await sits outside the json try below —
717
+ # so before this except existed the exception ended the read loop
718
+ # outright, and with it every future the loop was going to resolve.
719
+ # The call that overflowed hung forever, and so did every call after
720
+ # it, because nothing was reading stdin any more.
721
+ #
722
+ # `continue` is the drain: readline() has already cleared what it
723
+ # buffered, so the next one picks up mid-line and raises again until
724
+ # it finally reaches the newline. The tail it returns then fails
725
+ # json.loads below and is skipped like any other malformed line.
726
+ #
727
+ # Failing every pending call is blunt — the id was inside the frame
728
+ # we could not parse, so there is no way to know which one overflowed.
729
+ # A caller told its call died is strictly better off than one waiting
730
+ # on a future nothing will ever resolve.
731
+ _dbg(f"read loop: oversized frame discarded: {exc}")
732
+ _fail_all_pending(
733
+ "a bridge response exceeded the read limit and was dropped"
734
+ )
735
+ continue
736
+ if not raw:
737
+ break # EOF — TypeScript killed the process
738
+
739
+ line = raw.decode("utf-8", errors="replace").strip()
740
+ if not line:
741
+ continue
742
+
743
+ try:
744
+ msg = json.loads(line)
745
+ except json.JSONDecodeError:
746
+ continue
747
+
748
+ # API response (has id + result/error)
749
+ if "id" in msg and ("result" in msg or "error" in msg):
750
+ req_id = msg["id"]
751
+ future, method = _pending.pop(req_id, (None, "?"))
752
+ if future and not future.done():
753
+ if "error" in msg:
754
+ _dbg(f"response id={req_id} ERROR: {msg['error']}")
755
+ # Prefixed with the method. Still a RuntimeError, because
756
+ # callers and tests catch that; the message is the only part
757
+ # that changes.
758
+ future.set_exception(RuntimeError(f"{method}: {msg['error']}"))
759
+ else:
760
+ _dbg(f"response id={req_id} OK (result_type={type(msg['result']).__name__})")
761
+ future.set_result(msg["result"])
762
+ else:
763
+ _dbg(f"response id={req_id} ORPHAN (no pending future)")
764
+ continue
765
+
766
+ # Exec command from TypeScript
767
+ if "exec" in msg and "exec_id" in msg:
768
+ _dbg(f"exec command received: exec_id={msg['exec_id']}, code_len={len(msg['exec'])}")
769
+ exec_id = msg["exec_id"]
770
+ _running_execs[exec_id] = asyncio.ensure_future(
771
+ _handle_exec(msg["exec"], exec_id, msg.get("meta"), msg.get("timeout_ms"))
772
+ )
773
+ continue
774
+
775
+ # Cancel an exec still running. An unknown or already-finished id is a
776
+ # no-op rather than an error: a cancel racing a result is normal, and
777
+ # there is nothing useful to say about losing that race.
778
+ if "cancel_exec_id" in msg:
779
+ exec_id = msg["cancel_exec_id"]
780
+ task = _running_execs.get(exec_id)
781
+ _dbg(f"cancel received: exec_id={exec_id}, running={task is not None}")
782
+ if task is not None and not task.done():
783
+ task.cancel()
784
+ continue