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.
@@ -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()