1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
use crate::error::RunnerError;
use crate::ring_buffer::RingBuffer;
use crate::types::RunnerMode;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::time::timeout;
use super::io::{PipeReadError, drain_pipes, read_pipes_until_exit};
use super::platform;
use super::{BufferConfig, ClaudeResponse, NdjsonResult, WslOptions};
/// Runner for cross-platform Claude CLI execution with automatic detection
#[derive(Debug, Clone)]
pub struct Runner {
/// The execution mode to use
pub mode: RunnerMode,
/// WSL-specific configuration options
pub wsl_options: WslOptions,
/// Output buffering configuration
pub buffer_config: BufferConfig,
}
impl Runner {
/// Create a new Runner with the specified mode and options
#[must_use]
pub fn new(mode: RunnerMode, wsl_options: WslOptions) -> Self {
Self {
mode,
wsl_options,
buffer_config: BufferConfig::default(),
}
}
/// Create a new Runner with custom buffer configuration
#[must_use]
pub const fn with_buffer_config(
mode: RunnerMode,
wsl_options: WslOptions,
buffer_config: BufferConfig,
) -> Self {
Self {
mode,
wsl_options,
buffer_config,
}
}
/// Parse NDJSON output from Claude CLI
///
/// Treats stdout as NDJSON where each line is a JSON object.
/// Returns the last valid JSON object found, or `NoValidJson` with a tail excerpt.
///
/// # Arguments
/// * `stdout` - The stdout content to parse
///
/// # Returns
/// * `NdjsonResult::ValidJson` - If at least one valid JSON object was found (returns the last one)
/// * `NdjsonResult::NoValidJson` - If no valid JSON was found (includes tail excerpt for error reporting)
#[must_use]
pub fn parse_ndjson(stdout: &str) -> NdjsonResult {
crate::runner::ndjson::parse_ndjson(stdout)
}
/// Create a Runner with native mode
#[must_use]
#[allow(dead_code)] // API method for runner construction
pub fn native() -> Self {
Self {
mode: RunnerMode::Native,
wsl_options: WslOptions {
distro: None,
claude_path: None,
},
buffer_config: BufferConfig::default(),
}
}
/// Create a Runner with automatic detection
///
/// The runner will be in Auto mode and will detect the appropriate
/// concrete mode (Native or WSL) during execution.
///
/// # Internal API
///
/// This is an internal helper for future use. The CLI only supports `native` and `wsl`
/// modes via `--runner-mode`. Auto mode detection is handled internally when the config
/// specifies `runner_mode = "auto"`, but goes through `Runner::with_buffer_config()`.
// Internal API for future use; CLI only supports native/wsl
#[allow(dead_code)] // Internal API for future use; CLI only supports native/wsl
pub fn auto() -> Result<Self, RunnerError> {
Ok(Self {
mode: RunnerMode::Auto,
wsl_options: WslOptions::default(),
buffer_config: BufferConfig::default(),
})
}
/// Execute Claude CLI with the configured runner mode
///
/// Uses `wsl.exe --exec` with argv (no shell) for WSL execution and pipes packet via STDIN.
/// Records `runner_distro` from `wsl -l -q` or `$WSL_DISTRO_NAME` for WSL mode.
pub async fn execute_claude(
&self,
args: &[String],
stdin_content: &str,
timeout_duration: Option<Duration>,
) -> Result<ClaudeResponse, RunnerError> {
// Resolve Auto mode to actual mode
let actual_mode = match self.mode {
RunnerMode::Auto => Self::detect_auto()?,
mode => mode,
};
// Execute based on resolved mode
match actual_mode {
RunnerMode::Native | RunnerMode::Auto => {
self.execute_native(args, stdin_content, timeout_duration)
.await
}
RunnerMode::Wsl => {
self.execute_wsl(args, stdin_content, timeout_duration)
.await
}
}
}
/// Execute Claude CLI natively (spawn claude directly)
async fn execute_native(
&self,
args: &[String],
stdin_content: &str,
timeout_duration: Option<Duration>,
) -> Result<ClaudeResponse, RunnerError> {
#[allow(unused_mut)]
let mut cmd = self.native_command_spec(args).to_tokio_command();
// Set process group on Unix for killpg support
#[cfg(unix)]
{
#[allow(unused_imports)]
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
// Create a new process group
libc::setpgid(0, 0);
Ok(())
});
}
}
self.execute_with_command(
cmd,
RunnerMode::Native,
"claude",
stdin_content,
timeout_duration,
)
.await
}
/// Execute Claude CLI via WSL using `wsl.exe --exec` with argv (no shell)
async fn execute_wsl(
&self,
args: &[String],
stdin_content: &str,
timeout_duration: Option<Duration>,
) -> Result<ClaudeResponse, RunnerError> {
let cmd = self.wsl_command_spec(args).to_tokio_command();
let mut response = self
.execute_with_command(cmd, RunnerMode::Wsl, "wsl", stdin_content, timeout_duration)
.await?;
response.runner_distro = self.get_wsl_distro_name();
Ok(response)
}
async fn execute_with_command(
&self,
mut cmd: tokio::process::Command,
runner_used: RunnerMode,
label: &str,
stdin_content: &str,
timeout_duration: Option<Duration>,
) -> Result<ClaudeResponse, RunnerError> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Create Job Object on Windows for process tree termination
#[cfg(windows)]
let job = platform::create_job_object()?;
let mut child = cmd.spawn().map_err(|e| {
execution_failed(runner_used, format!("Failed to spawn {label} process: {e}"))
})?;
// Assign to Job Object on Windows
#[cfg(windows)]
platform::assign_to_job(&job, &child)?;
// Write stdin content
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_content.as_bytes())
.await
.map_err(|e| {
execution_failed(
runner_used,
format!("Failed to write to {label} stdin: {e}"),
)
})?;
drop(stdin); // Close stdin
}
// Take stdout and stderr for buffered reading
let mut stdout_pipe = child
.stdout
.take()
.ok_or_else(|| execution_failed(runner_used, "Failed to capture stdout".to_string()))?;
let mut stderr_pipe = child
.stderr
.take()
.ok_or_else(|| execution_failed(runner_used, "Failed to capture stderr".to_string()))?;
// Create ring buffers
let mut stdout_buffer = RingBuffer::new(self.buffer_config.stdout_cap_bytes);
let mut stderr_buffer = RingBuffer::new(self.buffer_config.stderr_cap_bytes);
let status = if let Some(duration) = timeout_duration {
// Store child ID before consuming it
let child_id = child.id();
let read_future = read_pipes_until_exit(
&mut child,
&mut stdout_pipe,
&mut stderr_pipe,
&mut stdout_buffer,
&mut stderr_buffer,
);
match timeout(duration, read_future).await {
Ok(result) => result.map_err(|err| map_pipe_error(runner_used, err))?,
Err(_) => {
// Timeout occurred - terminate the process using stored ID
if let Some(pid) = child_id {
platform::terminate_process_by_pid(pid, duration).await?;
}
// Drain remaining output after termination
let _ = drain_pipes(
&mut stdout_pipe,
&mut stderr_pipe,
&mut stdout_buffer,
&mut stderr_buffer,
)
.await;
// Return timeout error
return Err(RunnerError::Timeout {
timeout_seconds: duration.as_secs(),
});
}
}
} else {
read_pipes_until_exit(
&mut child,
&mut stdout_pipe,
&mut stderr_pipe,
&mut stdout_buffer,
&mut stderr_buffer,
)
.await
.map_err(|err| map_pipe_error(runner_used, err))?
};
let stdout = stdout_buffer.to_string();
let stderr = stderr_buffer.to_string();
let ndjson_result = Self::parse_ndjson(&stdout);
Ok(ClaudeResponse {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
runner_used,
runner_distro: None,
timed_out: false,
ndjson_result,
stdout_truncated: stdout_buffer.was_truncated(),
stderr_truncated: stderr_buffer.was_truncated(),
stdout_total_bytes: stdout_buffer.total_bytes_written(),
stderr_total_bytes: stderr_buffer.total_bytes_written(),
})
}
}
impl Default for Runner {
fn default() -> Self {
Self {
mode: RunnerMode::Auto,
wsl_options: WslOptions::default(),
buffer_config: BufferConfig::default(),
}
}
}
fn execution_failed(runner_used: RunnerMode, reason: String) -> RunnerError {
match runner_used {
RunnerMode::Native => RunnerError::NativeExecutionFailed { reason },
RunnerMode::Wsl => RunnerError::WslExecutionFailed { reason },
RunnerMode::Auto => RunnerError::NativeExecutionFailed { reason },
}
}
fn map_pipe_error(runner_used: RunnerMode, error: PipeReadError) -> RunnerError {
match error {
PipeReadError::Stdout(err) => {
execution_failed(runner_used, format!("Failed to read stdout: {err}"))
}
PipeReadError::Stderr(err) => {
execution_failed(runner_used, format!("Failed to read stderr: {err}"))
}
PipeReadError::Wait(err) => {
execution_failed(runner_used, format!("Failed to wait for process: {err}"))
}
}
}
#[cfg(test)]
mod tests {
use super::Runner;
use crate::runner::claude::BufferConfig;
use crate::runner::claude::WslOptions;
use crate::types::RunnerMode;
#[test]
fn test_runner_creation() {
let runner = Runner::new(RunnerMode::Native, WslOptions::default());
assert_eq!(runner.mode, RunnerMode::Native);
}
#[test]
fn test_runner_default() {
let runner = Runner::default();
assert_eq!(runner.mode, RunnerMode::Auto);
}
#[test]
fn test_runner_with_buffer_config() {
let buffer_config = BufferConfig {
stdout_cap_bytes: 1024,
stderr_cap_bytes: 512,
stderr_receipt_cap_bytes: 256,
};
let runner =
Runner::with_buffer_config(RunnerMode::Native, WslOptions::default(), buffer_config);
assert_eq!(runner.buffer_config.stdout_cap_bytes, 1024);
assert_eq!(runner.buffer_config.stderr_cap_bytes, 512);
assert_eq!(runner.buffer_config.stderr_receipt_cap_bytes, 256);
}
}