Skip to main content

running_process/client/
client.rs

1//! Synchronous IPC client for the running-process daemon.
2//!
3//! Connects to the daemon over a local socket (Unix domain socket on
4//! Linux/macOS, named pipe on Windows) and exchanges length-prefixed protobuf
5//! messages.
6
7use crate::client::paths;
8use crate::proto::daemon::{
9    BulkTerminateSessionsRequest, BulkTerminateSessionsResponse, DaemonRequest, DaemonResponse,
10    GetProcessTreeRequest, GetSessionBacklogRequest, GetSessionBacklogResponse, KeyValue,
11    KillTreeRequest, KillZombiesRequest, ListActiveRequest, ListByOriginatorRequest, PingRequest,
12    PipeStreamKind, PurgeExitedSessionsRequest, PurgeExitedSessionsResponse, RequestType,
13    ResizePtySessionRequest, ServiceConfig, ServiceDeleteRequest, ServiceDescribeRequest,
14    ServiceFlushRequest, ServiceListRequest, ServiceLogsRequest, ServiceRestartRequest,
15    ServiceResurrectRequest, ServiceSaveRequest, ServiceStartRequest, ServiceStopRequest,
16    ShutdownRequest, SpawnDaemonRequest as ProtoSpawnDaemonRequest, StatusCode, StatusRequest,
17};
18use interprocess::local_socket::Stream;
19use interprocess::TryClone;
20use prost::Message;
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24// ---------------------------------------------------------------------------
25// Error type
26// ---------------------------------------------------------------------------
27
28/// Errors produced by [`DaemonClient`] operations.
29#[derive(Debug)]
30pub enum ClientError {
31    /// Failed to connect to the daemon socket.
32    Connect(std::io::Error),
33    /// I/O error during send or receive.
34    Io(std::io::Error),
35    /// Failed to decode a protobuf response.
36    Decode(prost::DecodeError),
37    /// The daemon returned an application-level error response.
38    Server {
39        /// Application-level status code returned by the daemon.
40        code: StatusCode,
41        /// Human-readable daemon error message.
42        message: String,
43    },
44    /// The daemon is not running and could not be started.
45    DaemonNotRunning,
46}
47
48impl std::fmt::Display for ClientError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            ClientError::Connect(e) => write!(f, "failed to connect to daemon: {e}"),
52            ClientError::Io(e) => write!(f, "daemon I/O error: {e}"),
53            ClientError::Decode(e) => write!(f, "failed to decode daemon response: {e}"),
54            ClientError::Server { code, message } => {
55                write!(f, "daemon returned {:?}: {}", code, message)
56            }
57            ClientError::DaemonNotRunning => write!(f, "daemon is not running"),
58        }
59    }
60}
61
62impl std::error::Error for ClientError {
63    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64        match self {
65            ClientError::Connect(e) | ClientError::Io(e) => Some(e),
66            ClientError::Decode(e) => Some(e),
67            ClientError::Server { .. } | ClientError::DaemonNotRunning => None,
68        }
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Spawn API
74// ---------------------------------------------------------------------------
75
76/// Request to spawn a detached daemonized shell command under daemon control.
77#[derive(Debug, Clone)]
78pub struct SpawnCommandRequest {
79    /// Shell command line to execute.
80    pub command: String,
81    /// Working directory for the spawned command.
82    pub cwd: Option<PathBuf>,
83    /// Environment key/value pairs sent with the request.
84    pub env: Vec<(String, String)>,
85    /// Caller-provided originator used for tracking and filtering.
86    pub originator: Option<String>,
87    /// When `true`, the daemon clears the inherited env before applying
88    /// [`Self::env`], so the subprocess sees ONLY the supplied map.
89    /// Mirrors Python's `subprocess.Popen(env=…)` replace semantic.
90    /// Default `false` keeps the historic "layer on top of inherited"
91    /// behaviour.
92    pub clear_inherited_env: bool,
93}
94
95impl SpawnCommandRequest {
96    fn default_originator() -> String {
97        let caller = std::env::current_exe()
98            .ok()
99            .and_then(|path| {
100                path.file_stem()
101                    .map(|stem| stem.to_string_lossy().into_owned())
102            })
103            .filter(|value| !value.is_empty())
104            .unwrap_or_else(|| "running-process-client".to_string());
105        format!("{caller}:{}", std::process::id())
106    }
107
108    /// Build a shell-command request using the caller's current working
109    /// directory and environment.
110    pub fn shell(command: impl Into<String>) -> Self {
111        Self {
112            command: command.into(),
113            cwd: std::env::current_dir().ok(),
114            env: std::env::vars().collect(),
115            originator: Some(Self::default_originator()),
116            clear_inherited_env: false,
117        }
118    }
119
120    /// Override the working directory used for the spawned command.
121    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
122        self.cwd = Some(cwd.into());
123        self
124    }
125
126    /// Replace the environment block sent to the daemon (layered on top
127    /// of the daemon's inherited env, unless [`Self::with_env_replace`]
128    /// is used instead).
129    pub fn with_envs<I, K, V>(mut self, env: I) -> Self
130    where
131        I: IntoIterator<Item = (K, V)>,
132        K: Into<String>,
133        V: Into<String>,
134    {
135        self.env = env
136            .into_iter()
137            .map(|(key, value)| (key.into(), value.into()))
138            .collect();
139        self
140    }
141
142    /// Set the env block AND tell the daemon to clear the inherited
143    /// env first — the subprocess will see ONLY the supplied map.
144    ///
145    /// Mirrors Python's `subprocess.Popen(env=…)` semantic:
146    ///
147    /// ```python
148    /// subprocess.Popen(["..."], env=None)        # inherits
149    /// subprocess.Popen(["..."], env={"K": "V"})  # replaces
150    /// ```
151    ///
152    /// On Windows you typically still want to include `SystemRoot` in
153    /// the supplied map so `cmd.exe` can load its DLLs.
154    pub fn with_env_replace<I, K, V>(mut self, env: I) -> Self
155    where
156        I: IntoIterator<Item = (K, V)>,
157        K: Into<String>,
158        V: Into<String>,
159    {
160        self.env = env
161            .into_iter()
162            .map(|(key, value)| (key.into(), value.into()))
163            .collect();
164        self.clear_inherited_env = true;
165        self
166    }
167
168    /// Add or replace a single environment variable while keeping the rest
169    /// of the existing environment block intact.
170    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
171        let key = key.into();
172        let value = value.into();
173        if let Some((_, existing)) = self
174            .env
175            .iter_mut()
176            .find(|(existing_key, _)| *existing_key == key)
177        {
178            *existing = value;
179        } else {
180            self.env.push((key, value));
181        }
182        self
183    }
184
185    /// Set the originator value stored in the daemon registry and injected
186    /// into the spawned child environment.
187    pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
188        self.originator = Some(originator.into());
189        self
190    }
191}
192
193/// Information about a daemonized process spawned by the service.
194#[derive(Debug, Clone, PartialEq)]
195pub struct SpawnedDaemon {
196    /// Operating-system process identifier of the spawned daemon.
197    pub pid: u32,
198    /// Daemon-side creation timestamp in Unix seconds.
199    pub created_at: f64,
200    /// Shell command registered for the spawned daemon.
201    pub command: String,
202    /// Working directory reported for the spawned daemon.
203    pub cwd: Option<String>,
204    /// Originator recorded for the spawned daemon.
205    pub originator: Option<String>,
206    /// Containment mechanism used by the daemon for this process.
207    pub containment: String,
208}
209
210// ---------------------------------------------------------------------------
211// Client
212// ---------------------------------------------------------------------------
213
214/// Synchronous IPC client that communicates with the daemon over a local socket.
215///
216/// Messages are framed with a 4-byte big-endian length prefix followed by
217/// a protobuf-encoded payload.
218pub struct DaemonClient {
219    // Raw nonblocking streams (not BufReader/BufWriter): `send_request`
220    // drives deadline-bounded reads/writes via `deadline_io` so a stalled
221    // or crashed-mid-reply daemon can't wedge the caller (issue #590, B1).
222    reader: Stream,
223    writer: Stream,
224    next_id: AtomicU64,
225}
226
227impl DaemonClient {
228    /// Connect to a running daemon identified by an optional scope hash.
229    ///
230    /// The socket path is computed by [`paths::socket_path`] and the name type
231    /// dispatch matches the server via [`paths::make_socket_name`].
232    pub fn connect(scope_hash: Option<&str>) -> Result<Self, ClientError> {
233        let path = paths::socket_path(scope_hash);
234        Self::connect_to(&path)
235    }
236
237    /// Connect to a daemon listening at an explicit socket path.
238    ///
239    /// Use this when you already know the socket path (e.g. in integration
240    /// tests that start a server on a unique path).
241    pub fn connect_to(socket_path: &str) -> Result<Self, ClientError> {
242        // Validate the name up front so a bad path keeps its own error,
243        // then connect with a bounded timeout (issue #590, cluster B) so a
244        // bound-but-never-accepting daemon socket can't wedge the caller.
245        paths::make_socket_name(socket_path).map_err(ClientError::Connect)?;
246        let stream = crate::client::deadline_io::connect_with_timeout(socket_path)
247            .map_err(ClientError::Connect)?;
248        let stream_clone = stream.try_clone().map_err(ClientError::Connect)?;
249        // Nonblocking on both handles so `send_request`'s deadline-bounded
250        // reads/writes work. On Unix `try_clone` shares the file
251        // description (so O_NONBLOCK carries), but on Windows each handle's
252        // mode is independent — set both explicitly.
253        use interprocess::local_socket::traits::Stream as _;
254        stream.set_nonblocking(true).map_err(ClientError::Connect)?;
255        stream_clone
256            .set_nonblocking(true)
257            .map_err(ClientError::Connect)?;
258
259        Ok(Self {
260            reader: stream,
261            writer: stream_clone,
262            next_id: AtomicU64::new(1),
263        })
264    }
265
266    /// Send a request and wait for the corresponding response.
267    ///
268    /// The request is length-prefixed (4-byte big-endian u32) then protobuf-encoded.
269    /// The response uses the same framing.
270    pub fn send_request(&mut self, request: DaemonRequest) -> Result<DaemonResponse, ClientError> {
271        use crate::client::deadline_io::{
272            read_frame_with_deadline, rpc_read_deadline, write_all_with_deadline,
273        };
274
275        // Frame: 4-byte big-endian length prefix + protobuf payload.
276        let payload = request.encode_to_vec();
277        let mut framed = Vec::with_capacity(4 + payload.len());
278        framed.extend_from_slice(&(payload.len() as u32).to_be_bytes());
279        framed.extend_from_slice(&payload);
280
281        // Bound the whole round-trip (issue #590, cluster B1): a daemon that
282        // accepts then stalls or crashes mid-reply must not wedge the
283        // Python-facing caller. `read_frame_with_deadline` also applies the
284        // `MAX_FRAME_BYTES` cap before allocating the response buffer.
285        let deadline = rpc_read_deadline();
286        write_all_with_deadline(&mut self.writer, &framed, deadline).map_err(ClientError::Io)?;
287        let resp_buf =
288            read_frame_with_deadline(&mut self.reader, deadline).map_err(ClientError::Io)?;
289
290        DaemonResponse::decode(&resp_buf[..]).map_err(ClientError::Decode)
291    }
292
293    // -----------------------------------------------------------------------
294    // Convenience helpers
295    // -----------------------------------------------------------------------
296
297    /// Allocate the next request ID.
298    pub(crate) fn next_request_id(&self) -> u64 {
299        self.next_id.fetch_add(1, Ordering::Relaxed)
300    }
301
302    fn ensure_ok(&self, response: &DaemonResponse) -> Result<(), ClientError> {
303        if response.code == StatusCode::Ok as i32 {
304            return Ok(());
305        }
306
307        let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
308        Err(ClientError::Server {
309            code,
310            message: response.message.clone(),
311        })
312    }
313
314    /// Ping the daemon to check liveness.
315    pub fn ping(&mut self) -> Result<DaemonResponse, ClientError> {
316        let request = DaemonRequest {
317            id: self.next_request_id(),
318            r#type: RequestType::Ping.into(),
319            protocol_version: 1,
320            client_name: String::from("running-process-client"),
321            ping: Some(PingRequest {}),
322            ..Default::default()
323        };
324        self.send_request(request)
325    }
326
327    /// Ask the daemon to shut down.
328    pub fn shutdown(
329        &mut self,
330        graceful: bool,
331        timeout_seconds: f64,
332    ) -> Result<DaemonResponse, ClientError> {
333        let request = DaemonRequest {
334            id: self.next_request_id(),
335            r#type: RequestType::Shutdown.into(),
336            protocol_version: 1,
337            client_name: String::from("running-process-client"),
338            shutdown: Some(ShutdownRequest {
339                graceful,
340                timeout_seconds,
341            }),
342            ..Default::default()
343        };
344        self.send_request(request)
345    }
346
347    /// Query daemon status.
348    pub fn status(&mut self) -> Result<DaemonResponse, ClientError> {
349        let request = DaemonRequest {
350            id: self.next_request_id(),
351            r#type: RequestType::Status.into(),
352            protocol_version: 1,
353            client_name: String::from("running-process-client"),
354            status: Some(StatusRequest {}),
355            ..Default::default()
356        };
357        self.send_request(request)
358    }
359
360    /// List all active tracked processes.
361    pub fn list_active(&mut self) -> Result<DaemonResponse, ClientError> {
362        let request = DaemonRequest {
363            id: self.next_request_id(),
364            r#type: RequestType::ListActive.into(),
365            protocol_version: 1,
366            client_name: String::from("running-process-client"),
367            list_active: Some(ListActiveRequest {}),
368            ..Default::default()
369        };
370        self.send_request(request)
371    }
372
373    /// List tracked processes filtered by originator tool name.
374    pub fn list_by_originator(&mut self, tool: &str) -> Result<DaemonResponse, ClientError> {
375        let request = DaemonRequest {
376            id: self.next_request_id(),
377            r#type: RequestType::ListByOriginator.into(),
378            protocol_version: 1,
379            client_name: String::from("running-process-client"),
380            list_by_originator: Some(ListByOriginatorRequest {
381                tool: tool.to_string(),
382            }),
383            ..Default::default()
384        };
385        self.send_request(request)
386    }
387
388    /// Kill zombie processes tracked by the daemon.
389    pub fn kill_zombies(&mut self, dry_run: bool) -> Result<DaemonResponse, ClientError> {
390        let request = DaemonRequest {
391            id: self.next_request_id(),
392            r#type: RequestType::KillZombies.into(),
393            protocol_version: 1,
394            client_name: String::from("running-process-client"),
395            kill_zombies: Some(KillZombiesRequest { dry_run }),
396            ..Default::default()
397        };
398        self.send_request(request)
399    }
400
401    /// Kill a process tree rooted at `pid`.
402    pub fn kill_tree(
403        &mut self,
404        pid: u32,
405        timeout_seconds: f64,
406    ) -> Result<DaemonResponse, ClientError> {
407        let request = DaemonRequest {
408            id: self.next_request_id(),
409            r#type: RequestType::KillTree.into(),
410            protocol_version: 1,
411            client_name: String::from("running-process-client"),
412            kill_tree: Some(KillTreeRequest {
413                pid,
414                timeout_seconds,
415            }),
416            ..Default::default()
417        };
418        self.send_request(request)
419    }
420
421    /// Get the process tree display for a given PID.
422    pub fn get_process_tree(&mut self, pid: u32) -> Result<DaemonResponse, ClientError> {
423        let request = DaemonRequest {
424            id: self.next_request_id(),
425            r#type: RequestType::GetProcessTree.into(),
426            protocol_version: 1,
427            client_name: String::from("running-process-client"),
428            get_process_tree: Some(GetProcessTreeRequest { pid }),
429            ..Default::default()
430        };
431        self.send_request(request)
432    }
433
434    /// Ask the daemon to spawn and track a detached shell command.
435    pub fn spawn_command(
436        &mut self,
437        request: &SpawnCommandRequest,
438    ) -> Result<SpawnedDaemon, ClientError> {
439        let daemon_request = DaemonRequest {
440            id: self.next_request_id(),
441            r#type: RequestType::SpawnDaemon.into(),
442            protocol_version: 1,
443            client_name: String::from("running-process-client"),
444            spawn_daemon: Some(ProtoSpawnDaemonRequest {
445                command: request.command.clone(),
446                cwd: request
447                    .cwd
448                    .as_ref()
449                    .map(|cwd| cwd.to_string_lossy().into_owned())
450                    .unwrap_or_default(),
451                env: request
452                    .env
453                    .iter()
454                    .map(|(k, v)| KeyValue {
455                        key: k.clone(),
456                        value: v.clone(),
457                    })
458                    .collect(),
459                originator: request.originator.clone().unwrap_or_default(),
460                clear_inherited_env: request.clear_inherited_env,
461            }),
462            ..Default::default()
463        };
464
465        let response = self.send_request(daemon_request)?;
466        self.ensure_ok(&response)?;
467
468        let payload = response.spawn_daemon.ok_or_else(|| ClientError::Server {
469            code: StatusCode::Internal,
470            message: "spawn response missing payload".to_string(),
471        })?;
472
473        Ok(SpawnedDaemon {
474            pid: payload.pid,
475            created_at: payload.created_at,
476            command: payload.command,
477            cwd: if payload.cwd.is_empty() {
478                None
479            } else {
480                Some(payload.cwd)
481            },
482            originator: if payload.originator.is_empty() {
483                None
484            } else {
485                Some(payload.originator)
486            },
487            containment: payload.containment,
488        })
489    }
490
491    // --- service supervision (runpm) — Phase 1 ---
492
493    /// Start a supervised service from a [`ServiceConfig`].
494    pub fn service_start(&mut self, config: ServiceConfig) -> Result<DaemonResponse, ClientError> {
495        let request = DaemonRequest {
496            id: self.next_request_id(),
497            r#type: RequestType::ServiceStart.into(),
498            protocol_version: 1,
499            client_name: String::from("running-process-client"),
500            service_start: Some(ServiceStartRequest {
501                config: Some(config),
502            }),
503            ..Default::default()
504        };
505        self.send_request(request)
506    }
507
508    /// Stop a supervised service identified by name, id, or `"all"`.
509    pub fn service_stop(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
510        let request = DaemonRequest {
511            id: self.next_request_id(),
512            r#type: RequestType::ServiceStop.into(),
513            protocol_version: 1,
514            client_name: String::from("running-process-client"),
515            service_stop: Some(ServiceStopRequest {
516                target: target.to_string(),
517            }),
518            ..Default::default()
519        };
520        self.send_request(request)
521    }
522
523    /// Restart a supervised service identified by name, id, or `"all"`.
524    pub fn service_restart(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
525        let request = DaemonRequest {
526            id: self.next_request_id(),
527            r#type: RequestType::ServiceRestart.into(),
528            protocol_version: 1,
529            client_name: String::from("running-process-client"),
530            service_restart: Some(ServiceRestartRequest {
531                target: target.to_string(),
532            }),
533            ..Default::default()
534        };
535        self.send_request(request)
536    }
537
538    /// Delete a supervised service from the registry.
539    pub fn service_delete(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
540        let request = DaemonRequest {
541            id: self.next_request_id(),
542            r#type: RequestType::ServiceDelete.into(),
543            protocol_version: 1,
544            client_name: String::from("running-process-client"),
545            service_delete: Some(ServiceDeleteRequest {
546                target: target.to_string(),
547            }),
548            ..Default::default()
549        };
550        self.send_request(request)
551    }
552
553    /// List all supervised services known to the daemon.
554    pub fn service_list(&mut self) -> Result<DaemonResponse, ClientError> {
555        let request = DaemonRequest {
556            id: self.next_request_id(),
557            r#type: RequestType::ServiceList.into(),
558            protocol_version: 1,
559            client_name: String::from("running-process-client"),
560            service_list: Some(ServiceListRequest {}),
561            ..Default::default()
562        };
563        self.send_request(request)
564    }
565
566    /// Describe a single supervised service in detail.
567    pub fn service_describe(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
568        let request = DaemonRequest {
569            id: self.next_request_id(),
570            r#type: RequestType::ServiceDescribe.into(),
571            protocol_version: 1,
572            client_name: String::from("running-process-client"),
573            service_describe: Some(ServiceDescribeRequest {
574                target: target.to_string(),
575            }),
576            ..Default::default()
577        };
578        self.send_request(request)
579    }
580
581    /// Fetch buffered log output for a supervised service.
582    pub fn service_logs(
583        &mut self,
584        target: &str,
585        lines: u32,
586        follow: bool,
587    ) -> Result<DaemonResponse, ClientError> {
588        let request = DaemonRequest {
589            id: self.next_request_id(),
590            r#type: RequestType::ServiceLogs.into(),
591            protocol_version: 1,
592            client_name: String::from("running-process-client"),
593            service_logs: Some(ServiceLogsRequest {
594                target: target.to_string(),
595                lines,
596                follow,
597            }),
598            ..Default::default()
599        };
600        self.send_request(request)
601    }
602
603    /// Flush buffered logs for a supervised service.
604    pub fn service_flush(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
605        let request = DaemonRequest {
606            id: self.next_request_id(),
607            r#type: RequestType::ServiceFlush.into(),
608            protocol_version: 1,
609            client_name: String::from("running-process-client"),
610            service_flush: Some(ServiceFlushRequest {
611                target: target.to_string(),
612            }),
613            ..Default::default()
614        };
615        self.send_request(request)
616    }
617
618    /// Persist the current set of supervised services to a snapshot.
619    pub fn service_save(&mut self) -> Result<DaemonResponse, ClientError> {
620        let request = DaemonRequest {
621            id: self.next_request_id(),
622            r#type: RequestType::ServiceSave.into(),
623            protocol_version: 1,
624            client_name: String::from("running-process-client"),
625            service_save: Some(ServiceSaveRequest {}),
626            ..Default::default()
627        };
628        self.send_request(request)
629    }
630
631    /// Restore supervised services from the most recent snapshot.
632    pub fn service_resurrect(&mut self) -> Result<DaemonResponse, ClientError> {
633        let request = DaemonRequest {
634            id: self.next_request_id(),
635            r#type: RequestType::ServiceResurrect.into(),
636            protocol_version: 1,
637            client_name: String::from("running-process-client"),
638            service_resurrect: Some(ServiceResurrectRequest {}),
639            ..Default::default()
640        };
641        self.send_request(request)
642    }
643
644    /// Resize a PTY session without going through an attach
645    /// (#130 M5 follow-up). The new size persists for the lifetime of
646    /// the session; subsequent attaches can override it via their own
647    /// rows/cols fields.
648    pub fn resize_pty_session(
649        &mut self,
650        session_id: &str,
651        rows: u16,
652        cols: u16,
653    ) -> Result<(), ClientError> {
654        let request = DaemonRequest {
655            id: self.next_request_id(),
656            r#type: RequestType::ResizePtySession.into(),
657            protocol_version: 1,
658            client_name: String::from("running-process-client"),
659            resize_pty_session: Some(ResizePtySessionRequest {
660                session_id: session_id.into(),
661                rows: rows as u32,
662                cols: cols as u32,
663            }),
664            ..Default::default()
665        };
666        let response = self.send_request(request)?;
667        if response.code != StatusCode::Ok as i32 {
668            let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
669            return Err(ClientError::Server {
670                code,
671                message: response.message,
672            });
673        }
674        Ok(())
675    }
676
677    /// Purge exited sessions from both daemon-side registries (#130 M9
678    /// H4). Returns counts of PTY and pipe sessions reaped.
679    pub fn purge_exited_sessions(
680        &mut self,
681        originator: &str,
682    ) -> Result<PurgeExitedSessionsResponse, ClientError> {
683        let request = DaemonRequest {
684            id: self.next_request_id(),
685            r#type: RequestType::PurgeExitedSessions.into(),
686            protocol_version: 1,
687            client_name: String::from("running-process-client"),
688            purge_exited_sessions: Some(PurgeExitedSessionsRequest {
689                originator: originator.into(),
690            }),
691            ..Default::default()
692        };
693        let response = self.send_request(request)?;
694        if response.code != StatusCode::Ok as i32 {
695            let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
696            return Err(ClientError::Server {
697                code,
698                message: response.message,
699            });
700        }
701        response
702            .purge_exited_sessions
703            .ok_or_else(|| ClientError::Server {
704                code: StatusCode::Internal,
705                message: "purge_exited_sessions response missing payload".into(),
706            })
707    }
708
709    /// Schedule termination of every session older than the threshold
710    /// (#130 M9 H4). `older_than_secs=0` terminates everything in scope.
711    pub fn bulk_terminate_sessions(
712        &mut self,
713        older_than_secs: u64,
714        originator: &str,
715        grace_ms: u32,
716    ) -> Result<BulkTerminateSessionsResponse, ClientError> {
717        let request = DaemonRequest {
718            id: self.next_request_id(),
719            r#type: RequestType::BulkTerminateSessions.into(),
720            protocol_version: 1,
721            client_name: String::from("running-process-client"),
722            bulk_terminate_sessions: Some(BulkTerminateSessionsRequest {
723                older_than_secs,
724                originator: originator.into(),
725                grace_ms,
726            }),
727            ..Default::default()
728        };
729        let response = self.send_request(request)?;
730        if response.code != StatusCode::Ok as i32 {
731            let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
732            return Err(ClientError::Server {
733                code,
734                message: response.message,
735            });
736        }
737        response
738            .bulk_terminate_sessions
739            .ok_or_else(|| ClientError::Server {
740                code: StatusCode::Internal,
741                message: "bulk_terminate_sessions response missing payload".into(),
742            })
743    }
744
745    /// Snapshot a PTY or pipe session's output backlog without consuming
746    /// it. For pipe sessions, `pipe_stream` selects between stdout and
747    /// stderr (default stdout). For PTY sessions `pipe_stream` is ignored.
748    /// Returns `None` when the session is not found.
749    pub fn get_session_backlog(
750        &mut self,
751        session_id: &str,
752        pipe_stream: PipeStreamKind,
753    ) -> Result<Option<GetSessionBacklogResponse>, ClientError> {
754        let request = DaemonRequest {
755            id: self.next_request_id(),
756            r#type: RequestType::GetSessionBacklog.into(),
757            protocol_version: 1,
758            client_name: String::from("running-process-client"),
759            get_session_backlog: Some(GetSessionBacklogRequest {
760                session_id: session_id.into(),
761                pipe_stream: pipe_stream as i32,
762            }),
763            ..Default::default()
764        };
765        let response = self.send_request(request)?;
766        if response.code == StatusCode::NotFound as i32 {
767            return Ok(None);
768        }
769        if response.code != StatusCode::Ok as i32 {
770            let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
771            return Err(ClientError::Server {
772                code,
773                message: response.message,
774            });
775        }
776        Ok(response.get_session_backlog)
777    }
778}
779
780// ---------------------------------------------------------------------------
781// Auto-start logic
782// ---------------------------------------------------------------------------
783
784/// Connect to the daemon, starting it first if it is not running.
785///
786/// 1. Attempt to connect.
787/// 2. On failure, spawn `running-process-daemon start` as a detached process.
788/// 3. Retry with exponential back-off: 50 ms, 100 ms, 200 ms, 400 ms.
789/// 4. Return an error if the daemon cannot be reached after all retries.
790pub fn connect_or_start(scope_hash: Option<&str>) -> Result<DaemonClient, ClientError> {
791    // Fast path: daemon already running.
792    if let Ok(client) = DaemonClient::connect(scope_hash) {
793        return Ok(client);
794    }
795
796    // Spawn the daemon as a detached background process.
797    spawn_daemon()?;
798
799    // Retry with exponential back-off.
800    //
801    // #199: intentional — the daemon binds its socket asynchronously
802    // after `spawn_daemon()` returns. There's no event the OS can
803    // signal us with when the socket is ready, so we poll. Exponential
804    // back-off (50→100→200→400ms) is the standard pattern; total
805    // wait caps at 750ms.
806    let delays_ms: [u64; 4] = [50, 100, 200, 400];
807    for delay in delays_ms {
808        std::thread::sleep(std::time::Duration::from_millis(delay));
809        if let Ok(client) = DaemonClient::connect(scope_hash) {
810            return Ok(client);
811        }
812    }
813
814    Err(ClientError::DaemonNotRunning)
815}
816
817/// Launch a detached shell command through the running-process daemon.
818///
819/// The daemon owns process tracking after launch, so this helper returns as
820/// soon as the child has been spawned and registered.
821pub fn launch_detached(command: &str) -> Result<SpawnedDaemon, ClientError> {
822    let mut client = connect_or_start(None)?;
823    client.spawn_command(&SpawnCommandRequest::shell(command))
824}
825
826/// Convenience helper that connects to the daemon and asks it to daemonize
827/// the provided shell command under the caller's current cwd/environment.
828///
829/// Prefer [`launch_detached`] in new code; this name is kept for existing
830/// callers.
831pub fn daemonize_command(command: &str) -> Result<SpawnedDaemon, ClientError> {
832    launch_detached(command)
833}
834
835/// Spawn the daemon binary as a detached background process.
836fn spawn_daemon() -> Result<(), ClientError> {
837    let exe = daemon_exe_path();
838    let mut command = std::process::Command::new(&exe);
839    command.arg("start");
840    crate::spawn_daemon(&mut command).map_err(ClientError::Io)?;
841    Ok(())
842}
843
844/// Determine the path to the daemon executable.
845///
846/// Looks next to the current executable first, then falls back to expecting
847/// it on `$PATH`.
848fn daemon_exe_path() -> String {
849    if let Ok(mut path) = std::env::current_exe() {
850        path.pop(); // remove current binary name
851        let candidate = path.join(if cfg!(windows) {
852            "running-process-daemon.exe"
853        } else {
854            "running-process-daemon"
855        });
856        if candidate.exists() {
857            return candidate.to_string_lossy().into_owned();
858        }
859    }
860    // Fallback: assume it is on PATH.
861    String::from("running-process-daemon")
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn launch_detached_has_public_sync_signature() {
870        let _api: fn(&str) -> Result<SpawnedDaemon, ClientError> = launch_detached;
871    }
872
873    #[test]
874    fn spawn_command_request_builder_sets_detached_launch_context() {
875        let request = SpawnCommandRequest::shell("echo hello")
876            .with_cwd("work")
877            .with_envs([("A", "1")])
878            .with_env("B", "2")
879            .with_originator("tool:123");
880
881        assert_eq!(request.command, "echo hello");
882        assert_eq!(request.cwd.as_deref(), Some(std::path::Path::new("work")));
883        assert_eq!(
884            request.env,
885            vec![
886                ("A".to_string(), "1".to_string()),
887                ("B".to_string(), "2".to_string())
888            ]
889        );
890        assert_eq!(request.originator.as_deref(), Some("tool:123"));
891    }
892}