baserun-cli 0.1.0__py3-none-any.whl
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.
- baserun_cli/__init__.py +0 -0
- baserun_cli/_vendored/__init__.py +28 -0
- baserun_cli/_vendored/base.py +218 -0
- baserun_cli/_vendored/cli.py +332 -0
- baserun_cli/_vendored/parsers/__init__.py +35 -0
- baserun_cli/_vendored/parsers/base.py +47 -0
- baserun_cli/_vendored/parsers/bash_agent.py +83 -0
- baserun_cli/_vendored/parsers/claude.py +97 -0
- baserun_cli/_vendored/parsers/codex.py +202 -0
- baserun_cli/channel.py +350 -0
- baserun_cli/main.py +98 -0
- baserun_cli/runner.py +319 -0
- baserun_cli-0.1.0.dist-info/METADATA +47 -0
- baserun_cli-0.1.0.dist-info/RECORD +17 -0
- baserun_cli-0.1.0.dist-info/WHEEL +5 -0
- baserun_cli-0.1.0.dist-info/entry_points.txt +2 -0
- baserun_cli-0.1.0.dist-info/top_level.txt +1 -0
baserun_cli/runner.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Task runner: receives a task envelope, drives the CLIAgentConnector, and
|
|
2
|
+
publishes parsed events back to the run:{run_id} channel.
|
|
3
|
+
|
|
4
|
+
Translates the vendored ConnectorEvent stream into the wire protocol:
|
|
5
|
+
|
|
6
|
+
{"type":"result","data":{"run_id":"...","seq":N,"finished":false,
|
|
7
|
+
"kind":"session|thinking|tool_call|tool_result|message|final|error|usage",
|
|
8
|
+
"content":{...}}}
|
|
9
|
+
|
|
10
|
+
Terminal frame: kind=="final", finished==true.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._vendored import (
|
|
22
|
+
CLIAgentConnector,
|
|
23
|
+
ConnectorEvent,
|
|
24
|
+
ConnectorEventType,
|
|
25
|
+
get_connector,
|
|
26
|
+
)
|
|
27
|
+
from .channel import ChannelClient
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# connector event type → wire kind
|
|
32
|
+
_KIND_MAP = {
|
|
33
|
+
ConnectorEventType.SESSION: "session",
|
|
34
|
+
ConnectorEventType.THINKING: "thinking",
|
|
35
|
+
ConnectorEventType.TOOL_CALL: "tool_call",
|
|
36
|
+
ConnectorEventType.TOOL_RESULT: "tool_result",
|
|
37
|
+
ConnectorEventType.MESSAGE: "message",
|
|
38
|
+
ConnectorEventType.FINAL: "final",
|
|
39
|
+
ConnectorEventType.ERROR: "error",
|
|
40
|
+
ConnectorEventType.USAGE: "usage",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# run_id dedup persistence — survives process restart
|
|
44
|
+
_RECENT_TTL = 86400 # 24h
|
|
45
|
+
_MAX_RECENT = 50 # max entries (oldest trimmed)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _state_file(app_id: str) -> str:
|
|
49
|
+
"""Per-agent state file: ~/.lark-agent-hub-runs-{app_id}.json"""
|
|
50
|
+
safe_id = app_id.replace("/", "_")
|
|
51
|
+
return os.path.join(os.path.expanduser("~"), f".lark-agent-hub-runs-{safe_id}.json")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# State entry: {"ts": float, "status": "done"|"error", "seq": int}
|
|
55
|
+
# - ts: completion timestamp (for TTL expiry + ordering)
|
|
56
|
+
# - status: terminal kind ("done" = final, "error" = error frame)
|
|
57
|
+
# - seq: last published event seq (ordering, similar to nchan message_id)
|
|
58
|
+
RecentEntry = dict[str, Any] # {"ts": float, "status": str, "seq": int}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_recent(app_id: str) -> dict[str, RecentEntry]:
|
|
62
|
+
path = _state_file(app_id)
|
|
63
|
+
try:
|
|
64
|
+
with open(path) as f:
|
|
65
|
+
data = json.load(f)
|
|
66
|
+
now = time.time()
|
|
67
|
+
# filter expired + migrate old format ({rid: float} → {rid: {ts, status, seq}})
|
|
68
|
+
result: dict[str, RecentEntry] = {}
|
|
69
|
+
for rid, val in data.items():
|
|
70
|
+
if isinstance(val, (int, float)):
|
|
71
|
+
# old format: {rid: timestamp}
|
|
72
|
+
ts = float(val)
|
|
73
|
+
elif isinstance(val, dict):
|
|
74
|
+
ts = float(val.get("ts", 0))
|
|
75
|
+
else:
|
|
76
|
+
continue
|
|
77
|
+
if now - ts < _RECENT_TTL:
|
|
78
|
+
if isinstance(val, dict):
|
|
79
|
+
result[rid] = val
|
|
80
|
+
else:
|
|
81
|
+
result[rid] = {"ts": ts, "status": "done", "seq": 0}
|
|
82
|
+
return result
|
|
83
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _save_recent(app_id: str, recent: dict[str, RecentEntry]) -> None:
|
|
88
|
+
path = _state_file(app_id)
|
|
89
|
+
# trim to max entries (keep most recent by ts)
|
|
90
|
+
if len(recent) > _MAX_RECENT:
|
|
91
|
+
sorted_items = sorted(recent.items(), key=lambda x: x[1].get("ts", 0), reverse=True)
|
|
92
|
+
recent = dict(sorted_items[:_MAX_RECENT])
|
|
93
|
+
try:
|
|
94
|
+
with open(path, "w") as f:
|
|
95
|
+
json.dump(recent, f)
|
|
96
|
+
except OSError as e:
|
|
97
|
+
log.warning("failed to persist run history: %s", e)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TaskRunner:
|
|
101
|
+
def __init__(self, channel: ChannelClient, concurrency: int = 1, app_id: str = "") -> None:
|
|
102
|
+
self.channel = channel
|
|
103
|
+
self._sem = asyncio.Semaphore(max(1, concurrency))
|
|
104
|
+
self._active_runs: set[str] = set()
|
|
105
|
+
self._app_id = app_id or getattr(channel, "app_id", "")
|
|
106
|
+
self._recent_runs: dict[str, RecentEntry] = _load_recent(self._app_id)
|
|
107
|
+
if self._recent_runs:
|
|
108
|
+
log.info("loaded %d recent run_ids from %s", len(self._recent_runs), _state_file(self._app_id))
|
|
109
|
+
|
|
110
|
+
def _cleanup_recent(self) -> None:
|
|
111
|
+
now = time.time()
|
|
112
|
+
expired = [rid for rid, entry in self._recent_runs.items()
|
|
113
|
+
if now - entry.get("ts", 0) > _RECENT_TTL]
|
|
114
|
+
for rid in expired:
|
|
115
|
+
del self._recent_runs[rid]
|
|
116
|
+
if expired:
|
|
117
|
+
_save_recent(self._app_id, self._recent_runs)
|
|
118
|
+
|
|
119
|
+
async def handle(self, envelope: dict[str, Any]) -> None:
|
|
120
|
+
"""envelope = {"type":"task","data":{run_id, mode, prompt, ...}}"""
|
|
121
|
+
data = envelope.get("data") or envelope
|
|
122
|
+
run_id = data.get("run_id", "")
|
|
123
|
+
|
|
124
|
+
self._cleanup_recent()
|
|
125
|
+
|
|
126
|
+
if run_id in self._active_runs:
|
|
127
|
+
log.warning("duplicate task for run %s (already running), skipping", run_id)
|
|
128
|
+
return
|
|
129
|
+
if run_id in self._recent_runs:
|
|
130
|
+
log.warning("duplicate task for run %s (recently completed), skipping", run_id)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
self._active_runs.add(run_id)
|
|
134
|
+
terminal_published = False
|
|
135
|
+
try:
|
|
136
|
+
async with self._sem:
|
|
137
|
+
terminal_published, status, last_seq = await self._run_task(data)
|
|
138
|
+
finally:
|
|
139
|
+
self._active_runs.discard(run_id)
|
|
140
|
+
# 只在 terminal 确实发布后才记入去重表
|
|
141
|
+
if terminal_published:
|
|
142
|
+
self._recent_runs[run_id] = {
|
|
143
|
+
"ts": time.time(),
|
|
144
|
+
"status": status,
|
|
145
|
+
"seq": last_seq,
|
|
146
|
+
}
|
|
147
|
+
_save_recent(self._app_id, self._recent_runs)
|
|
148
|
+
|
|
149
|
+
async def _run_task(self, data: dict[str, Any]) -> tuple[bool, str, int]:
|
|
150
|
+
"""Execute a run. Returns (terminal_published, status, last_seq).
|
|
151
|
+
|
|
152
|
+
Events are persisted to a local JSONL file as they are produced.
|
|
153
|
+
After the connector finishes, any events that failed to publish
|
|
154
|
+
via WS are retried via HTTP to guarantee delivery.
|
|
155
|
+
"""
|
|
156
|
+
run_id = data["run_id"]
|
|
157
|
+
mode = data.get("mode", "new")
|
|
158
|
+
prompt = data["prompt"]
|
|
159
|
+
agent_session_id = data.get("agent_session_id")
|
|
160
|
+
connector_type = data.get("connector_type", "claude_code")
|
|
161
|
+
config = data.get("config") or {}
|
|
162
|
+
|
|
163
|
+
connector = get_connector(connector_type, config)
|
|
164
|
+
log.info("run %s mode=%s connector=%s session_id=%s", run_id, mode, connector.spec.bin, agent_session_id or "new")
|
|
165
|
+
|
|
166
|
+
events_iter = self._select_events(connector, mode, prompt, agent_session_id)
|
|
167
|
+
|
|
168
|
+
# pre-connect WS publisher (don't wait for first event — avoids 10s+ delay
|
|
169
|
+
# where early events accumulate and arrive as a burst, killing streaming UX)
|
|
170
|
+
asyncio.create_task(self.channel.ensure_run_pub(run_id))
|
|
171
|
+
|
|
172
|
+
# local persistence: raw event log for debugging + retry
|
|
173
|
+
log_dir = os.path.join(os.path.expanduser("~"), ".lark-agent-hub-logs")
|
|
174
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
175
|
+
log_path = os.path.join(log_dir, f"{run_id}.jsonl")
|
|
176
|
+
|
|
177
|
+
seq = 0
|
|
178
|
+
saw_terminal = False
|
|
179
|
+
terminal_status = "done"
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with open(log_path, "w") as log_file:
|
|
183
|
+
async for ev in events_iter:
|
|
184
|
+
seq += 1
|
|
185
|
+
kind = _KIND_MAP.get(ev.type, "message")
|
|
186
|
+
is_terminal = ev.type in (ConnectorEventType.FINAL, ConnectorEventType.ERROR)
|
|
187
|
+
|
|
188
|
+
# build wire payload
|
|
189
|
+
payload = {
|
|
190
|
+
"type": "result",
|
|
191
|
+
"data": {
|
|
192
|
+
"run_id": run_id,
|
|
193
|
+
"seq": seq,
|
|
194
|
+
"finished": is_terminal,
|
|
195
|
+
"kind": kind,
|
|
196
|
+
"content": ev.payload,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# persist locally
|
|
201
|
+
log_file.write(json.dumps(payload["data"], ensure_ascii=False) + "\n")
|
|
202
|
+
log_file.flush()
|
|
203
|
+
|
|
204
|
+
log.debug("run %s event seq=%d kind=%s", run_id, seq, kind)
|
|
205
|
+
|
|
206
|
+
# publish
|
|
207
|
+
await self.channel.publish_event(run_id, payload)
|
|
208
|
+
|
|
209
|
+
if is_terminal:
|
|
210
|
+
saw_terminal = True
|
|
211
|
+
terminal_status = kind
|
|
212
|
+
log.info("run %s terminal event (kind=%s, seq=%d)", run_id, kind, seq)
|
|
213
|
+
|
|
214
|
+
if not saw_terminal:
|
|
215
|
+
seq += 1
|
|
216
|
+
payload = {
|
|
217
|
+
"type": "result",
|
|
218
|
+
"data": {
|
|
219
|
+
"run_id": run_id,
|
|
220
|
+
"seq": seq,
|
|
221
|
+
"finished": True,
|
|
222
|
+
"kind": "final",
|
|
223
|
+
"content": {},
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
log_file.write(json.dumps(payload["data"], ensure_ascii=False) + "\n")
|
|
227
|
+
log_file.flush()
|
|
228
|
+
ok = await self.channel.publish_event(run_id, payload)
|
|
229
|
+
if not ok:
|
|
230
|
+
failed_events.append(payload)
|
|
231
|
+
log.info("run %s synthesized terminal (seq=%d)", run_id, seq)
|
|
232
|
+
|
|
233
|
+
# verify-and-replay:对比 nchan 实际状态,精确补发缺失事件
|
|
234
|
+
all_local = []
|
|
235
|
+
try:
|
|
236
|
+
with open(log_path) as f:
|
|
237
|
+
for line in f:
|
|
238
|
+
line = line.strip()
|
|
239
|
+
if line:
|
|
240
|
+
all_local.append({"type": "result", "data": json.loads(line)})
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
if all_local:
|
|
245
|
+
await self.channel.verify_and_replay(run_id, all_local)
|
|
246
|
+
|
|
247
|
+
log.info("run %s completed (%d events, log: %s)", run_id, seq, log_path)
|
|
248
|
+
return True, terminal_status, seq
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
log.exception("run %s failed at seq=%d", run_id, seq)
|
|
252
|
+
# publish error terminal via HTTP (reliable)
|
|
253
|
+
seq += 1
|
|
254
|
+
error_payload = {
|
|
255
|
+
"type": "result",
|
|
256
|
+
"data": {
|
|
257
|
+
"run_id": run_id,
|
|
258
|
+
"seq": seq,
|
|
259
|
+
"finished": True,
|
|
260
|
+
"kind": "error",
|
|
261
|
+
"content": {"message": str(e)},
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
try:
|
|
265
|
+
with open(log_path, "a") as log_file:
|
|
266
|
+
log_file.write(json.dumps(error_payload["data"], ensure_ascii=False) + "\n")
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
await self.channel.publish_event(run_id, error_payload)
|
|
270
|
+
return True, "error", seq
|
|
271
|
+
|
|
272
|
+
finally:
|
|
273
|
+
await self.channel.close_run_pub(run_id)
|
|
274
|
+
|
|
275
|
+
def _select_events(
|
|
276
|
+
self,
|
|
277
|
+
connector: CLIAgentConnector,
|
|
278
|
+
mode: str,
|
|
279
|
+
prompt: str,
|
|
280
|
+
agent_session_id: str | None,
|
|
281
|
+
):
|
|
282
|
+
if mode == "resume" and agent_session_id:
|
|
283
|
+
return connector.resume(agent_session_id, prompt)
|
|
284
|
+
if mode == "fork" and agent_session_id:
|
|
285
|
+
return connector.fork(agent_session_id, prompt)
|
|
286
|
+
return connector.new_session(prompt)
|
|
287
|
+
|
|
288
|
+
async def _publish_event(
|
|
289
|
+
self, run_id: str, seq: int, ev: ConnectorEvent, finished: bool
|
|
290
|
+
) -> None:
|
|
291
|
+
kind = _KIND_MAP.get(ev.type, "message")
|
|
292
|
+
await self.channel.publish_event(
|
|
293
|
+
run_id,
|
|
294
|
+
{
|
|
295
|
+
"type": "result",
|
|
296
|
+
"data": {
|
|
297
|
+
"run_id": run_id,
|
|
298
|
+
"seq": seq,
|
|
299
|
+
"finished": finished,
|
|
300
|
+
"kind": kind,
|
|
301
|
+
"content": ev.payload,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def _publish_terminal(self, run_id: str, seq: int) -> None:
|
|
307
|
+
await self.channel.publish_event(
|
|
308
|
+
run_id,
|
|
309
|
+
{
|
|
310
|
+
"type": "result",
|
|
311
|
+
"data": {
|
|
312
|
+
"run_id": run_id,
|
|
313
|
+
"seq": seq,
|
|
314
|
+
"finished": True,
|
|
315
|
+
"kind": "final",
|
|
316
|
+
"content": {},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: baserun-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BaseRun agent-side daemon (connects to nchan, spawns CLI agents, publishes run events)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: websockets>=13.0
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
|
|
10
|
+
# baserun-cli
|
|
11
|
+
|
|
12
|
+
BaseRun agent-side daemon. It connects to the BaseRun nchan channel, runs local CLI agents, and publishes run events back to BaseRun.
|
|
13
|
+
|
|
14
|
+
## Run
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
NCHAN_URL="wss://baserun.livesig.cn/nchan" \
|
|
18
|
+
AGENT_APP_ID="<agent_id>" \
|
|
19
|
+
AGENT_APP_SECRET="<agent_secret>" \
|
|
20
|
+
CONNECTOR_TYPE="claude_code" \
|
|
21
|
+
uvx baserun-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`CONNECTOR_TYPE` defaults to `claude_code`.
|
|
25
|
+
|
|
26
|
+
## Build
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd baserun-cli
|
|
30
|
+
rm -rf dist build *.egg-info
|
|
31
|
+
python3 -m build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Publish
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd baserun-cli
|
|
38
|
+
python3 -m twine upload dist/*
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with uv:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd baserun-cli
|
|
45
|
+
uv build
|
|
46
|
+
uv publish
|
|
47
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
baserun_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
baserun_cli/channel.py,sha256=zExZ6FhhnW5Q_XCEcPfoGexarMTss0V-yjRODL78egs,14706
|
|
3
|
+
baserun_cli/main.py,sha256=gwF0UWym75L0m6vtKH0rigDsxfQcG5aODcw08yTMv6o,3247
|
|
4
|
+
baserun_cli/runner.py,sha256=1XPA2Kg7RKmVkcnb9uEY1g8VZSyptI1hGsupMa2ImAY,11889
|
|
5
|
+
baserun_cli/_vendored/__init__.py,sha256=p7Gs6VZV1iCsdT4rUYAVGHtEdFDryf0bKmSQ8n2T66w,714
|
|
6
|
+
baserun_cli/_vendored/base.py,sha256=R1MW9Ele_RVM6RNRzRsJ2YytVTex4u0IEKQ5k6QPlx8,9349
|
|
7
|
+
baserun_cli/_vendored/cli.py,sha256=uM-PcCibLFG4JFUa0p84V3HJ7TMR8rXDG1dCT9mJQcc,14484
|
|
8
|
+
baserun_cli/_vendored/parsers/__init__.py,sha256=EfwdkaVGDooMD6Z_oToY2g80TTl-luHy8KraMmU2QJs,1235
|
|
9
|
+
baserun_cli/_vendored/parsers/base.py,sha256=zLlWiGyqaHIfCkMQQzb3P1HmM0UjIB2oYtidYERzx9g,1636
|
|
10
|
+
baserun_cli/_vendored/parsers/bash_agent.py,sha256=KoQ2rh7L646I2KYL2uW5kbjju_G5kmsjK0mEx9ov3Wk,3055
|
|
11
|
+
baserun_cli/_vendored/parsers/claude.py,sha256=TiSrIto_2NgkRGwiMIEx7InGdod3XOlVTiA-hRjNxRo,4102
|
|
12
|
+
baserun_cli/_vendored/parsers/codex.py,sha256=F4mU1w5kzvX53znxaAdKgsT2ooOdT8U9FQq025lKp6c,7065
|
|
13
|
+
baserun_cli-0.1.0.dist-info/METADATA,sha256=CmWiQFcvK2jK_tsL4nLWOPoKtPwdrZCoKTfxxrFQ6vc,864
|
|
14
|
+
baserun_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
baserun_cli-0.1.0.dist-info/entry_points.txt,sha256=FCe2sionsoqBvcQwHFbs7A3g23wbS0OUXIUPKxgPfXk,59
|
|
16
|
+
baserun_cli-0.1.0.dist-info/top_level.txt,sha256=sG8tqnc3iyQdtUhIVjvYGxo9cmDLsuwnMU7tlCCteGw,12
|
|
17
|
+
baserun_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
baserun_cli
|