baserun-cli 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.
- baserun_cli-0.1.0/PKG-INFO +47 -0
- baserun_cli-0.1.0/README.md +38 -0
- baserun_cli-0.1.0/baserun_cli/__init__.py +0 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/__init__.py +28 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/base.py +218 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/cli.py +332 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/parsers/__init__.py +35 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/parsers/base.py +47 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/parsers/bash_agent.py +83 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/parsers/claude.py +97 -0
- baserun_cli-0.1.0/baserun_cli/_vendored/parsers/codex.py +202 -0
- baserun_cli-0.1.0/baserun_cli/channel.py +350 -0
- baserun_cli-0.1.0/baserun_cli/main.py +98 -0
- baserun_cli-0.1.0/baserun_cli/runner.py +319 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/PKG-INFO +47 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/SOURCES.txt +20 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/dependency_links.txt +1 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/entry_points.txt +2 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/requires.txt +2 -0
- baserun_cli-0.1.0/baserun_cli.egg-info/top_level.txt +1 -0
- baserun_cli-0.1.0/pyproject.toml +20 -0
- baserun_cli-0.1.0/setup.cfg +4 -0
|
@@ -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,38 @@
|
|
|
1
|
+
# baserun-cli
|
|
2
|
+
|
|
3
|
+
BaseRun agent-side daemon. It connects to the BaseRun nchan channel, runs local CLI agents, and publishes run events back to BaseRun.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
NCHAN_URL="wss://baserun.livesig.cn/nchan" \
|
|
9
|
+
AGENT_APP_ID="<agent_id>" \
|
|
10
|
+
AGENT_APP_SECRET="<agent_secret>" \
|
|
11
|
+
CONNECTOR_TYPE="claude_code" \
|
|
12
|
+
uvx baserun-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`CONNECTOR_TYPE` defaults to `claude_code`.
|
|
16
|
+
|
|
17
|
+
## Build
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd baserun-cli
|
|
21
|
+
rm -rf dist build *.egg-info
|
|
22
|
+
python3 -m build
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Publish
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd baserun-cli
|
|
29
|
+
python3 -m twine upload dist/*
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or with uv:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd baserun-cli
|
|
36
|
+
uv build
|
|
37
|
+
uv publish
|
|
38
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Vendored copy of the server's connector layer (CLISpec + CLIAgentConnector + parsers).
|
|
2
|
+
|
|
3
|
+
Kept in sync with backend/app/connectors/{base,cli,parsers}.py. The agent client
|
|
4
|
+
needs the spawn+parse logic but not the server's other deps (DB, FastAPI, Feishu).
|
|
5
|
+
|
|
6
|
+
If these drift, prefer syncing FROM backend TO here.
|
|
7
|
+
"""
|
|
8
|
+
from .base import ( # noqa: F401
|
|
9
|
+
BUILTIN_SPECS,
|
|
10
|
+
CLISpec,
|
|
11
|
+
ConnectorEvent,
|
|
12
|
+
ConnectorEventType,
|
|
13
|
+
HealthResult,
|
|
14
|
+
get_connector,
|
|
15
|
+
resolve_spec,
|
|
16
|
+
)
|
|
17
|
+
from .cli import CLIAgentConnector # noqa: F401
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BUILTIN_SPECS",
|
|
21
|
+
"CLISpec",
|
|
22
|
+
"ConnectorEvent",
|
|
23
|
+
"ConnectorEventType",
|
|
24
|
+
"HealthResult",
|
|
25
|
+
"get_connector",
|
|
26
|
+
"resolve_spec",
|
|
27
|
+
"CLIAgentConnector",
|
|
28
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""AgentConnector abstraction.
|
|
2
|
+
|
|
3
|
+
A connector drives one kind of agent. The host spawns the agent process
|
|
4
|
+
on-demand; session state persists in the agent's own session store and is
|
|
5
|
+
addressed by `agent_session_id`.
|
|
6
|
+
|
|
7
|
+
CONNECTION CONTRACT. An agent is integrable as a spawn-mode connector iff its
|
|
8
|
+
CLI exposes:
|
|
9
|
+
1. one-shot headless execution: `<bin> <prompt_flag> "<input>"`
|
|
10
|
+
2. resume an existing session: `... <resume_flag> <session_id>`
|
|
11
|
+
3. structured streaming output: `... <output_format_flag>` (e.g. stream-json)
|
|
12
|
+
4. (optional) native fork: `... <fork_flag>` — copies parent history
|
|
13
|
+
into a new session id. If absent, the host
|
|
14
|
+
falls back to rebuild_path_history().
|
|
15
|
+
|
|
16
|
+
Claude Code and Codex both fit (Codex lacks #4). OpenClaw does NOT fit this
|
|
17
|
+
contract (its CLI is a gateway client + has no fork flag) — see README.
|
|
18
|
+
|
|
19
|
+
The contract is expressed as data (a CLISpec), not as code per agent. Adding a
|
|
20
|
+
new compatible agent = adding a CLISpec, no new class.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import enum
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, AsyncIterator
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConnectorEventType(str, enum.Enum):
|
|
30
|
+
SESSION = "session" # {agent_session_id, mode: new|resume|fork|fallback}
|
|
31
|
+
THINKING = "thinking" # {delta}
|
|
32
|
+
TOOL_CALL = "tool_call" # {tool, args, call_id}
|
|
33
|
+
TOOL_RESULT = "tool_result" # {call_id, result}
|
|
34
|
+
MESSAGE = "message" # {delta}
|
|
35
|
+
FINAL = "final" # {text, session_id}
|
|
36
|
+
ERROR = "error" # {message}
|
|
37
|
+
USAGE = "usage" # {input, output, total}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ConnectorEvent:
|
|
42
|
+
type: ConnectorEventType
|
|
43
|
+
payload: dict[str, Any]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class HealthResult:
|
|
48
|
+
ok: bool
|
|
49
|
+
detail: str = ""
|
|
50
|
+
status: str = "online" # short label for the Bitable status field
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ForkShape:
|
|
55
|
+
"""How a native fork is expressed on the CLI.
|
|
56
|
+
|
|
57
|
+
Three shapes cover the real CLIs:
|
|
58
|
+
- flag_overlay (Claude Code): resume + an extra flag, single command.
|
|
59
|
+
`claude -p "<p>" --resume <sid> --fork-session` → streams jsonl
|
|
60
|
+
- two_step (Codex): step 1 creates a forked session (returns its id),
|
|
61
|
+
step 2 resumes into it headless. Lets a TUI-only `fork` subcommand
|
|
62
|
+
still achieve headless native fork via `exec resume`.
|
|
63
|
+
step1: `codex fork <sid>` → new session id (from output)
|
|
64
|
+
step2: `codex exec resume <new> -p` → streams jsonl
|
|
65
|
+
- subcommand: a single subcommand taking sid+prompt positionally.
|
|
66
|
+
`codex fork <sid> "<p>"` (headless variant, if it ever ships)
|
|
67
|
+
|
|
68
|
+
`headless` indicates whether this fork form can run non-interactively AND
|
|
69
|
+
emit the streaming structured output our subprocess parser expects. If
|
|
70
|
+
False, the host treats native_fork as unavailable and falls back to rebuild.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
shape: str = "flag_overlay" # "flag_overlay" | "two_step" | "subcommand"
|
|
74
|
+
# flag_overlay: the extra flag appended after --resume <sid>
|
|
75
|
+
flag: str = "--fork-session"
|
|
76
|
+
# subcommand: the subcommand name (e.g. "fork"); sid+prompt are positional
|
|
77
|
+
subcommand: str = "fork"
|
|
78
|
+
# two_step fields:
|
|
79
|
+
# step1 command template (bin is prepended). Use {sid} placeholder.
|
|
80
|
+
# e.g. "fork {sid}" → `<bin> fork <sid>`
|
|
81
|
+
fork_cmd: str = "fork {sid}"
|
|
82
|
+
# step2 command template: how to resume into the new session headless.
|
|
83
|
+
# Use {sid} (the NEW session id from step1) and {prompt} placeholders.
|
|
84
|
+
# e.g. "exec resume {sid} {prompt}" → `<bin> exec resume <new> "<p>"`
|
|
85
|
+
resume_cmd: str = "exec resume {sid} {prompt}"
|
|
86
|
+
# regex to extract the new session id from step1's combined stdout/stderr.
|
|
87
|
+
# Matches the first UUID found by default.
|
|
88
|
+
id_pattern: str = r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"
|
|
89
|
+
# whether this fork form works headless + structured-output (our requirement)
|
|
90
|
+
headless: bool = True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class CLISpec:
|
|
95
|
+
"""The connection contract as data. Drives a single generic connector.
|
|
96
|
+
|
|
97
|
+
Fields map to CLI flags/behaviour. Anything not declared is assumed absent
|
|
98
|
+
(e.g. fork=None → no native fork, host uses fallback rebuild).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# which output parser to use: "claude" | "codex" | ... (strategy selector)
|
|
102
|
+
output_schema: str = "claude"
|
|
103
|
+
# the executable name or path
|
|
104
|
+
bin: str = "claude"
|
|
105
|
+
# how to pass the prompt and turn on streaming structured output
|
|
106
|
+
prompt_flag: str = "-p"
|
|
107
|
+
output_format_flag: tuple[str, ...] = ("--output-format", "stream-json")
|
|
108
|
+
# extra flags always passed (e.g. --verbose for claude stream-json)
|
|
109
|
+
base_flags: list[str] = field(default_factory=lambda: ["--verbose"])
|
|
110
|
+
# resume: flag + whether it takes the session id as an argument
|
|
111
|
+
resume_flag: str | None = "--resume"
|
|
112
|
+
# resume via subcommand (e.g. codex: `exec resume <sid> <prompt>` instead of `exec <prompt> --resume <sid>`)
|
|
113
|
+
resume_subcommand: str | None = None
|
|
114
|
+
# native fork (optional). None → not supported, host falls back.
|
|
115
|
+
fork: ForkShape | None = None
|
|
116
|
+
# workdir / env injected into the subprocess
|
|
117
|
+
workdir: str | None = None
|
|
118
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def native_fork(self) -> bool:
|
|
122
|
+
"""True iff a headless native fork is available."""
|
|
123
|
+
return self.fork is not None and self.fork.headless
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------- specs
|
|
127
|
+
# Known agent specs. Keyed by the value stored in Agent.connector_type / config.
|
|
128
|
+
# Users can also supply a full CLISpec inline in Agent.config to add a new
|
|
129
|
+
# compatible agent without touching code here.
|
|
130
|
+
BUILTIN_SPECS: dict[str, CLISpec] = {
|
|
131
|
+
"claude_code": CLISpec(
|
|
132
|
+
output_schema="claude",
|
|
133
|
+
bin="claude",
|
|
134
|
+
prompt_flag="-p",
|
|
135
|
+
output_format_flag=("--output-format", "stream-json"),
|
|
136
|
+
base_flags=["--verbose", "--include-partial-messages", "--dangerously-skip-permissions"],
|
|
137
|
+
resume_flag="--resume",
|
|
138
|
+
fork=ForkShape(shape="flag_overlay", flag="--fork-session", headless=True),
|
|
139
|
+
),
|
|
140
|
+
"codex": CLISpec(
|
|
141
|
+
output_schema="codex",
|
|
142
|
+
bin="codex",
|
|
143
|
+
prompt_flag="exec", # `codex exec "<prompt>"`
|
|
144
|
+
output_format_flag=("--json",),
|
|
145
|
+
base_flags=["--full-auto"], # sandboxed auto-execution (like Claude's --dangerously-skip-permissions)
|
|
146
|
+
resume_flag=None, # Codex resume is a subcommand, not a flag
|
|
147
|
+
resume_subcommand="resume", # `codex exec resume <sid> <prompt> --json`
|
|
148
|
+
# codex fork is TUI-only (`codex fork`), cannot run headless.
|
|
149
|
+
# Host falls back to rebuild_path_history for fork.
|
|
150
|
+
fork=None,
|
|
151
|
+
),
|
|
152
|
+
"bash_agent": CLISpec(
|
|
153
|
+
output_schema="bash_agent",
|
|
154
|
+
bin="ccagent", # actual binary; can also be bash-agent, rustagent, etc.
|
|
155
|
+
prompt_flag="", # positional prompt (no flag prefix)
|
|
156
|
+
output_format_flag=("--output", "stream-json"),
|
|
157
|
+
base_flags=[],
|
|
158
|
+
resume_flag="--session",
|
|
159
|
+
fork=ForkShape(shape="flag_overlay", flag="--fork", headless=True),
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_spec(connector_type: str, config: dict[str, Any]) -> CLISpec:
|
|
165
|
+
"""Build a CLISpec from a builtin, overlaid with per-agent config overrides.
|
|
166
|
+
|
|
167
|
+
config may contain any CLISpec field to override the builtin (e.g. a custom
|
|
168
|
+
bin path, or workdir/env). It may also contain a full `cli_spec` dict to
|
|
169
|
+
define an entirely new agent not in BUILTIN_SPECS.
|
|
170
|
+
"""
|
|
171
|
+
spec = BUILTIN_SPECS.get(connector_type)
|
|
172
|
+
if spec is None:
|
|
173
|
+
# allow a brand-new agent defined entirely in config
|
|
174
|
+
raw = config.get("cli_spec") or {}
|
|
175
|
+
spec = CLISpec(output_schema=connector_type)
|
|
176
|
+
else:
|
|
177
|
+
from dataclasses import replace
|
|
178
|
+
|
|
179
|
+
spec = replace(spec) # copy so we don't mutate the builtin
|
|
180
|
+
|
|
181
|
+
# apply config overrides (env/workdir most common)
|
|
182
|
+
overrides = config.get("cli_spec") if "cli_spec" in config else config
|
|
183
|
+
for fld in ("bin", "prompt_flag", "resume_flag", "resume_subcommand", "fork", "workdir", "env", "base_flags", "output_schema"):
|
|
184
|
+
if fld in overrides:
|
|
185
|
+
setattr(spec, fld, overrides[fld])
|
|
186
|
+
return spec
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------ connector
|
|
190
|
+
class AgentConnector:
|
|
191
|
+
"""Generic spawn-mode connector driven by a CLISpec."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, spec: CLISpec, config: dict[str, Any] | None = None) -> None:
|
|
194
|
+
self.spec = spec
|
|
195
|
+
self.config = config or {}
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def capabilities(self) -> dict[str, bool]:
|
|
199
|
+
return {
|
|
200
|
+
"native_resume": self.spec.resume_flag is not None,
|
|
201
|
+
"native_fork": self.spec.native_fork,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# -------------------------------------------------------------- factories
|
|
205
|
+
@classmethod
|
|
206
|
+
def for_type(cls, connector_type: str, config: dict[str, Any]) -> "AgentConnector":
|
|
207
|
+
return cls(resolve_spec(connector_type, config), config)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_connector(connector_type: str, config: dict[str, Any]) -> "CLIAgentConnector":
|
|
211
|
+
"""Factory used by the rest of the system. Returns the generic CLI connector."""
|
|
212
|
+
from .cli import CLIAgentConnector
|
|
213
|
+
|
|
214
|
+
return CLIAgentConnector.for_type(connector_type, config)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# re-export the streaming protocol so callers can type-hint against it
|
|
218
|
+
AsyncEventIterator = AsyncIterator[ConnectorEvent]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Generic spawn-mode CLI connector, driven entirely by a CLISpec.
|
|
2
|
+
|
|
3
|
+
This single class replaces the previous per-agent ClaudeCodeConnector /
|
|
4
|
+
CodexConnector classes. All flag-assembly and process management is shared;
|
|
5
|
+
only the output-line parser is selected by CLISpec.output_schema (see parsers.py).
|
|
6
|
+
|
|
7
|
+
Adding a new compatible agent = adding a CLISpec (in base.BUILTIN_SPECS or
|
|
8
|
+
inline in Agent.config). No new class, no new file.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
from typing import AsyncIterator
|
|
17
|
+
|
|
18
|
+
from .base import (
|
|
19
|
+
AgentConnector,
|
|
20
|
+
ConnectorEvent,
|
|
21
|
+
ConnectorEventType,
|
|
22
|
+
HealthResult,
|
|
23
|
+
)
|
|
24
|
+
from .parsers import extract_session_id, parse_line
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CLIAgentConnector(AgentConnector):
|
|
28
|
+
"""The one connector implementation for all spec-compatible CLI agents."""
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------- health
|
|
31
|
+
async def health(self) -> HealthResult:
|
|
32
|
+
cli = shutil.which(self.spec.bin) or (
|
|
33
|
+
self.spec.bin if os.path.exists(self.spec.bin) else None
|
|
34
|
+
)
|
|
35
|
+
if not cli:
|
|
36
|
+
return HealthResult(
|
|
37
|
+
ok=False, status="offline", detail=f"CLI not found: {self.spec.bin}"
|
|
38
|
+
)
|
|
39
|
+
if self.spec.workdir and not os.path.isdir(self.spec.workdir):
|
|
40
|
+
return HealthResult(
|
|
41
|
+
ok=False, status="offline", detail=f"workdir missing: {self.spec.workdir}"
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
proc = await asyncio.create_subprocess_exec(
|
|
45
|
+
cli,
|
|
46
|
+
"--version",
|
|
47
|
+
stdout=asyncio.subprocess.PIPE,
|
|
48
|
+
stderr=asyncio.subprocess.PIPE,
|
|
49
|
+
env=self._env(),
|
|
50
|
+
cwd=self.spec.workdir,
|
|
51
|
+
)
|
|
52
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
53
|
+
return HealthResult(ok=True, status="online", detail=stdout.decode().strip())
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return HealthResult(ok=False, status="offline", detail=f"version check failed: {e}")
|
|
56
|
+
|
|
57
|
+
# ----------------------------------------------------------- process env
|
|
58
|
+
def _env(self) -> dict[str, str]:
|
|
59
|
+
env = dict(os.environ)
|
|
60
|
+
env.update(self.spec.env or {})
|
|
61
|
+
return env
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------- flag assembly
|
|
64
|
+
def _build_args(self, prompt: str, session_id: str | None, fork: bool) -> list[str]:
|
|
65
|
+
"""Assemble the CLI argv from the spec.
|
|
66
|
+
|
|
67
|
+
session_id=None, fork=False → new session
|
|
68
|
+
session_id set, fork=False → resume
|
|
69
|
+
session_id set, fork=True → native fork (requires spec.fork + headless)
|
|
70
|
+
"""
|
|
71
|
+
# subcommand fork shape (e.g. codex fork <sid> <prompt>) — sid+prompt positional
|
|
72
|
+
if fork and session_id and self.spec.fork and self.spec.fork.shape == "subcommand":
|
|
73
|
+
args = [self.spec.fork.subcommand, session_id, prompt]
|
|
74
|
+
args.extend(self.spec.output_format_flag)
|
|
75
|
+
args.extend(self.spec.base_flags)
|
|
76
|
+
return args
|
|
77
|
+
|
|
78
|
+
# resume via subcommand (e.g. codex: `exec resume <sid> <prompt> --json`)
|
|
79
|
+
if session_id and not fork and self.spec.resume_subcommand:
|
|
80
|
+
args = [self.spec.prompt_flag, self.spec.resume_subcommand, session_id, prompt]
|
|
81
|
+
args.extend(self.spec.output_format_flag)
|
|
82
|
+
args.extend(self.spec.base_flags)
|
|
83
|
+
return args
|
|
84
|
+
|
|
85
|
+
# default / flag_overlay shape (e.g. claude -p <p> --resume <sid> --fork-session)
|
|
86
|
+
if self.spec.prompt_flag:
|
|
87
|
+
args: list[str] = [self.spec.prompt_flag, prompt]
|
|
88
|
+
else:
|
|
89
|
+
# positional prompt (e.g. ccagent "prompt" --output stream-json)
|
|
90
|
+
args: list[str] = [prompt]
|
|
91
|
+
args.extend(self.spec.output_format_flag)
|
|
92
|
+
args.extend(self.spec.base_flags)
|
|
93
|
+
if session_id and self.spec.resume_flag:
|
|
94
|
+
args.extend([self.spec.resume_flag, session_id])
|
|
95
|
+
if fork and self.spec.fork and self.spec.fork.shape == "flag_overlay":
|
|
96
|
+
args.append(self.spec.fork.flag)
|
|
97
|
+
return args
|
|
98
|
+
|
|
99
|
+
async def _run(
|
|
100
|
+
self, prompt: str, session_id: str | None, fork: bool, mode_label: str
|
|
101
|
+
) -> AsyncIterator[ConnectorEvent]:
|
|
102
|
+
import logging as _log
|
|
103
|
+
_dbg = _log.getLogger("baserun_cli._vendored.cli")
|
|
104
|
+
cli = shutil.which(self.spec.bin) or (
|
|
105
|
+
self.spec.bin if os.path.exists(self.spec.bin) else self.spec.bin
|
|
106
|
+
)
|
|
107
|
+
args = self._build_args(prompt, session_id, fork)
|
|
108
|
+
_dbg.info("spawning: %s %s (cwd=%s)", cli, " ".join(args[:6]) + ("..." if len(args) > 6 else ""), self.spec.workdir)
|
|
109
|
+
|
|
110
|
+
proc = await asyncio.create_subprocess_exec(
|
|
111
|
+
cli,
|
|
112
|
+
*args,
|
|
113
|
+
stdout=asyncio.subprocess.PIPE,
|
|
114
|
+
stderr=asyncio.subprocess.PIPE,
|
|
115
|
+
env=self._env(),
|
|
116
|
+
cwd=self.spec.workdir,
|
|
117
|
+
)
|
|
118
|
+
_dbg.info("pid=%s started", proc.pid)
|
|
119
|
+
|
|
120
|
+
resolved_session_id: str | None = session_id
|
|
121
|
+
line_count = 0
|
|
122
|
+
# delta accumulation: consecutive thinking/message deltas are accumulated
|
|
123
|
+
# and flushed as a complete (non-delta) event when the type changes or EOF.
|
|
124
|
+
# Ensures JSONL persistence and finalize have complete text even for
|
|
125
|
+
# agents that only emit token-level deltas (e.g. ccagent).
|
|
126
|
+
acc: dict[str, str] = {"thinking": "", "message": ""}
|
|
127
|
+
|
|
128
|
+
def _flush_acc(ev_type: ConnectorEventType) -> list[ConnectorEvent]:
|
|
129
|
+
"""Flush accumulated text for the given type as a non-delta event."""
|
|
130
|
+
key = ev_type.value
|
|
131
|
+
text = acc.get(key, "")
|
|
132
|
+
if not text:
|
|
133
|
+
return []
|
|
134
|
+
acc[key] = ""
|
|
135
|
+
return [ConnectorEvent(ev_type, {"delta": text, "is_delta": False})]
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
assert proc.stdout is not None
|
|
139
|
+
async for raw_line in proc.stdout:
|
|
140
|
+
line = raw_line.decode(errors="replace").strip()
|
|
141
|
+
if not line:
|
|
142
|
+
continue
|
|
143
|
+
line_count += 1
|
|
144
|
+
try:
|
|
145
|
+
obj = json.loads(line)
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
_dbg.debug("pid=%s non-JSON line #%d: %.200s", proc.pid, line_count, line)
|
|
148
|
+
continue
|
|
149
|
+
_dbg.debug("pid=%s JSON line #%d: type=%s", proc.pid, line_count, obj.get("type", "?"))
|
|
150
|
+
# session id resolution (init line)
|
|
151
|
+
sid = extract_session_id(self.spec.output_schema, obj)
|
|
152
|
+
if sid and not resolved_session_id:
|
|
153
|
+
resolved_session_id = sid
|
|
154
|
+
_dbg.info("pid=%s session_id=%s", proc.pid, sid)
|
|
155
|
+
yield ConnectorEvent(
|
|
156
|
+
ConnectorEventType.SESSION,
|
|
157
|
+
{"agent_session_id": sid, "mode": mode_label},
|
|
158
|
+
)
|
|
159
|
+
# typed events
|
|
160
|
+
parsed = list(parse_line(self.spec.output_schema, obj))
|
|
161
|
+
_dbg.debug("pid=%s line #%d → %d event(s): %s", proc.pid, line_count, len(parsed), [e.type.value for e in parsed])
|
|
162
|
+
for ev in parsed:
|
|
163
|
+
# accumulate consecutive deltas; flush on type switch
|
|
164
|
+
if ev.type in (ConnectorEventType.THINKING, ConnectorEventType.MESSAGE) and ev.payload.get("is_delta"):
|
|
165
|
+
# type switch within deltas (e.g. thinking→message): flush the other type first
|
|
166
|
+
other = ConnectorEventType.MESSAGE if ev.type == ConnectorEventType.THINKING else ConnectorEventType.THINKING
|
|
167
|
+
for flushed in _flush_acc(other):
|
|
168
|
+
yield flushed
|
|
169
|
+
acc[ev.type.value] += ev.payload.get("delta", "")
|
|
170
|
+
yield ev # still yield delta for real-time streaming
|
|
171
|
+
else:
|
|
172
|
+
# non-delta event → flush both accumulators before yielding
|
|
173
|
+
for ft in (ConnectorEventType.THINKING, ConnectorEventType.MESSAGE):
|
|
174
|
+
for flushed in _flush_acc(ft):
|
|
175
|
+
yield flushed
|
|
176
|
+
yield ev
|
|
177
|
+
|
|
178
|
+
# EOF → flush remaining accumulated text
|
|
179
|
+
for ft in (ConnectorEventType.THINKING, ConnectorEventType.MESSAGE):
|
|
180
|
+
for flushed in _flush_acc(ft):
|
|
181
|
+
yield flushed
|
|
182
|
+
|
|
183
|
+
_dbg.info("pid=%s stdout EOF (total %d lines), waiting for exit...", proc.pid, line_count)
|
|
184
|
+
await proc.wait()
|
|
185
|
+
_dbg.info("pid=%s exited returncode=%s", proc.pid, proc.returncode)
|
|
186
|
+
if proc.returncode not in (0, None):
|
|
187
|
+
stderr = ""
|
|
188
|
+
if proc.stderr:
|
|
189
|
+
stderr = (await proc.stderr.read()).decode(errors="replace")
|
|
190
|
+
_dbg.warning("pid=%s stderr: %.500s", proc.pid, stderr)
|
|
191
|
+
yield ConnectorEvent(
|
|
192
|
+
ConnectorEventType.ERROR,
|
|
193
|
+
{"message": f"{self.spec.bin} exited {proc.returncode}: {stderr[:500]}"},
|
|
194
|
+
)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
_dbg.exception("pid=%s _run exception", proc.pid)
|
|
197
|
+
yield ConnectorEvent(ConnectorEventType.ERROR, {"message": str(e)})
|
|
198
|
+
finally:
|
|
199
|
+
if proc.returncode is None:
|
|
200
|
+
_dbg.warning("pid=%s still running, killing", proc.pid)
|
|
201
|
+
proc.kill()
|
|
202
|
+
await proc.wait()
|
|
203
|
+
|
|
204
|
+
# ----------------------------------------------------------- lifecycle
|
|
205
|
+
async def new_session(self, prompt: str) -> AsyncIterator[ConnectorEvent]:
|
|
206
|
+
async for ev in self._run(prompt, session_id=None, fork=False, mode_label="new"):
|
|
207
|
+
yield ev
|
|
208
|
+
|
|
209
|
+
async def resume(
|
|
210
|
+
self, agent_session_id: str, user_message: str
|
|
211
|
+
) -> AsyncIterator[ConnectorEvent]:
|
|
212
|
+
async for ev in self._run(
|
|
213
|
+
user_message, session_id=agent_session_id, fork=False, mode_label="resume"
|
|
214
|
+
):
|
|
215
|
+
yield ev
|
|
216
|
+
|
|
217
|
+
async def fork(
|
|
218
|
+
self, agent_session_id: str, user_message: str
|
|
219
|
+
) -> AsyncIterator[ConnectorEvent]:
|
|
220
|
+
if not self.spec.native_fork:
|
|
221
|
+
raise NotImplementedError(
|
|
222
|
+
f"{self.spec.bin} has no headless native fork; "
|
|
223
|
+
"caller must use fallback rebuild"
|
|
224
|
+
)
|
|
225
|
+
fs = self.spec.fork
|
|
226
|
+
assert fs is not None
|
|
227
|
+
# two_step (codex): step1 create fork → extract id → step2 exec resume
|
|
228
|
+
if fs.shape == "two_step":
|
|
229
|
+
async for ev in self._fork_two_step(agent_session_id, user_message, fs):
|
|
230
|
+
yield ev
|
|
231
|
+
return
|
|
232
|
+
# flag_overlay / subcommand: single command
|
|
233
|
+
async for ev in self._run(
|
|
234
|
+
user_message, session_id=agent_session_id, fork=True, mode_label="fork"
|
|
235
|
+
):
|
|
236
|
+
yield ev
|
|
237
|
+
|
|
238
|
+
async def _fork_two_step(
|
|
239
|
+
self,
|
|
240
|
+
agent_session_id: str,
|
|
241
|
+
user_message: str,
|
|
242
|
+
fs: "ForkShape",
|
|
243
|
+
) -> AsyncIterator[ConnectorEvent]:
|
|
244
|
+
"""Two-step fork: (1) create forked session, (2) exec resume into it.
|
|
245
|
+
|
|
246
|
+
Step 1 runs `<bin> fork <sid>` (or fs.fork_cmd) and extracts the new
|
|
247
|
+
session id from its output via fs.id_pattern. Step 2 builds the resume
|
|
248
|
+
command from fs.resume_cmd and runs it headless, streaming events.
|
|
249
|
+
"""
|
|
250
|
+
import shlex
|
|
251
|
+
import re
|
|
252
|
+
|
|
253
|
+
cli = shutil.which(self.spec.bin) or (
|
|
254
|
+
self.spec.bin if os.path.exists(self.spec.bin) else self.spec.bin
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# --- step 1: create the fork ---
|
|
258
|
+
step1_args = shlex.split(fs.fork_cmd.format(sid=agent_session_id))
|
|
259
|
+
proc = await asyncio.create_subprocess_exec(
|
|
260
|
+
cli,
|
|
261
|
+
*step1_args,
|
|
262
|
+
stdout=asyncio.subprocess.PIPE,
|
|
263
|
+
stderr=asyncio.subprocess.PIPE,
|
|
264
|
+
env=self._env(),
|
|
265
|
+
cwd=self.spec.workdir,
|
|
266
|
+
)
|
|
267
|
+
try:
|
|
268
|
+
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=60.0)
|
|
269
|
+
except asyncio.TimeoutError:
|
|
270
|
+
proc.kill()
|
|
271
|
+
await proc.wait()
|
|
272
|
+
yield ConnectorEvent(
|
|
273
|
+
ConnectorEventType.ERROR,
|
|
274
|
+
{"message": f"fork step1 timed out (TUI may be waiting for input)"},
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
combined = (out_b + b"\n" + err_b).decode(errors="replace")
|
|
278
|
+
m = re.search(fs.id_pattern, combined)
|
|
279
|
+
if not m:
|
|
280
|
+
yield ConnectorEvent(
|
|
281
|
+
ConnectorEventType.ERROR,
|
|
282
|
+
{"message": f"fork step1 produced no session id: {combined[:300]}"},
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
new_session_id = m.group(1)
|
|
286
|
+
|
|
287
|
+
# announce the new session id immediately (so the host can persist it)
|
|
288
|
+
yield ConnectorEvent(
|
|
289
|
+
ConnectorEventType.SESSION,
|
|
290
|
+
{"agent_session_id": new_session_id, "mode": "fork"},
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# --- step 2: exec resume into the new session headless ---
|
|
294
|
+
# build the resume argv from the template, then append output flags.
|
|
295
|
+
# shlex-quote the prompt so spaces/special chars survive shlex.split.
|
|
296
|
+
step2_args = shlex.split(
|
|
297
|
+
fs.resume_cmd.format(sid=new_session_id, prompt=shlex.quote(user_message))
|
|
298
|
+
)
|
|
299
|
+
full_args = step2_args + list(self.spec.output_format_flag) + list(self.spec.base_flags)
|
|
300
|
+
proc2 = await asyncio.create_subprocess_exec(
|
|
301
|
+
cli,
|
|
302
|
+
*full_args,
|
|
303
|
+
stdout=asyncio.subprocess.PIPE,
|
|
304
|
+
stderr=asyncio.subprocess.PIPE,
|
|
305
|
+
env=self._env(),
|
|
306
|
+
cwd=self.spec.workdir,
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
assert proc2.stdout is not None
|
|
310
|
+
async for raw_line in proc2.stdout:
|
|
311
|
+
line = raw_line.decode(errors="replace").strip()
|
|
312
|
+
if not line:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
obj = json.loads(line)
|
|
316
|
+
except json.JSONDecodeError:
|
|
317
|
+
continue
|
|
318
|
+
for ev in parse_line(self.spec.output_schema, obj):
|
|
319
|
+
yield ev
|
|
320
|
+
await proc2.wait()
|
|
321
|
+
if proc2.returncode not in (0, None):
|
|
322
|
+
stderr = ""
|
|
323
|
+
if proc2.stderr:
|
|
324
|
+
stderr = (await proc2.stderr.read()).decode(errors="replace")
|
|
325
|
+
yield ConnectorEvent(
|
|
326
|
+
ConnectorEventType.ERROR,
|
|
327
|
+
{"message": f"{self.spec.bin} resume exited {proc2.returncode}: {stderr[:500]}"},
|
|
328
|
+
)
|
|
329
|
+
finally:
|
|
330
|
+
if proc2.returncode is None:
|
|
331
|
+
proc2.kill()
|
|
332
|
+
await proc2.wait()
|