@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.
- package/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +290659 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# api.run(argv, input=None, cwd=None, env=None, timeout=120, background=False) -> dict
|
|
2
|
+
<!-- group: Coding -->
|
|
3
|
+
<!-- also: subprocess, exec, argv, stdin, python -c, node -e, quoting, no shell -->
|
|
4
|
+
|
|
5
|
+
Run one program with exactly these arguments. **No shell. Gated** (it asks as
|
|
6
|
+
`bash`, showing the equivalent command line). **Never raises — read
|
|
7
|
+
`exit_code`.**
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
A command used to cross up to four interpreters on its way to the program:
|
|
12
|
+
your Python string, then `bash -c`, then often PowerShell or `python -c` or
|
|
13
|
+
`node -e` or a heredoc. Each layer has its own escapes, and none of them shows
|
|
14
|
+
you what the next one received. Python ate the backslash in `'\''`; bash ate
|
|
15
|
+
`$_` before PowerShell saw it; a regex lost its `\$` and silently matched
|
|
16
|
+
nothing. Those were the commonest ways scripts here broke.
|
|
17
|
+
|
|
18
|
+
`run` removes the layers instead of asking you to get them right. `argv` goes
|
|
19
|
+
to the operating system as a list, so `argv[1]` arrives as `argv[1]` — nothing
|
|
20
|
+
is expanded, split, globbed or quoted. And a script for another interpreter
|
|
21
|
+
goes in as **`input`**, on stdin, so it is never on a command line at all.
|
|
22
|
+
|
|
23
|
+
## Examples
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
r = await api.run(["git", "log", "--format=%h %s", "-5"])
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A script in another language — the source is a value, not something to quote:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
src = r'''
|
|
33
|
+
import sys, re
|
|
34
|
+
print(re.findall(r"\$[0-9.]+", sys.stdin.read()))
|
|
35
|
+
'''
|
|
36
|
+
r = await api.run(["python", "-"], input=src)
|
|
37
|
+
r = await api.run(["node", "-"], input="console.log(process.version)")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Arguments with spaces, quotes and dollar signs need nothing:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
await api.run(["git", "commit", "-m", "Fix the 'it's' case; costs $0"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Elsewhere, with extra environment:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
await api.run(["pnpm", "test"], cwd="widgex-ide/extension", env={"CI": "1"}, timeout=900)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
- **`argv`** — a list of strings. A single string is refused: that is a command
|
|
55
|
+
line, and `api.bash` is the call for those.
|
|
56
|
+
- **`input`** — `str` or `bytes`, written to stdin, which is then closed. With
|
|
57
|
+
no `input`, stdin is empty.
|
|
58
|
+
- **`cwd`** — any path `api.*` accepts, including `/c/...` and `~`.
|
|
59
|
+
- **`env`** — set on top of the inherited environment, never instead of it.
|
|
60
|
+
- **`timeout`**, **`background`** — as for `api.bash`; a background run is
|
|
61
|
+
managed with `bash_output`, `bash_list` and `bash_kill`.
|
|
62
|
+
|
|
63
|
+
`argv[0]` is looked up on `PATH` the way a shell would, and the result carries
|
|
64
|
+
the full path in `program`, so "which python was that" has an answer. On
|
|
65
|
+
Windows the Microsoft Store's `python.exe` stubs are skipped. A path with a
|
|
66
|
+
directory in it — `./scripts/x.mjs` — is relative to `cwd`, or the shell's cwd.
|
|
67
|
+
|
|
68
|
+
**Batch files on Windows.** `npm`, `pnpm` and `npx` are `.cmd` files, and
|
|
69
|
+
Windows runs those through cmd.exe, which reads `& | < > ^ % "` in their
|
|
70
|
+
arguments as cmd syntax. There is no escaping that is safe for every batch
|
|
71
|
+
file, so `run` refuses such an argument (exit code 2, with the reason) rather
|
|
72
|
+
than pass it on. Plain arguments to `.cmd` files work as normal.
|
|
73
|
+
|
|
74
|
+
The permission card shows the call as the bash command it amounts to:
|
|
75
|
+
`env` as `VAR=value` before it, and `input` as a heredoc after it, so what
|
|
76
|
+
you approve is everything the call does.
|
|
77
|
+
|
|
78
|
+
## Returns
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
{"stdout": str, "stderr": str, "exit_code": int, "program": str}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`exit_code` 127 with `"…: not found on PATH"` means the program does not exist.
|
|
85
|
+
`-1` means it timed out or could not start, exactly as for `bash`.
|
|
86
|
+
|
|
87
|
+
## Getting it wrong
|
|
88
|
+
|
|
89
|
+
**Putting a pipeline in `argv`.** `["ls", "|", "grep", "x"]` passes `|` to
|
|
90
|
+
`ls`. Pipes, globs and `&&` are shell features: use `api.bash` for those, or do
|
|
91
|
+
the filtering in Python.
|
|
92
|
+
|
|
93
|
+
**Writing the script as a normal string.** `input="print('a\nb')"` is Python
|
|
94
|
+
source in *your* Python first, and `\n` there is a newline before `run` ever
|
|
95
|
+
sees it. Use a raw string, `r'''…'''`, for any source with backslashes.
|
|
96
|
+
|
|
97
|
+
## See also
|
|
98
|
+
|
|
99
|
+
`bash`, `powershell`, `sandbox`
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# The sandbox
|
|
2
|
+
<!-- group: Concepts -->
|
|
3
|
+
|
|
4
|
+
Why `import`, `open()` and `os.*` are refused, and what to use instead.
|
|
5
|
+
|
|
6
|
+
Your code runs in a real Python process with a PEP 578 audit hook installed.
|
|
7
|
+
The hook sees every security-sensitive operation at the C level and, once
|
|
8
|
+
installed, cannot be removed by anything you execute. It is not a linter and
|
|
9
|
+
not a name filter — rebinding, `getattr`, or reaching through a builtin all hit
|
|
10
|
+
the same wall.
|
|
11
|
+
|
|
12
|
+
## The rule
|
|
13
|
+
|
|
14
|
+
Inside the code you write, **only `api` reaches the outside world.**
|
|
15
|
+
|
|
16
|
+
Refused in your code:
|
|
17
|
+
|
|
18
|
+
- `open()`, and every other direct file access
|
|
19
|
+
- `os.system`, `os.popen`, `subprocess.*`, `socket`, and importing them
|
|
20
|
+
- `eval`, `exec`, `compile`, `input`, `breakpoint`
|
|
21
|
+
- `ctypes` and friends, which would let native code step around the hook
|
|
22
|
+
- `time.sleep`, which would freeze the whole shell — `await asyncio.sleep(n)`
|
|
23
|
+
|
|
24
|
+
Allowed in your code: ordinary Python, **including imports of the pure
|
|
25
|
+
standard library**. These import normally:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
abc ast base64 binascii bisect calendar cmath collections collections.abc
|
|
29
|
+
contextlib copy csv dataclasses datetime decimal difflib enum fnmatch
|
|
30
|
+
fractions functools gzip hashlib heapq hmac html html.parser inspect io
|
|
31
|
+
itertools json keyword math numbers operator pprint random re secrets shlex
|
|
32
|
+
statistics string struct textwrap tomllib traceback types typing unicodedata
|
|
33
|
+
urllib urllib.parse uuid xml.etree.ElementTree zlib
|
|
34
|
+
asyncio os os.path pathlib time
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`os` is the sandboxed one (paths, no environment) and `time` has no `sleep`;
|
|
38
|
+
`json`, `os`, `Path` and `asyncio` are also bound without importing. None of
|
|
39
|
+
these can reach the system on their own: a file, process or socket any of them
|
|
40
|
+
tries to open is refused by the same hook as your own `open()`. A refused
|
|
41
|
+
import raises `ImportError`, so `try: … except ImportError:` works.
|
|
42
|
+
|
|
43
|
+
The same operations *are* permitted inside an `api.*` method's own body. That
|
|
44
|
+
is not an inconsistency: an api call is a declared, named, journalled,
|
|
45
|
+
possibly-gated action, and the whole point of routing through `api` is that the
|
|
46
|
+
IDE can see it. Your code calling `open()` directly is a file access nobody
|
|
47
|
+
recorded.
|
|
48
|
+
|
|
49
|
+
## What to use instead
|
|
50
|
+
|
|
51
|
+
| You reached for | Call this |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `open(p).read()` | `await api.read_file(p, raw=True)` — the exact text, no line numbers |
|
|
54
|
+
| `open(p, "w").write(s)` | `await api.write_file(p, s)` — `s` may be `bytes` |
|
|
55
|
+
| `os.listdir(d)` | `await api.ls(d)` |
|
|
56
|
+
| `os.path.exists(p)` | `await api.glob(p)` — empty list means no |
|
|
57
|
+
| `glob.glob(pat)` | `await api.glob(pat)` |
|
|
58
|
+
| `subprocess.run(argv)` | `await api.run(argv)` — no shell, nothing to quote |
|
|
59
|
+
| a shell pipeline | `await api.bash(cmd)` |
|
|
60
|
+
| PowerShell | `await api.powershell(script)` |
|
|
61
|
+
| a third-party module | `await api.run(["python", "-"], input=script)` |
|
|
62
|
+
| `os.chdir(d)` | `await api.cd(d)` |
|
|
63
|
+
| `requests.get(url)` | `await api.run(["curl", "-s", url])`, or the browser calls |
|
|
64
|
+
|
|
65
|
+
**Do not reach for `api.bash("python -c ...")` to get at a module.** It is a
|
|
66
|
+
second interpreter, outside this sandbox, behind two more layers of quoting —
|
|
67
|
+
the commonest way scripts here broke. If the module is in the list above,
|
|
68
|
+
import it; if it is not, pass the script as `input=` to `api.run`, where
|
|
69
|
+
nothing is quoted at all.
|
|
70
|
+
|
|
71
|
+
`api.bash`, `api.run` and `api.powershell` are the escape hatch. What they run
|
|
72
|
+
is a normal process — it is not sandboxed, because the *user approved that
|
|
73
|
+
specific command*. That approval is what the sandbox is protecting.
|
|
74
|
+
|
|
75
|
+
## Modes
|
|
76
|
+
|
|
77
|
+
The shell runs in one of several sandbox modes chosen by the IDE. `strict` is
|
|
78
|
+
the tightest and is what the rules above describe. Looser modes widen which
|
|
79
|
+
paths an api method may touch; none of them let your own code import or open
|
|
80
|
+
anything. Write code that works under `strict` and it works everywhere.
|
|
81
|
+
|
|
82
|
+
## Seals
|
|
83
|
+
|
|
84
|
+
An organisation can **seal** a file or a folder against some or all models —
|
|
85
|
+
read, write, execute, copy or move. A seal is not a permission: no mode, grant
|
|
86
|
+
or approval lifts it, and there is nobody to ask. When a call is refused with
|
|
87
|
+
"WidgeX Seal", carry on without that file and say in your reply that it was
|
|
88
|
+
sealed.
|
|
89
|
+
|
|
90
|
+
Every way of reaching a sealed file is refused, not only `read_file`:
|
|
91
|
+
`edit_file` (an edit reads), `grep` (the file is skipped and named), `bash`,
|
|
92
|
+
`run` and `powershell` commands that name it — directly, through a glob, a
|
|
93
|
+
`cd`, git's `rev:path`, or inline code that mentions it — `drive` or
|
|
94
|
+
`navigate` to it as a `file://` page, `aux_context=` and any other api that
|
|
95
|
+
would hand it to a model. A command that copies or moves a file out of a
|
|
96
|
+
sealed folder is refused too, because the copy would not be sealed.
|
|
97
|
+
|
|
98
|
+
Output is screened as well: a line of a read-sealed file that reaches your
|
|
99
|
+
result by any route is replaced with a notice saying it was withheld. Do not try
|
|
100
|
+
to recover withheld lines another way — encoding or transforming a sealed file
|
|
101
|
+
to get it past the screen is exactly what a seal forbids, even where nothing
|
|
102
|
+
can detect it.
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Wrong — open() is refused by the audit hook, and the exec dies here.
|
|
108
|
+
import json
|
|
109
|
+
data = json.load(open("package.json"))
|
|
110
|
+
|
|
111
|
+
# Right.
|
|
112
|
+
import json
|
|
113
|
+
data = json.loads(await api.read_file("package.json", raw=True))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Checking whether something exists:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
hits = await api.glob("src/**/config.ts")
|
|
120
|
+
if not hits:
|
|
121
|
+
return "no config found"
|
|
122
|
+
return await api.read_file(hits[0])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Getting it wrong
|
|
126
|
+
|
|
127
|
+
**Treating a refusal as a bug in your code and retrying with a different
|
|
128
|
+
spelling.** `__builtins__["open"]`, `os = __import__("os")` and every other
|
|
129
|
+
route hit the same hook. If an api call cannot do what you need, that is a
|
|
130
|
+
missing capability — say so with `api.report_bug(kind="missing_capability")`
|
|
131
|
+
and route around it, usually via `api.run`.
|
|
132
|
+
|
|
133
|
+
**Putting a whole file's contents into `api.bash` as a heredoc to avoid
|
|
134
|
+
`write_file`.** It works, but `bash` changes are not journalled: they will not
|
|
135
|
+
show up in `xylon_get_diff` and cannot be reverted with the turn. Use
|
|
136
|
+
`write_file` and `edit_file` for file contents.
|
|
137
|
+
|
|
138
|
+
**Assuming a refusal is silent.** It raises, which aborts the entire exec —
|
|
139
|
+
every line after it in that block never runs. Keep sandbox-risky experiments in
|
|
140
|
+
their own small exec.
|
|
141
|
+
|
|
142
|
+
## See also
|
|
143
|
+
|
|
144
|
+
`permissions`, `bash`, `read_file`, `write_file`, `report_bug`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# api.session_fork(prompt, aux_context=None) -> dict
|
|
2
|
+
<!-- group: Sessions -->
|
|
3
|
+
|
|
4
|
+
Fork into an independent clone: copied repo, copied conversation. Gated.
|
|
5
|
+
|
|
6
|
+
The child runs in a **cloned copy of the repository** and starts with a copy of
|
|
7
|
+
this conversation's history. Your `prompt` becomes its opening task. It runs
|
|
8
|
+
independently; this call returns as soon as it exists.
|
|
9
|
+
|
|
10
|
+
## Options
|
|
11
|
+
|
|
12
|
+
- **`prompt`** — the forked session's starting task. Unlike `agent`,
|
|
13
|
+
the child already knows what you have been doing, so this can be short: it is
|
|
14
|
+
the instruction, not the briefing.
|
|
15
|
+
- **`aux_context`** — path to a `.md` or `.txt` file, absolute or relative to
|
|
16
|
+
the cwd. Its **contents** are read now and attached to the child's opening
|
|
17
|
+
prompt. Capped at 100,000 characters. Read on this side because the fork runs
|
|
18
|
+
in a *different* checkout — a path you passed might not exist there.
|
|
19
|
+
|
|
20
|
+
There is no `model` and no `summary`. The conversation copy is the briefing.
|
|
21
|
+
|
|
22
|
+
## Returns
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
{"sessionId": str, "xylonNodePath": str}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Immediately. The fork exists; it has not finished.
|
|
29
|
+
|
|
30
|
+
## Examples
|
|
31
|
+
|
|
32
|
+
Trying an approach without committing the user's tree to it:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
child = await api.session_fork(
|
|
36
|
+
"Try the approach we just rejected — swap the audit hook for a subprocess "
|
|
37
|
+
"jail — and report whether the test suite passes. Change nothing else."
|
|
38
|
+
)
|
|
39
|
+
return f"Forked to {child['sessionId']}; its edits are in a clone, not here."
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Two approaches in parallel, compared afterwards:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
a = await api.session_fork("Implement it with a ContextVar, as discussed.")
|
|
46
|
+
b = await api.session_fork("Implement it by threading the id through explicitly.")
|
|
47
|
+
ra = await api.session_join(a["sessionId"], timeout=1800)
|
|
48
|
+
rb = await api.session_join(b["sessionId"], timeout=1800)
|
|
49
|
+
return {"contextvar": ra.get("lastMessage"), "explicit": rb.get("lastMessage")}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Getting it wrong
|
|
53
|
+
|
|
54
|
+
**Expecting the fork's edits to appear in your repo.** They will not. The clone
|
|
55
|
+
is a separate checkout, and nothing merges it back. A fork's output is what it
|
|
56
|
+
*tells* you, plus a diff you can read from its xylon node. If you want the
|
|
57
|
+
files changed here, do not fork.
|
|
58
|
+
|
|
59
|
+
**Forking for routine parallelism.** Cloning a repository is expensive, and two
|
|
60
|
+
forks of a large tree cost real disk and real time. `agent` shares the
|
|
61
|
+
directory; `agent` shares even more.
|
|
62
|
+
|
|
63
|
+
**Forking to give a child context.** It works — the history is copied — but you
|
|
64
|
+
copy *everything*, including whatever is irrelevant. `agent(wait=False,
|
|
65
|
+
summary=...)` gives the child three sentences instead of an hour.
|
|
66
|
+
|
|
67
|
+
**Forgetting it is gated and long-lived.** The user approves a fork; it then
|
|
68
|
+
sits there consuming tokens until it finishes or is terminated. You can
|
|
69
|
+
`session_terminate` a fork, because it is your descendant.
|
|
70
|
+
|
|
71
|
+
## See also
|
|
72
|
+
|
|
73
|
+
`agent`, `session_join`, `session_terminate`,
|
|
74
|
+
`agent-vs-fork`, `sessions`, `xylon`
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# api.session_harness(prompt, token_budget=None, window='day', machine=None, model=None) -> dict
|
|
2
|
+
<!-- group: Sessions -->
|
|
3
|
+
<!-- also: session_daemonize -->
|
|
4
|
+
|
|
5
|
+
Turn this session into a harness: the same prompt, re-served every turn. Gated.
|
|
6
|
+
|
|
7
|
+
A harness runs unattended. At the start of each turn it is handed `prompt` again
|
|
8
|
+
and works until the turn ends; then it starts over. It stops when
|
|
9
|
+
`api.session_rest` is called, when its machine reaches an end state, when a
|
|
10
|
+
human stops it, or when it reaches a limit:
|
|
11
|
+
|
|
12
|
+
- **tokens** — `token_budget` per window, 1,000,000 per day if you give none.
|
|
13
|
+
There is no "no limit".
|
|
14
|
+
- **cycles** — 50 in one run.
|
|
15
|
+
|
|
16
|
+
Reaching a limit stops the harness until a person presses Run in the Harness
|
|
17
|
+
Manager. It does not wait and resume by itself.
|
|
18
|
+
|
|
19
|
+
**A harness cannot ask the user anything.** That constraint shapes everything
|
|
20
|
+
below.
|
|
21
|
+
|
|
22
|
+
## Options
|
|
23
|
+
|
|
24
|
+
- **`prompt`** — the standing task, re-served verbatim every turn. Write it for
|
|
25
|
+
a session with no memory of the last run: what to look at, what counts as
|
|
26
|
+
work, and what to do when there is nothing to do.
|
|
27
|
+
- **`token_budget`** — tokens per window, 1,000 to 100,000,000. Omit it for
|
|
28
|
+
1,000,000. Spending it stops the harness until a person restarts it.
|
|
29
|
+
- **`window`** — `'hour'`, `'day'` or `'week'`. Default `'day'`.
|
|
30
|
+
- **`machine`** — the name of a state machine in `.widgex/harnesses/`, which
|
|
31
|
+
splits the standing task into states with their own prompts and gated
|
|
32
|
+
transitions. Every state must have a path to a `"terminal": true` state, or
|
|
33
|
+
the machine does not load. See `harness` for the format.
|
|
34
|
+
- **`model`** — the model every run uses, e.g. `'claude-opus-5'`, or
|
|
35
|
+
`'provider/model'` when two providers share an id. An unknown id is refused,
|
|
36
|
+
with the list of valid ones, and **nothing is armed**.
|
|
37
|
+
|
|
38
|
+
**Omitting `machine` or `model` keeps whatever is currently set.** This is
|
|
39
|
+
deliberate: re-arming to change a budget must not silently unbind a machine.
|
|
40
|
+
There is no api route to *clear* a binding — that is done from the Harness
|
|
41
|
+
Manager.
|
|
42
|
+
|
|
43
|
+
## Returns
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
{"sessionId": str, "runs": int, "spent": int,
|
|
47
|
+
"budget": {"tokens": int, "window": str},
|
|
48
|
+
"machine": {"name": str, "state": str} | None,
|
|
49
|
+
"model": {"providerId": str, "modelId": str} | None}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`runs` and `spent` are the counters for the current window, so a re-arm shows
|
|
53
|
+
you what the previous arming had used.
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
A standing task with an explicit idle branch:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
return await api.session_harness(
|
|
61
|
+
"Check the CI dashboard for newly failing tests on main. For each one, find "
|
|
62
|
+
"the commit that broke it and open an issue naming that commit. If nothing "
|
|
63
|
+
"is failing, say so in one line and stop for this turn.",
|
|
64
|
+
token_budget=200_000,
|
|
65
|
+
window="day",
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Raising a budget without disturbing the machine binding:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
return await api.session_harness(prompt, token_budget=500_000, window="week")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Stopping the loop from inside it:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
if not failures:
|
|
79
|
+
await api.session_rest("main has been green for three consecutive runs")
|
|
80
|
+
return "Nothing failing — rested."
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Getting it wrong
|
|
84
|
+
|
|
85
|
+
**Writing a prompt that assumes the previous turn.** "Continue where you left
|
|
86
|
+
off" reaches a session that does not know where that was. State is in the repo
|
|
87
|
+
and the tree, not in the harness. Say how to find it.
|
|
88
|
+
|
|
89
|
+
**Calling `ask_user`.** It is denied outright — "this harness runs unattended and
|
|
90
|
+
cannot ask questions". Decide, or `session_rest` with the question as your
|
|
91
|
+
reason.
|
|
92
|
+
|
|
93
|
+
**Expecting a gated call to prompt.** It will not. Gated calls resolve from
|
|
94
|
+
grants: auto-allowed or auto-denied. A harness that needs `edit_file` and has no
|
|
95
|
+
grant for it will be refused every turn, forever.
|
|
96
|
+
|
|
97
|
+
**No exit condition.** Without a `session_rest` or an end state, the harness
|
|
98
|
+
runs until it hits its token or cycle limit and stops there, having finished
|
|
99
|
+
nothing. Decide what "finished" means before arming.
|
|
100
|
+
|
|
101
|
+
**Using `session_terminate` to stop it.** That destroys the session. `session_rest`
|
|
102
|
+
stops the loop and leaves it re-armable.
|
|
103
|
+
|
|
104
|
+
**Assuming `machine=None` unbinds.** It keeps the current machine. Unbinding is
|
|
105
|
+
a Harness Manager action.
|
|
106
|
+
|
|
107
|
+
## See also
|
|
108
|
+
|
|
109
|
+
`harness`, `session_rest`, `permissions`, `ask_user`, `sessions`
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# api.session_join(session_id, timeout=None) -> dict
|
|
2
|
+
<!-- group: Sessions -->
|
|
3
|
+
|
|
4
|
+
Wait for a child session to finish its current turn. Not gated.
|
|
5
|
+
|
|
6
|
+
Blocks until the target reaches `done` or `error`, or until the timeout — a
|
|
7
|
+
child still working then comes back as `timeout` and **keeps running**.
|
|
8
|
+
|
|
9
|
+
## Options
|
|
10
|
+
|
|
11
|
+
- **`session_id`** — the id from `session_fork` or `agent`.
|
|
12
|
+
- **`timeout`** — seconds. Must satisfy `0 < timeout <= 3600`. Defaults to
|
|
13
|
+
**300**. A non-number, zero, or anything over an hour raises `ValueError`
|
|
14
|
+
before it waits.
|
|
15
|
+
|
|
16
|
+
**Raise the exec's own `timeout_seconds` above this**, or the tool call running
|
|
17
|
+
your code is killed while the join is still waiting. The tool call defaults to
|
|
18
|
+
120 seconds, which is shorter than this call's own default of 300.
|
|
19
|
+
|
|
20
|
+
## Returns
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
{"lastMessage": str, "xylonNodePath": str, "status": "done" | "error" | "timeout"}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
plus an **`error`** key when the run failed.
|
|
27
|
+
|
|
28
|
+
`status` is the only reliable signal:
|
|
29
|
+
|
|
30
|
+
| `status` | Meaning | `lastMessage` |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `done` | The turn finished. | the child's answer |
|
|
33
|
+
| `error` | The run failed. | **empty** — read `error` |
|
|
34
|
+
| `timeout` | Still working. | whatever it had said so far |
|
|
35
|
+
|
|
36
|
+
A failed run has an **empty `lastMessage`**, because the reason is recorded as
|
|
37
|
+
a system message rather than an assistant one. An empty `lastMessage` on its
|
|
38
|
+
own cannot be told apart from a child that simply had nothing to say.
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
|
|
42
|
+
Check `status` first. Always:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
r = await api.session_join(child_id, timeout=900)
|
|
46
|
+
if r["status"] == "error":
|
|
47
|
+
return {"child_failed": child_id, "why": r.get("error")}
|
|
48
|
+
if r["status"] == "timeout":
|
|
49
|
+
return f"{child_id} is still working; I'll leave it running."
|
|
50
|
+
return r["lastMessage"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Polling, rather than one long block:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
for _ in range(4):
|
|
57
|
+
r = await api.session_join(child_id, timeout=600)
|
|
58
|
+
if r["status"] != "timeout":
|
|
59
|
+
break
|
|
60
|
+
await api.todo_write(todos) # keep the user informed between waits
|
|
61
|
+
return r
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Waiting on several children:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
out = {}
|
|
68
|
+
for cid in child_ids:
|
|
69
|
+
r = await api.session_join(cid, timeout=300)
|
|
70
|
+
out[cid] = r["lastMessage"] if r["status"] == "done" else r["status"]
|
|
71
|
+
return out
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Getting it wrong
|
|
75
|
+
|
|
76
|
+
**Reading `lastMessage` without checking `status`.** This is the failure this
|
|
77
|
+
call is most known for:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# Wrong — a crashed child reports as a silent success.
|
|
81
|
+
r = await api.session_join(cid)
|
|
82
|
+
return r["lastMessage"] or "the child had nothing to add"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You will tell the user the work is done when it failed.
|
|
86
|
+
|
|
87
|
+
**Leaving the exec timeout at its default.** `timeout=1800` inside a tool call
|
|
88
|
+
capped at 120 seconds means your code dies at 120 seconds and the child is
|
|
89
|
+
orphaned. Set the tool call's `timeout_seconds` higher than the join's.
|
|
90
|
+
|
|
91
|
+
**Treating `timeout` as a failure.** The child is still going. Either wait
|
|
92
|
+
again or report that it is running; do not restart the work.
|
|
93
|
+
|
|
94
|
+
**Joining a `agent`.** A delegate is not a session and has no id — it
|
|
95
|
+
already blocked and returned its answer directly.
|
|
96
|
+
|
|
97
|
+
**Joining something that is not your descendant.** Only sessions you spawned
|
|
98
|
+
are yours to wait on.
|
|
99
|
+
|
|
100
|
+
## See also
|
|
101
|
+
|
|
102
|
+
`agent`, `session_fork`, `session_terminate`, `agent`, `sessions`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Landing a child's branch (no api call — the user does this)
|
|
2
|
+
|
|
3
|
+
<!-- group: Sessions -->
|
|
4
|
+
|
|
5
|
+
Taking a branch child's work back into a parent branch. **There is no
|
|
6
|
+
`api.session_land`.** Landing is the "Land changes" wizard in the session
|
|
7
|
+
queue, and the user drives it.
|
|
8
|
+
|
|
9
|
+
This page is here because the operation is one you will be asked about and will
|
|
10
|
+
want to explain — and because it used to document a call, in enough detail that
|
|
11
|
+
an agent reading the manual would go on to invoke one that has never existed.
|
|
12
|
+
What follows is what landing does, so you can say so; the last section is what
|
|
13
|
+
to do instead of reaching for it.
|
|
14
|
+
|
|
15
|
+
## Only what the child did
|
|
16
|
+
|
|
17
|
+
A child spawned with `api.agent(isolate=True)` gets a copy of the parent's
|
|
18
|
+
uncommitted work at the moment it was spawned. That copy is committed on the
|
|
19
|
+
child's branch so its own work can be measured against something, but it is
|
|
20
|
+
**not** the child's work and it does not come back — the parent already has it.
|
|
21
|
+
|
|
22
|
+
The consequence worth knowing when you are reading a landing preview: a child
|
|
23
|
+
that has done nothing of its own has **nothing to land**, even though its
|
|
24
|
+
branch carries a commit. It is reported as idle rather than merged. Merging it
|
|
25
|
+
would put the parent's own work back on top of the parent, and with three
|
|
26
|
+
children it would do so three times.
|
|
27
|
+
|
|
28
|
+
## What the preview answers
|
|
29
|
+
|
|
30
|
+
The wizard previews before it merges, and the question it answers is the one
|
|
31
|
+
landing children one at a time cannot: **which files more than one child
|
|
32
|
+
touched**. Git will merge two children's edits to the same file perfectly
|
|
33
|
+
happily, and the result can still be one child silently undoing the other. The
|
|
34
|
+
overlap list names those paths and the branches that collide on them, while
|
|
35
|
+
there is still a choice about the order — or about whether to take both.
|
|
36
|
+
|
|
37
|
+
## Outcomes you may be shown
|
|
38
|
+
|
|
39
|
+
- **`landed`** — its work is in the target branch.
|
|
40
|
+
- **`conflicted`** — git stopped. The pass stops with it.
|
|
41
|
+
- **`skipped`** — deliberately left out: mid-turn, blocked by uncommitted
|
|
42
|
+
work, or already in the target. The pass carries on.
|
|
43
|
+
- **`not-attempted`** — the pass had already stopped. Nothing was done to it.
|
|
44
|
+
- **`failed`** — it threw. Distinct from a conflict, which is a resting state.
|
|
45
|
+
|
|
46
|
+
## If a landing conflicts, stop
|
|
47
|
+
|
|
48
|
+
Report what happened and stop. Do not go into the worktree and fix the merge:
|
|
49
|
+
the user has a screen for exactly that, with both sides in front of them, and a
|
|
50
|
+
conflict is a question about intent rather than a mechanical failure. Say which
|
|
51
|
+
branch stopped it and what it collided with.
|
|
52
|
+
|
|
53
|
+
Taking one side of a conflicted file *when the user has asked you to* is
|
|
54
|
+
ordinary work and is not gated — `git checkout --ours -- <path>` moves no ref.
|
|
55
|
+
Switching branches still is.
|
|
56
|
+
|
|
57
|
+
## Do not do this with git
|
|
58
|
+
|
|
59
|
+
Running `git merge` in `api.bash` reaches the same commits and skips everything
|
|
60
|
+
that makes the operation safe: the per-repository lock that stops two windows
|
|
61
|
+
landing at once, the refusal when either branch has moved since it was looked
|
|
62
|
+
at, the sibling-overlap warning, and the accounting write that clears the
|
|
63
|
+
child's row in the user's queue and lets its branch be deleted afterwards. A
|
|
64
|
+
repository merged that way looks right and has no record of what happened to
|
|
65
|
+
it.
|
|
66
|
+
|
|
67
|
+
So when a landing is what is wanted: say so, and let the user run the wizard.
|
|
68
|
+
|
|
69
|
+
## See also
|
|
70
|
+
|
|
71
|
+
`agent` spawns the children this is about — `isolate=True` is the one that
|
|
72
|
+
gives a child a branch and a worktree of its own. `agent-vs-fork` covers which
|
|
73
|
+
relationship to reach for in the first place.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# api.session_nudge(session_id, prompt) -> dict
|
|
2
|
+
<!-- also: nudge, re-prompt, reprompt, wake_child, chase, reuse_subagent, persistent_subagent -->
|
|
3
|
+
<!-- group: Handing work to another agent -->
|
|
4
|
+
|
|
5
|
+
Send a fresh prompt into a child of yours that has gone idle. Requires
|
|
6
|
+
permission; a harness holding the session-spawning grant is allowed it without
|
|
7
|
+
asking.
|
|
8
|
+
|
|
9
|
+
Two jobs, and the second is the one worth planning around.
|
|
10
|
+
|
|
11
|
+
## Chasing
|
|
12
|
+
|
|
13
|
+
A subagent that stopped without finishing is not lost. It still has everything
|
|
14
|
+
it read, everything it worked out, and whatever it half-did. Name **what is
|
|
15
|
+
outstanding**, in the words you dispatched it with:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
api.session_nudge(child, "The importer is converted but you never said whether "
|
|
19
|
+
"the fixtures still pass. Run them and report.")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
"Finish the task" tells it nothing it did not already believe it had done. If
|
|
23
|
+
you dispatched it with `must=[…]`, those obligations are the words to use — and
|
|
24
|
+
when a child ends, the notice you are served names them for you.
|
|
25
|
+
|
|
26
|
+
A subagent that stopped is usually not being lazy. It hit something: a decision
|
|
27
|
+
it could not make, a test it could not run, a dependency that is not there. Ask
|
|
28
|
+
what stopped it as well as telling it to carry on — and if the answer is a
|
|
29
|
+
decision, that is not something to nudge. That is something to take to the user.
|
|
30
|
+
|
|
31
|
+
## Reuse
|
|
32
|
+
|
|
33
|
+
The expensive part of a subagent is not the subagent. It is the reading: the
|
|
34
|
+
files it had to open before it could say anything useful. A child that has
|
|
35
|
+
already read the importer is the cheapest possible place to send your next
|
|
36
|
+
question about the importer, because it is not going to read it again.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
for row in await api.children():
|
|
40
|
+
if row["nudgeable"] and "importer" in (row["asked"] or ""):
|
|
41
|
+
await api.session_nudge(row["sessionId"], "What does csv.ts do with "
|
|
42
|
+
"newlines inside a quoted field?")
|
|
43
|
+
break
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`api.children()` is the roster. Match on **`asked`** — what that child was
|
|
47
|
+
originally sent to do — and not on `label`, which is overwritten with the branch
|
|
48
|
+
name and so reads the same for every child sharing a worktree.
|
|
49
|
+
|
|
50
|
+
This matters most to an agent that cannot read anything itself. If every
|
|
51
|
+
question costs a fresh subagent that starts from an empty head, delegating is a
|
|
52
|
+
tax on curiosity; if it costs a re-prompt, it is just how you work.
|
|
53
|
+
|
|
54
|
+
## What comes back
|
|
55
|
+
|
|
56
|
+
`{delivered: True}`, or `{delivered: False, reason: "..."}`.
|
|
57
|
+
|
|
58
|
+
A refusal is an ordinary answer rather than an exception, because the commonest
|
|
59
|
+
one — *"it is still working"* — is a normal thing to find when you look, and
|
|
60
|
+
walking a roster should not mean a `try` around every row.
|
|
61
|
+
|
|
62
|
+
| Refused when | Because |
|
|
63
|
+
|---|---|
|
|
64
|
+
| it is mid-turn | A nudge reacts to a session having *stopped*. It is never queued: one arriving mid-sentence reads as an answer to something nobody asked. |
|
|
65
|
+
| it is not your descendant | Same subtree rule as `session_terminate`. You cannot reach a parent, a sibling or a stranger. |
|
|
66
|
+
| it is you | A session prompting itself is a loop with no exit. |
|
|
67
|
+
| the prompt is empty | It would say nothing. |
|
|
68
|
+
|
|
69
|
+
## It is not `api.agent`
|
|
70
|
+
|
|
71
|
+
`agent` makes a new one. This talks to one you already have. When you find
|
|
72
|
+
yourself about to spawn a second reader for a subject you already have a reader
|
|
73
|
+
for, this is the call you wanted.
|