batonsh 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- batonsh-0.1.0/.gitignore +13 -0
- batonsh-0.1.0/PKG-INFO +141 -0
- batonsh-0.1.0/README.md +130 -0
- batonsh-0.1.0/baton/__init__.py +54 -0
- batonsh-0.1.0/baton/_discovery.py +50 -0
- batonsh-0.1.0/baton/client.py +746 -0
- batonsh-0.1.0/baton/py.typed +0 -0
- batonsh-0.1.0/baton/run.py +438 -0
- batonsh-0.1.0/examples/run_task.py +88 -0
- batonsh-0.1.0/examples/watch.py +90 -0
- batonsh-0.1.0/pyproject.toml +20 -0
- batonsh-0.1.0/tests/test_client.py +489 -0
batonsh-0.1.0/.gitignore
ADDED
batonsh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: batonsh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Baton, the multi-agent coding TUI/daemon
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Operating System :: POSIX
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Baton Python SDK
|
|
13
|
+
|
|
14
|
+
Drive [Baton](../README.md), the multi-agent coding daemon, from Python. It is a small,
|
|
15
|
+
standard-library-only client for the Baton control protocol (JSON-RPC 2.0 over the project's Unix socket),
|
|
16
|
+
the same API the TUI uses.
|
|
17
|
+
|
|
18
|
+
- Python 3.9 or newer, with no dependencies. Works on macOS and Linux (Unix sockets).
|
|
19
|
+
- Import name `baton`, distribution name `batonsh`.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install ./sdk-python # or just put sdk-python/ on PYTHONPATH
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import baton
|
|
29
|
+
|
|
30
|
+
with baton.connect() as client: # finds the project, starts `baton daemon` if needed
|
|
31
|
+
run = client.run_state() # self-updating view of the run
|
|
32
|
+
seq = run.last_seq
|
|
33
|
+
client.submit("migrate the tests to pytest") # same as typing it in the TUI
|
|
34
|
+
run.wait_idle(timeout=900, since_seq=seq) # until every agent is idle again
|
|
35
|
+
for a in run.agents.values():
|
|
36
|
+
print(a.name, a.status, a.diff, "$%.3f" % a.cost, a.last_message)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Connecting
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
client = baton.connect(
|
|
43
|
+
cwd=None, # any directory inside the project (default: os.getcwd())
|
|
44
|
+
socket_path=None, # explicit socket (skips discovery)
|
|
45
|
+
autostart=True, # launch a detached daemon when nothing answers
|
|
46
|
+
client_name="python",
|
|
47
|
+
daemon_command=None, # ["baton", "daemon", "--root", "{root}"] or lambda root: [...]
|
|
48
|
+
start_timeout=15.0,
|
|
49
|
+
env=None, # extra env vars for a started daemon
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`connect()` walks up from `cwd` to the project root (the first directory with `.baton/config.toml`,
|
|
54
|
+
otherwise the nearest `.git`). It connects to `.baton/run/baton.sock`, or to the path stored in
|
|
55
|
+
`.baton/run/socket-path` for deeply nested projects. Without a running daemon it starts
|
|
56
|
+
`baton daemon --root <root>` using `$BATON_BIN` (shell-split, e.g. `BATON_BIN="bun packages/cli/src/main.ts"`)
|
|
57
|
+
or `baton` on `PATH`, waits for the socket, and raises `BatonError` if the daemon exits first.
|
|
58
|
+
The `hello` handshake is done for you: `client.project_root`, `client.run_id`, `client.daemon_pid`.
|
|
59
|
+
|
|
60
|
+
## Calls
|
|
61
|
+
|
|
62
|
+
Each control method has a wrapper. Results are plain dicts with the protocol's camelCase keys.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.snapshot() # agents, tasks, approvals, totals, autonomy, lastSeq
|
|
66
|
+
info = client.spawn("codex", label="review", approval="auto-edit", options={"speed": 0})
|
|
67
|
+
client.prompt(info["agentId"], "review the latest diff", delivery="steer")
|
|
68
|
+
client.submit("@codex-2 verify codex-1's fix") # @mentions, /commands, or a task for the orchestrator
|
|
69
|
+
client.send("*", "freeze: release in 10 minutes", delivery="interrupt")
|
|
70
|
+
client.respond(agent_id, request_id, option_id) # answer a permission request (None cancels)
|
|
71
|
+
client.interrupt(agent_id); client.kill(agent_id)
|
|
72
|
+
client.set_autonomy("auto") # normal | ask | auto | yolo
|
|
73
|
+
client.merge(agent_id); print(client.diff(agent_id))
|
|
74
|
+
client.query_events(types=["tool.*"], limit=50)
|
|
75
|
+
client.call("runs.list", {"limit": 5}) # anything else, by method name
|
|
76
|
+
client.shutdown() # stop the daemon
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Errors raise `baton.BatonError`, which carries `.code`, `.message` and `.data`. A closed connection
|
|
80
|
+
raises `baton.ConnectionClosed`, and a `timeout=` on `call()` raises `baton.BatonTimeoutError`, which
|
|
81
|
+
is also a `TimeoutError`.
|
|
82
|
+
|
|
83
|
+
## Events
|
|
84
|
+
|
|
85
|
+
Every event is a dict `{id, seq, ts, runId, agentId?, type, data}` (see the BEP table in
|
|
86
|
+
[docs/sdk.md](../docs/sdk.md)).
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# iterator: subscribes immediately; breaking out of the loop unsubscribes; ends when the connection closes
|
|
90
|
+
for e in client.events(types=["tool.*", "bus.message"], from_seq=0):
|
|
91
|
+
print(e["seq"], e["type"], e["data"])
|
|
92
|
+
|
|
93
|
+
# callback on a worker thread (it may call the client)
|
|
94
|
+
def approve_reads(e):
|
|
95
|
+
d = e["data"]
|
|
96
|
+
if d.get("kind") == "read":
|
|
97
|
+
allow = next(o["optionId"] for o in d["options"] if o["kind"] == "allow_once")
|
|
98
|
+
client.respond(e["agentId"], d["requestId"], allow)
|
|
99
|
+
|
|
100
|
+
sub = client.on_event(approve_reads, types=["permission.requested"])
|
|
101
|
+
...
|
|
102
|
+
sub.close()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`events(timeout=…)` raises `TimeoutError` when nothing arrives for that long. `client.subscribe(handler)`
|
|
106
|
+
is the low-level form. Its handler runs on the socket reader thread, so it must be fast and must not
|
|
107
|
+
call the client.
|
|
108
|
+
|
|
109
|
+
## Run state
|
|
110
|
+
|
|
111
|
+
`client.run_state()` replays the whole run, then follows it live, and gives you:
|
|
112
|
+
|
|
113
|
+
- `agents`: `{agentId: Agent}`, where each `Agent` has `name adapter status label role model cost usage diff tool_calls turns last_message busy alive`
|
|
114
|
+
- `approvals`: `{requestId: Approval}`
|
|
115
|
+
- `tasks`, `workspaces`, `bus`, `autonomy`, `mode`, `totals`, `last_seq`
|
|
116
|
+
- `wait_for(predicate, timeout)`, `wait_idle(timeout, since_seq=None, settle=0.3)`, `is_idle()`, `find_agent(name_or_id)`
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
run = client.run_state()
|
|
120
|
+
coder = run.wait_for(lambda s: s.find_agent("codex-1"), timeout=30)
|
|
121
|
+
run.wait_for(lambda s: not s.approvals and coder.status == "ready", timeout=600)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Pass `since_seq=run.last_seq` (captured before you submit work) to `wait_idle` so it does not return
|
|
125
|
+
before the work starts. `settle` requires the run to stay quiet for that long, which covers short idle
|
|
126
|
+
gaps when agents hand off to each other.
|
|
127
|
+
|
|
128
|
+
## Examples
|
|
129
|
+
|
|
130
|
+
- `examples/watch.py` prints a live one-line-per-event feed (`--replay`, `--types 'tool.*'`, `--agent NAME`).
|
|
131
|
+
- `examples/run_task.py "task" --approve reads` submits a task, waits until every agent is idle, and prints cost, per-agent changes and the files touched.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
python3 -m unittest discover -s sdk-python/tests -v
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The integration tests start a throwaway daemon with `bun packages/cli/src/main.ts daemon` in a temporary
|
|
140
|
+
git repo, using mock agents and temporary `BATON_CONFIG_DIR`/`BATON_STATE_DIR`. They are skipped when `bun`
|
|
141
|
+
is not installed.
|
batonsh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Baton Python SDK
|
|
2
|
+
|
|
3
|
+
Drive [Baton](../README.md), the multi-agent coding daemon, from Python. It is a small,
|
|
4
|
+
standard-library-only client for the Baton control protocol (JSON-RPC 2.0 over the project's Unix socket),
|
|
5
|
+
the same API the TUI uses.
|
|
6
|
+
|
|
7
|
+
- Python 3.9 or newer, with no dependencies. Works on macOS and Linux (Unix sockets).
|
|
8
|
+
- Import name `baton`, distribution name `batonsh`.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install ./sdk-python # or just put sdk-python/ on PYTHONPATH
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import baton
|
|
18
|
+
|
|
19
|
+
with baton.connect() as client: # finds the project, starts `baton daemon` if needed
|
|
20
|
+
run = client.run_state() # self-updating view of the run
|
|
21
|
+
seq = run.last_seq
|
|
22
|
+
client.submit("migrate the tests to pytest") # same as typing it in the TUI
|
|
23
|
+
run.wait_idle(timeout=900, since_seq=seq) # until every agent is idle again
|
|
24
|
+
for a in run.agents.values():
|
|
25
|
+
print(a.name, a.status, a.diff, "$%.3f" % a.cost, a.last_message)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Connecting
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
client = baton.connect(
|
|
32
|
+
cwd=None, # any directory inside the project (default: os.getcwd())
|
|
33
|
+
socket_path=None, # explicit socket (skips discovery)
|
|
34
|
+
autostart=True, # launch a detached daemon when nothing answers
|
|
35
|
+
client_name="python",
|
|
36
|
+
daemon_command=None, # ["baton", "daemon", "--root", "{root}"] or lambda root: [...]
|
|
37
|
+
start_timeout=15.0,
|
|
38
|
+
env=None, # extra env vars for a started daemon
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`connect()` walks up from `cwd` to the project root (the first directory with `.baton/config.toml`,
|
|
43
|
+
otherwise the nearest `.git`). It connects to `.baton/run/baton.sock`, or to the path stored in
|
|
44
|
+
`.baton/run/socket-path` for deeply nested projects. Without a running daemon it starts
|
|
45
|
+
`baton daemon --root <root>` using `$BATON_BIN` (shell-split, e.g. `BATON_BIN="bun packages/cli/src/main.ts"`)
|
|
46
|
+
or `baton` on `PATH`, waits for the socket, and raises `BatonError` if the daemon exits first.
|
|
47
|
+
The `hello` handshake is done for you: `client.project_root`, `client.run_id`, `client.daemon_pid`.
|
|
48
|
+
|
|
49
|
+
## Calls
|
|
50
|
+
|
|
51
|
+
Each control method has a wrapper. Results are plain dicts with the protocol's camelCase keys.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.snapshot() # agents, tasks, approvals, totals, autonomy, lastSeq
|
|
55
|
+
info = client.spawn("codex", label="review", approval="auto-edit", options={"speed": 0})
|
|
56
|
+
client.prompt(info["agentId"], "review the latest diff", delivery="steer")
|
|
57
|
+
client.submit("@codex-2 verify codex-1's fix") # @mentions, /commands, or a task for the orchestrator
|
|
58
|
+
client.send("*", "freeze: release in 10 minutes", delivery="interrupt")
|
|
59
|
+
client.respond(agent_id, request_id, option_id) # answer a permission request (None cancels)
|
|
60
|
+
client.interrupt(agent_id); client.kill(agent_id)
|
|
61
|
+
client.set_autonomy("auto") # normal | ask | auto | yolo
|
|
62
|
+
client.merge(agent_id); print(client.diff(agent_id))
|
|
63
|
+
client.query_events(types=["tool.*"], limit=50)
|
|
64
|
+
client.call("runs.list", {"limit": 5}) # anything else, by method name
|
|
65
|
+
client.shutdown() # stop the daemon
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Errors raise `baton.BatonError`, which carries `.code`, `.message` and `.data`. A closed connection
|
|
69
|
+
raises `baton.ConnectionClosed`, and a `timeout=` on `call()` raises `baton.BatonTimeoutError`, which
|
|
70
|
+
is also a `TimeoutError`.
|
|
71
|
+
|
|
72
|
+
## Events
|
|
73
|
+
|
|
74
|
+
Every event is a dict `{id, seq, ts, runId, agentId?, type, data}` (see the BEP table in
|
|
75
|
+
[docs/sdk.md](../docs/sdk.md)).
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# iterator: subscribes immediately; breaking out of the loop unsubscribes; ends when the connection closes
|
|
79
|
+
for e in client.events(types=["tool.*", "bus.message"], from_seq=0):
|
|
80
|
+
print(e["seq"], e["type"], e["data"])
|
|
81
|
+
|
|
82
|
+
# callback on a worker thread (it may call the client)
|
|
83
|
+
def approve_reads(e):
|
|
84
|
+
d = e["data"]
|
|
85
|
+
if d.get("kind") == "read":
|
|
86
|
+
allow = next(o["optionId"] for o in d["options"] if o["kind"] == "allow_once")
|
|
87
|
+
client.respond(e["agentId"], d["requestId"], allow)
|
|
88
|
+
|
|
89
|
+
sub = client.on_event(approve_reads, types=["permission.requested"])
|
|
90
|
+
...
|
|
91
|
+
sub.close()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`events(timeout=…)` raises `TimeoutError` when nothing arrives for that long. `client.subscribe(handler)`
|
|
95
|
+
is the low-level form. Its handler runs on the socket reader thread, so it must be fast and must not
|
|
96
|
+
call the client.
|
|
97
|
+
|
|
98
|
+
## Run state
|
|
99
|
+
|
|
100
|
+
`client.run_state()` replays the whole run, then follows it live, and gives you:
|
|
101
|
+
|
|
102
|
+
- `agents`: `{agentId: Agent}`, where each `Agent` has `name adapter status label role model cost usage diff tool_calls turns last_message busy alive`
|
|
103
|
+
- `approvals`: `{requestId: Approval}`
|
|
104
|
+
- `tasks`, `workspaces`, `bus`, `autonomy`, `mode`, `totals`, `last_seq`
|
|
105
|
+
- `wait_for(predicate, timeout)`, `wait_idle(timeout, since_seq=None, settle=0.3)`, `is_idle()`, `find_agent(name_or_id)`
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
run = client.run_state()
|
|
109
|
+
coder = run.wait_for(lambda s: s.find_agent("codex-1"), timeout=30)
|
|
110
|
+
run.wait_for(lambda s: not s.approvals and coder.status == "ready", timeout=600)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Pass `since_seq=run.last_seq` (captured before you submit work) to `wait_idle` so it does not return
|
|
114
|
+
before the work starts. `settle` requires the run to stay quiet for that long, which covers short idle
|
|
115
|
+
gaps when agents hand off to each other.
|
|
116
|
+
|
|
117
|
+
## Examples
|
|
118
|
+
|
|
119
|
+
- `examples/watch.py` prints a live one-line-per-event feed (`--replay`, `--types 'tool.*'`, `--agent NAME`).
|
|
120
|
+
- `examples/run_task.py "task" --approve reads` submits a task, waits until every agent is idle, and prints cost, per-agent changes and the files touched.
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
python3 -m unittest discover -s sdk-python/tests -v
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The integration tests start a throwaway daemon with `bun packages/cli/src/main.ts daemon` in a temporary
|
|
129
|
+
git repo, using mock agents and temporary `BATON_CONFIG_DIR`/`BATON_STATE_DIR`. They are skipped when `bun`
|
|
130
|
+
is not installed.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Python SDK for Baton — drive the multi-agent daemon (batond) from scripts.
|
|
2
|
+
|
|
3
|
+
import baton
|
|
4
|
+
|
|
5
|
+
with baton.connect() as client: # starts the project daemon if needed
|
|
6
|
+
run = client.run_state()
|
|
7
|
+
seq = run.last_seq
|
|
8
|
+
client.submit("fix the flaky test in tests/api")
|
|
9
|
+
run.wait_idle(timeout=600, since_seq=seq)
|
|
10
|
+
for a in run.agents.values():
|
|
11
|
+
print(a.name, a.status, a.diff, "$%.2f" % a.cost)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._discovery import discover_socket, find_project_root, project_paths
|
|
15
|
+
from .client import (
|
|
16
|
+
CONTROL_PROTOCOL_VERSION,
|
|
17
|
+
BatonError,
|
|
18
|
+
BatonTimeoutError,
|
|
19
|
+
Client,
|
|
20
|
+
Conflict,
|
|
21
|
+
ConnectionClosed,
|
|
22
|
+
InvalidParams,
|
|
23
|
+
NotFound,
|
|
24
|
+
Subscription,
|
|
25
|
+
Unavailable,
|
|
26
|
+
VersionMismatch,
|
|
27
|
+
connect,
|
|
28
|
+
)
|
|
29
|
+
from .run import BUSY_STATUSES, Agent, Approval, RunState, Workspace
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"CONTROL_PROTOCOL_VERSION",
|
|
35
|
+
"BUSY_STATUSES",
|
|
36
|
+
"Agent",
|
|
37
|
+
"Approval",
|
|
38
|
+
"BatonError",
|
|
39
|
+
"BatonTimeoutError",
|
|
40
|
+
"Client",
|
|
41
|
+
"ConnectionClosed",
|
|
42
|
+
"Conflict",
|
|
43
|
+
"InvalidParams",
|
|
44
|
+
"NotFound",
|
|
45
|
+
"Unavailable",
|
|
46
|
+
"VersionMismatch",
|
|
47
|
+
"RunState",
|
|
48
|
+
"Subscription",
|
|
49
|
+
"Workspace",
|
|
50
|
+
"connect",
|
|
51
|
+
"discover_socket",
|
|
52
|
+
"find_project_root",
|
|
53
|
+
"project_paths",
|
|
54
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Project root and socket discovery (mirrors @batonsh/kernel findProjectRoot / projectPaths)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_project_root(start: Optional[str] = None) -> str:
|
|
8
|
+
"""Walk up from ``start`` looking for ``.baton/config.toml``, then ``.git``; falls back to ``start``."""
|
|
9
|
+
begin = os.path.abspath(start if start is not None else os.getcwd())
|
|
10
|
+
d = begin
|
|
11
|
+
git_root = None
|
|
12
|
+
while True:
|
|
13
|
+
if os.path.exists(os.path.join(d, ".baton", "config.toml")):
|
|
14
|
+
return d
|
|
15
|
+
if git_root is None and os.path.exists(os.path.join(d, ".git")):
|
|
16
|
+
git_root = d
|
|
17
|
+
parent = os.path.dirname(d)
|
|
18
|
+
if parent == d:
|
|
19
|
+
break
|
|
20
|
+
d = parent
|
|
21
|
+
return git_root if git_root is not None else begin
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def project_paths(root: str) -> Dict[str, str]:
|
|
25
|
+
d = os.path.join(root, ".baton")
|
|
26
|
+
run = os.path.join(d, "run")
|
|
27
|
+
return {
|
|
28
|
+
"dir": d,
|
|
29
|
+
"config": os.path.join(d, "config.toml"),
|
|
30
|
+
"state": os.path.join(d, "state.db"),
|
|
31
|
+
"run": run,
|
|
32
|
+
"socket": os.path.join(run, "baton.sock"),
|
|
33
|
+
"pid_file": os.path.join(run, "batond.pid"),
|
|
34
|
+
"log": os.path.join(run, "batond.log"),
|
|
35
|
+
"worktrees": os.path.join(d, "worktrees"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def discover_socket(project_root: str) -> str:
|
|
40
|
+
"""The daemon's socket: ``.baton/run/socket-path`` (long project paths) or ``.baton/run/baton.sock``."""
|
|
41
|
+
paths = project_paths(project_root)
|
|
42
|
+
alt = os.path.join(paths["run"], "socket-path")
|
|
43
|
+
try:
|
|
44
|
+
with open(alt, "r", encoding="utf-8") as f:
|
|
45
|
+
p = f.read().strip()
|
|
46
|
+
if p:
|
|
47
|
+
return p
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
return paths["socket"]
|