Skip to main content

mj_client/
daemon.rs

1//! Authenticated local daemon protocol and client transport.
2use crate::review::RuntimeReviewView;
3use crate::session::{ManagedSessionView, ViewError};
4use anyhow::{Context, Result, bail, ensure};
5use mj_core::config::{Config, data_dir};
6use mj_core::credentials::CredentialSyncSignal;
7use mj_core::elicitation::ElicitationResponse;
8use mj_core::relay::{RelayCommand, RelayOperationalState};
9use mj_core::review::driver::Resolution;
10use mj_core::state::*;
11use mj_core::targets::{AdditionalMount, ProvisionStage};
12use mj_core::workspace::WorkspaceRecord;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::fs;
16use std::net::SocketAddr;
17use std::path::PathBuf;
18use std::time::{Duration, Instant};
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21pub fn metadata_path() -> PathBuf {
22    data_dir().join("daemon.json")
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct DaemonMetadata {
28    pub protocol_version: u32,
29    pub pid: u32,
30    pub address: SocketAddr,
31    pub token: String,
32    pub started_at: String,
33    pub build_version: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct WorkspaceListing {
39    pub workspace: WorkspaceRecord,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct SessionPreview {
45    pub id: String,
46    pub title: String,
47    pub project: String,
48    pub harness: String,
49    pub state: String,
50    pub active: bool,
51    pub updated_at: String,
52}
53
54/// One session in the user's SessionWiki index, as a control surface shows it.
55///
56/// It is the daemon's own shape rather than SessionWiki's row: it carries the
57/// search snippet that found the row and, for a Mjolnir session this daemon
58/// still holds, the id that resumes it instead of restoring it.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct WikiRow {
62    pub id: String,
63    pub tool: String,
64    pub project: String,
65    pub title: String,
66    pub started: Option<String>,
67    pub msgs: i64,
68    pub preview: Option<String>,
69    /// The tool deleted its own copy and SessionWiki kept the transcript.
70    pub archived: bool,
71    /// The session id the tool that ran it knows it by, when its stored path
72    /// carries one. It is what matches a row against an import scan.
73    pub native_id: Option<String>,
74    /// The matching text, when this row came from a search.
75    pub snippet: Option<String>,
76    /// The live Mjolnir session this row describes, when this daemon has it.
77    pub hel_session_id: Option<String>,
78}
79
80/// How far along the daemon's SessionWiki index is when a search answers.
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum WikiIndexState {
84    /// The index has completed one full build; its answers are complete.
85    Ready,
86    /// The first full build has not finished yet, so a search can miss
87    /// sessions that exist. This is the state a fresh index starts in.
88    #[default]
89    Indexing,
90    /// The index file on disk was written by a different SessionWiki schema
91    /// version. Mjolnir will not open it, because opening it would drop and
92    /// rebuild the user's whole cache.
93    VersionMismatch,
94}
95
96/// What a search says about the index it answered from.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WikiStatus {
100    pub state: WikiIndexState,
101    /// A sync is running now, so repeating the query may return more.
102    pub topping_up: bool,
103}
104
105/// One page of search results with the state of the index behind them.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct WikiSearchPage {
109    pub rows: Vec<WikiRow>,
110    pub status: WikiStatus,
111}
112
113/// Start a new session carrying a compacted hand-off from an archived one.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct WikiRestoreRequest {
117    /// The SessionWiki session id to restore from.
118    pub wiki_id: String,
119    pub workspace_id: String,
120    pub profile_id: String,
121    pub target_template_id: String,
122    /// Where the new session opens. None takes the project the archived
123    /// session ran in, when that directory still exists.
124    #[serde(default)]
125    pub project_directory: Option<PathBuf>,
126    #[serde(default)]
127    pub additional_mounts: Vec<AdditionalMount>,
128    #[serde(default)]
129    pub resource_allocation: Option<SessionResourceAllocation>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct WorkspaceSnapshot {
135    pub workspace: WorkspaceRecord,
136    pub sessions: Vec<SessionPreview>,
137    pub drafts: Vec<DraftPreview>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct RuntimeSessionView {
143    pub session_id: String,
144    pub projection_ordinal: u64,
145    pub projection_digest: String,
146    pub operational: Option<RelayOperationalState>,
147    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
148    pub connected: bool,
149    pub error: Option<ViewError>,
150}
151
152impl RuntimeSessionView {
153    pub fn from_managed(session_id: String, view: ManagedSessionView) -> Self {
154        let (projection_ordinal, projection_digest, operational, signal) =
155            view.snapshot
156                .map_or((0, String::new(), None, None), |snapshot| {
157                    (
158                        snapshot.materialized.applied_event_ordinal,
159                        snapshot.materialized.applied_event_digest,
160                        Some(snapshot.operational),
161                        snapshot.latest_credential_sync_signal,
162                    )
163                });
164        Self {
165            session_id,
166            projection_ordinal,
167            projection_digest,
168            operational,
169            latest_credential_sync_signal: signal,
170            connected: view.connected,
171            error: view.error,
172        }
173    }
174}
175
176/// Something the daemon did on its own that a surface should report once.
177///
178/// Background work has no lifecycle entry to hang a message on, so notices
179/// travel with the snapshot and carry an id: a surface reports the ones newer
180/// than the last it saw and nothing else, however often it polls.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct RuntimeNotice {
184    pub id: u64,
185    pub session_id: String,
186    pub text: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct RuntimeSnapshot {
192    #[serde(default)]
193    pub workspace_names: BTreeMap<String, String>,
194    #[serde(default)]
195    pub moves: Vec<mj_core::state::MoveOperation>,
196    pub revision: u64,
197    pub config: Config,
198    pub records: Vec<SessionRecord>,
199    pub sessions: Vec<RuntimeSessionView>,
200    pub lifecycles: Vec<RuntimeLifecycleView>,
201    /// Reviews the daemon is running, so every surface renders the same one.
202    #[serde(default)]
203    pub reviews: Vec<RuntimeReviewView>,
204    /// Recent background events for this workspace's sessions, oldest first.
205    #[serde(default)]
206    pub notices: Vec<RuntimeNotice>,
207    /// Parent/child relations for the sessions in `records`, so a surface can
208    /// keep a daemon-created child out of the real workspace without a full
209    /// state reload.
210    #[serde(default)]
211    pub subagents: Vec<mj_core::subagent::SubagentRecord>,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum RuntimeLifecycleKind {
217    Create,
218    Close,
219    Resume,
220    Move,
221    ForceStop,
222    DestroyStopped,
223    ForceDestroy,
224    Cleanup,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct RuntimeLifecycleView {
230    pub operation_id: String,
231    pub cancellable: bool,
232    pub session_id: String,
233    pub kind: RuntimeLifecycleKind,
234    pub started_at_epoch_seconds: u64,
235    pub active_stages: Vec<(ProvisionStage, u64)>,
236    pub resume_destination: Option<(String, String)>,
237    pub notice: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct ResumeSessionRequest {
243    pub session_id: String,
244    pub workspace_id: String,
245    pub profile_id: String,
246    pub target_template_id: String,
247    pub additional_mounts: Option<Vec<AdditionalMount>>,
248    pub resource_allocation: Option<SessionResourceAllocation>,
249    pub discard_queue: bool,
250    pub repository_preflight: Option<ResumeRepositorySourceReceipt>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct CreateSessionRequest {
256    #[serde(default)]
257    pub create_managed_worktree: Option<bool>,
258    /// None follows the global `[subagents] enabled` setting at launch time.
259    #[serde(default)]
260    pub mjolnir_subagents: Option<bool>,
261    #[serde(default)]
262    pub initial_prompt: Option<String>,
263    pub workspace_id: String,
264    pub profile_id: String,
265    pub bundle_id: String,
266    pub project_directory: Option<PathBuf>,
267    pub target_template_id: String,
268    pub additional_mounts: Vec<AdditionalMount>,
269    pub resource_allocation: Option<SessionResourceAllocation>,
270    pub title: String,
271    pub session_title_override: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct RegisteredSession {
277    pub session: SessionRecord,
278    pub remembered_container_size: Option<(String, HostContainerSize)>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct DraftPreview {
284    pub id: String,
285    pub session_id: Option<String>,
286    pub source: String,
287    pub owner_pid: Option<u32>,
288    pub saved_at: String,
289}
290
291/// `Ping`, `Status`, and `Stop` form the frozen management subset: their wire
292/// encoding — together with `RequestEnvelope`, `ResponseEnvelope`,
293/// `DaemonStatus`, and `WebViewerStatus` — must never change shape, because
294/// clients and daemons of *any* protocol version rely on them to identify,
295/// stop, and replace each other. Every other action may change freely behind a
296/// `PROTOCOL_VERSION` bump.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case", tag = "action", content = "arguments")]
299pub enum DaemonAction {
300    Ping,
301    Status,
302    WebViewerAccess,
303    RecoverWebViewer(crate::web::WebViewerRecovery),
304    InspectWebListener,
305    ListWorkspaces,
306    CreateWorkspace {
307        name: String,
308    },
309    RenameWorkspace {
310        workspace_id: String,
311        name: String,
312    },
313    TouchWorkspace {
314        workspace_id: String,
315    },
316    DeleteWorkspace {
317        workspace_id: String,
318    },
319    Attach {
320        client_id: String,
321        pid: u32,
322    },
323    Detach {
324        client_id: String,
325    },
326    PersistReadReceipt {
327        client_id: String,
328        workspace_id: String,
329        session_id: String,
330        through: u64,
331    },
332    PersistDetachedSessionState {
333        client_id: String,
334        workspace_id: String,
335        session_id: String,
336        through: u64,
337        owner_pid: u32,
338        draft: mj_core::storage::DetachedSessionDraft,
339    },
340    SaveActiveReview {
341        session_id: String,
342        review: mj_core::storage::StoredReview,
343    },
344    ClearActiveReview {
345        session_id: String,
346    },
347    RememberReviewerSelection {
348        workspace_id: String,
349        selection: mj_core::second_opinion::ReviewerSelection,
350    },
351    SaveWorkspacePaneSizes {
352        workspace_id: String,
353        sizes: mj_core::workspace::PaneSizes,
354    },
355    PersistImportedSession {
356        session: Box<SessionRecord>,
357    },
358    SetSessionTitle {
359        session_id: String,
360        title: String,
361    },
362    SetSessionContainerSettings {
363        session_id: String,
364        cpus: Option<String>,
365        memory: Option<String>,
366        mounts: Vec<AdditionalMount>,
367        mount_history: Vec<PathBuf>,
368    },
369    SetSessionAcpTitle {
370        session_id: String,
371        title: Option<String>,
372    },
373    MarkSessionTargetMissing {
374        session_id: String,
375        detail: String,
376        updated_at: String,
377    },
378    CheckpointSession {
379        session_id: String,
380    },
381    /// Search the user's SessionWiki index. An empty query lists the most
382    /// recent sessions.
383    WikiSearch {
384        query: String,
385        limit: usize,
386    },
387    /// The markdown briefing for one indexed session.
388    WikiBrief {
389        wiki_id: String,
390        max_chars: usize,
391    },
392    /// Start a new session from an archived one's transcript.
393    WikiRestore(WikiRestoreRequest),
394    ScanRecovery {
395        all_instances: bool,
396    },
397    AdoptRecovery {
398        session_id: String,
399        target_id: String,
400        profile: Option<String>,
401        bundle: Option<String>,
402        all_instances: bool,
403    },
404    DestroyRecovery {
405        session_id: String,
406        target_id: String,
407        confirmation: String,
408        all_instances: bool,
409    },
410    Snapshot {
411        workspace_id: String,
412    },
413    RuntimeSnapshot {
414        workspace_id: String,
415        after_revision: u64,
416        #[serde(default)]
417        all_workspaces: bool,
418    },
419    RenameProfile {
420        old_id: String,
421        new_id: String,
422    },
423    RenameTarget {
424        old_id: String,
425        new_id: String,
426    },
427    SubmitSessionCommand {
428        #[serde(default)]
429        inherited_draft: Option<String>,
430        session_id: String,
431        command_id: String,
432        command: RelayCommand,
433    },
434    SyncSession {
435        session_id: String,
436    },
437    RespondElicitation {
438        session_id: String,
439        elicitation_id: String,
440        response: ElicitationResponse,
441    },
442    StopBackgroundTask {
443        session_id: String,
444        background_task_id: String,
445    },
446    /// Drive a session's second-opinion reviewer. The reviewer is a sidecar of
447    /// the session's worker, so it travels the session's own relay rather than
448    /// becoming a session of its own here.
449    ReviewerAction {
450        session_id: String,
451        /// Which reviewing role the action drives; absent means the default
452        /// one, which is what plan review uses.
453        #[serde(default, skip_serializing_if = "Option::is_none")]
454        role: Option<String>,
455        action: crate::session::ReviewerAction,
456    },
457    /// Review the turn this session just finished, on a surface's request.
458    StartTurnReview {
459        session_id: String,
460    },
461    /// Forward, dismiss, or cancel the open review.
462    ResolveTurnReview {
463        session_id: String,
464        resolution: Resolution,
465    },
466    CloseSession {
467        session_id: String,
468    },
469    StartCreateSession(CreateSessionRequest),
470    WaitCreateSession {
471        session_id: String,
472    },
473    ResumeSession(ResumeSessionRequest),
474    PrepareMoveSession(MoveSelection),
475    MoveSession(MoveSessionRequest),
476    ForceStopSession {
477        session_id: String,
478    },
479    DestroyStoppedSession {
480        session_id: String,
481    },
482    ForceDestroySession {
483        session_id: String,
484    },
485    ForceDeleteWorkspace {
486        workspace_id: String,
487    },
488    CancelLifecycle {
489        session_id: String,
490    },
491    RecoverDraft {
492        draft_id: String,
493    },
494    Stop,
495}
496
497#[derive(Debug, Serialize, Deserialize)]
498#[serde(deny_unknown_fields)]
499pub struct RequestEnvelope {
500    pub protocol_version: u32,
501    pub request_id: u64,
502    pub token: String,
503    pub action: DaemonAction,
504}
505
506#[derive(Debug, Serialize, Deserialize)]
507#[serde(deny_unknown_fields)]
508pub struct ResponseEnvelope {
509    pub protocol_version: u32,
510    pub request_id: u64,
511    pub result: std::result::Result<DaemonReply, String>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(rename_all = "snake_case", tag = "reply", content = "value")]
516pub enum DaemonReply {
517    Pong,
518    Status(DaemonStatus),
519    WebViewerAccess(crate::web::WebViewerAccess),
520    WebListeners(Vec<crate::web::WebListenerProcess>),
521    Workspaces(Vec<WorkspaceListing>),
522    Workspace(WorkspaceRecord),
523    Snapshot(WorkspaceSnapshot),
524    RuntimeSnapshot(Box<RuntimeSnapshot>),
525    RegisteredSession(Box<RegisteredSession>),
526    MovePreparation(Box<MovePreparation>),
527    MoveOutcome(MoveOutcome),
528    Ordinal(u64),
529    Text(String),
530    OptionalSessionState(Option<SessionState>),
531    Checkpoint(mj_core::state::CheckpointMetadata),
532    RecoveryScan(mj_core::state::RecoveryScan),
533    WikiRows(WikiSearchPage),
534    Reviewer(Box<crate::session::ReviewerOutcome>),
535    Done,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
539#[serde(deny_unknown_fields)]
540pub struct DaemonStatus {
541    pub pid: u32,
542    pub started_at: String,
543    pub build_version: String,
544    pub attached_clients: usize,
545    pub phone_status: WebViewerStatus,
546}
547
548#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case", tag = "state")]
550pub enum WebViewerStatus {
551    Disabled,
552    Starting,
553    Ready {
554        viewer_url: String,
555        viewer_code: String,
556        qr_login_url: Option<String>,
557        fallback_reason: Option<String>,
558    },
559    Stopped,
560    Error {
561        message: String,
562    },
563}
564
565impl std::fmt::Debug for WebViewerStatus {
566    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        match self {
568            Self::Ready {
569                viewer_url,
570                viewer_code,
571                fallback_reason,
572                ..
573            } => formatter
574                .debug_struct("Ready")
575                .field("viewer_url", viewer_url)
576                .field("viewer_code", viewer_code)
577                .field("qr_login_url", &"[redacted]")
578                .field("fallback_reason", fallback_reason)
579                .finish(),
580            Self::Disabled => formatter.write_str("Disabled"),
581            Self::Starting => formatter.write_str("Starting"),
582            Self::Stopped => formatter.write_str("Stopped"),
583            Self::Error { message } => formatter.debug_tuple("Error").field(message).finish(),
584        }
585    }
586}
587
588impl std::fmt::Display for WebViewerStatus {
589    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590        match self {
591            Self::Disabled => formatter.write_str("disabled"),
592            Self::Starting => formatter.write_str("starting"),
593            Self::Stopped => formatter.write_str("stopped unexpectedly"),
594            Self::Error { message } => write!(formatter, "error: {message}"),
595            Self::Ready {
596                viewer_url,
597                viewer_code,
598                fallback_reason,
599                ..
600            } => {
601                write!(formatter, "{viewer_url}; viewer code {viewer_code}")?;
602                if let Some(reason) = fallback_reason {
603                    write!(
604                        formatter,
605                        "; local only because Tailscale HTTPS is unavailable: {reason}"
606                    )?;
607                }
608                Ok(())
609            }
610        }
611    }
612}
613
614/// Whether a non-child process has exited but not yet been reaped.
615///
616/// A zombie still answers `kill(pid, 0)`, because its process-table entry
617/// survives until its parent waits for it — so an existence probe alone calls
618/// it alive forever and anything waiting for it to leave waits forever. That is
619/// exactly the shape of `mj daemon restart` refusing to restart a daemon that
620/// had already stopped: `spawn_detached` used to leave the daemon a child of a
621/// long-lived Mjolnir process that never reaped it. It now double-forks, so the
622/// daemon is init's to reap, but any other unreaped child of this process would
623/// look the same, and the check stays cheap.
624///
625/// Treating a zombie as gone is also safe in the direction that matters: a
626/// zombie's PID cannot be reused until it is reaped, so nothing else can be
627/// occupying that number while this returns true.
628#[cfg(unix)]
629pub fn process_is_zombie(pid: u32) -> bool {
630    let pid = sysinfo::Pid::from_u32(pid);
631    let mut system = sysinfo::System::new();
632    system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
633    system
634        .process(pid)
635        .is_some_and(|process| process.status() == sysinfo::ProcessStatus::Zombie)
636}
637
638/// Wait for a process to leave, within [`STOP_TIMEOUT`].
639///
640/// The error says the process was still running rather than that it "did not
641/// stop": a daemon that is still winding down has not refused, and the two
642/// read very differently to somebody deciding whether to reach for a kill.
643pub async fn wait_for_exit(pid: u32) -> Result<()> {
644    let deadline = Instant::now() + STOP_TIMEOUT;
645    while daemon_process_is_alive(pid) {
646        ensure!(Instant::now() < deadline, "process {pid} is still running");
647        tokio::time::sleep(RETRY_DELAY).await;
648    }
649    Ok(())
650}
651
652/// Whether a daemon that Mjolnir launched in its own process group is alive.
653///
654/// Reaping is deliberately confined to this daemon-specific path. Attachment
655/// PIDs are merely observations and may alias unrelated children owned by this
656/// process, so their liveness probe below must never call `waitpid`.
657pub fn daemon_process_is_alive(pid: u32) -> bool {
658    #[cfg(unix)]
659    {
660        if pid == 0 {
661            return false;
662        }
663        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
664            return false;
665        };
666        let mut status = 0;
667        // SAFETY: `status` is writable for the call and WNOHANG never blocks.
668        // A non-child fails with ECHILD without changing any process state.
669        let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
670        if waited == raw_pid {
671            return false;
672        }
673        if waited == 0 {
674            return true;
675        }
676        let wait_error = std::io::Error::last_os_error();
677        if wait_error.raw_os_error() != Some(libc::ECHILD) {
678            return true;
679        }
680
681        #[cfg(target_os = "macos")]
682        return owned_daemon_group_is_alive(raw_pid);
683
684        #[cfg(not(target_os = "macos"))]
685        process_is_alive(pid)
686    }
687    #[cfg(not(unix))]
688    process_is_alive(pid)
689}
690
691#[cfg(target_os = "macos")]
692pub fn owned_daemon_group_is_alive(pid: libc::pid_t) -> bool {
693    // `spawn_detached` makes the daemon a process-group leader. Darwin
694    // excludes zombies from group signal probes: ESRCH means the group is gone
695    // and EPERM means only exiting members remain. The latter is safe here
696    // because this is a group we created for our own same-user child, not an
697    // arbitrary process group.
698    // SAFETY: signal 0 is only an existence probe, and the negative PID targets
699    // the daemon-owned group rather than another process.
700    if unsafe { libc::kill(-pid, 0) } == 0 {
701        return true;
702    }
703    let error = std::io::Error::last_os_error();
704    !matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM))
705}
706
707pub fn process_is_alive(pid: u32) -> bool {
708    #[cfg(unix)]
709    {
710        if pid == 0 {
711            return false;
712        }
713        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
714            return false;
715        };
716        // SAFETY: kill(pid, 0) sends no signal and is the standard existence
717        // probe. EPERM still means the process exists.
718        let result = unsafe { libc::kill(raw_pid, 0) };
719        let exists =
720            result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
721        exists && !process_is_zombie(pid)
722    }
723    #[cfg(not(unix))]
724    {
725        let _ = pid;
726        true
727    }
728}
729
730pub fn read_metadata() -> Result<DaemonMetadata> {
731    let metadata = read_metadata_any()?;
732    ensure!(
733        metadata.protocol_version == PROTOCOL_VERSION,
734        "daemon protocol {} is incompatible with client protocol {}",
735        metadata.protocol_version,
736        PROTOCOL_VERSION
737    );
738    Ok(metadata)
739}
740
741pub fn read_metadata_any() -> Result<DaemonMetadata> {
742    let path = metadata_path();
743    let body = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
744    let metadata: DaemonMetadata =
745        serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
746    Ok(metadata)
747}
748
749pub async fn write_frame<T: Serialize>(stream: &mut TcpStream, value: &T) -> Result<()> {
750    let body = serde_json::to_vec(value)?;
751    ensure!(body.len() <= MAX_FRAME_BYTES, "daemon frame is too large");
752    stream.write_u32(body.len() as u32).await?;
753    stream.write_all(&body).await?;
754    stream.flush().await?;
755    Ok(())
756}
757
758pub async fn read_frame<T: for<'de> Deserialize<'de>>(stream: &mut TcpStream) -> Result<T> {
759    let length = stream.read_u32().await? as usize;
760    ensure!(
761        length <= MAX_FRAME_BYTES,
762        "daemon frame exceeds {MAX_FRAME_BYTES} bytes"
763    );
764    let mut body = vec![0_u8; length];
765    stream.read_exact(&mut body).await?;
766    serde_json::from_slice(&body).context("decode daemon frame")
767}
768
769pub struct DaemonClient {
770    metadata: DaemonMetadata,
771    stream: TcpStream,
772    next_request_id: u64,
773}
774
775impl DaemonClient {
776    pub async fn connect(metadata: DaemonMetadata) -> Result<Self> {
777        let stream =
778            tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(metadata.address))
779                .await
780                .context("time out connecting to Mjolnir daemon")??;
781        Ok(Self {
782            metadata,
783            stream,
784            next_request_id: 1,
785        })
786    }
787
788    /// Speak the daemon's advertised dialect, not this build's: management
789    /// requests must reach daemons of any protocol version, and the frozen
790    /// subset encodes identically across all of them.
791    pub async fn request(&mut self, action: DaemonAction) -> Result<DaemonReply> {
792        let protocol_version = self.metadata.protocol_version;
793        let request_id = self.next_request_id;
794        self.next_request_id += 1;
795        write_frame(
796            &mut self.stream,
797            &RequestEnvelope {
798                protocol_version,
799                request_id,
800                token: self.metadata.token.clone(),
801                action,
802            },
803        )
804        .await?;
805        let response: ResponseEnvelope = read_frame(&mut self.stream).await?;
806        ensure!(
807            response.protocol_version == protocol_version,
808            "daemon changed protocol"
809        );
810        ensure!(
811            response.request_id == request_id,
812            "daemon crossed request IDs"
813        );
814        response.result.map_err(anyhow::Error::msg)
815    }
816
817    pub async fn status(&mut self) -> Result<DaemonStatus> {
818        match self.request(DaemonAction::Status).await? {
819            DaemonReply::Status(status) => Ok(status),
820            reply => bail!("unexpected daemon status reply {reply:?}"),
821        }
822    }
823
824    pub async fn web_access(&mut self) -> Result<crate::web::WebViewerAccess> {
825        match self.request(DaemonAction::WebViewerAccess).await? {
826            DaemonReply::WebViewerAccess(access) => Ok(access),
827            reply => bail!("unexpected web viewer reply {reply:?}"),
828        }
829    }
830
831    pub async fn recover_web_viewer(
832        &mut self,
833        action: crate::web::WebViewerRecovery,
834    ) -> Result<()> {
835        match self.request(DaemonAction::RecoverWebViewer(action)).await? {
836            DaemonReply::Done => Ok(()),
837            reply => bail!("unexpected web viewer recovery reply {reply:?}"),
838        }
839    }
840
841    pub async fn inspect_web_listener(&mut self) -> Result<Vec<crate::web::WebListenerProcess>> {
842        match self.request(DaemonAction::InspectWebListener).await? {
843            DaemonReply::WebListeners(processes) => Ok(processes),
844            reply => bail!("unexpected listener inspection reply {reply:?}"),
845        }
846    }
847
848    pub async fn list_workspaces(&mut self) -> Result<Vec<WorkspaceListing>> {
849        match self.request(DaemonAction::ListWorkspaces).await? {
850            DaemonReply::Workspaces(workspaces) => Ok(workspaces),
851            reply => bail!("unexpected daemon workspace reply {reply:?}"),
852        }
853    }
854
855    pub async fn rename_profile(&mut self, old_id: String, new_id: String) -> Result<()> {
856        match self
857            .request(DaemonAction::RenameProfile { old_id, new_id })
858            .await?
859        {
860            DaemonReply::Done => Ok(()),
861            reply => bail!("unexpected rename-profile reply {reply:?}"),
862        }
863    }
864
865    pub async fn rename_target(&mut self, old_id: String, new_id: String) -> Result<()> {
866        match self
867            .request(DaemonAction::RenameTarget { old_id, new_id })
868            .await?
869        {
870            DaemonReply::Done => Ok(()),
871            reply => bail!("unexpected rename-target reply {reply:?}"),
872        }
873    }
874
875    pub async fn create_workspace(&mut self, name: String) -> Result<WorkspaceRecord> {
876        match self.request(DaemonAction::CreateWorkspace { name }).await? {
877            DaemonReply::Workspace(workspace) => Ok(workspace),
878            reply => bail!("unexpected create-workspace reply {reply:?}"),
879        }
880    }
881
882    pub async fn rename_workspace(&mut self, workspace_id: String, name: String) -> Result<()> {
883        match self
884            .request(DaemonAction::RenameWorkspace { workspace_id, name })
885            .await?
886        {
887            DaemonReply::Done => Ok(()),
888            reply => bail!("unexpected rename-workspace reply {reply:?}"),
889        }
890    }
891
892    pub async fn touch_workspace(&mut self, workspace_id: String) -> Result<()> {
893        match self
894            .request(DaemonAction::TouchWorkspace { workspace_id })
895            .await?
896        {
897            DaemonReply::Done => Ok(()),
898            reply => bail!("unexpected touch-workspace reply {reply:?}"),
899        }
900    }
901
902    pub async fn delete_workspace(&mut self, workspace_id: String) -> Result<()> {
903        match self
904            .request(DaemonAction::DeleteWorkspace { workspace_id })
905            .await?
906        {
907            DaemonReply::Done => Ok(()),
908            reply => bail!("unexpected delete-workspace reply {reply:?}"),
909        }
910    }
911
912    pub async fn attach(&mut self, client_id: String, pid: u32) -> Result<()> {
913        match self
914            .request(DaemonAction::Attach { client_id, pid })
915            .await?
916        {
917            DaemonReply::Done => Ok(()),
918            reply => bail!("unexpected attach reply {reply:?}"),
919        }
920    }
921
922    pub async fn detach(&mut self, client_id: String) -> Result<()> {
923        match self.request(DaemonAction::Detach { client_id }).await? {
924            DaemonReply::Done => Ok(()),
925            reply => bail!("unexpected detach reply {reply:?}"),
926        }
927    }
928
929    pub async fn persist_read_receipt(
930        &mut self,
931        client_id: String,
932        workspace_id: String,
933        session_id: String,
934        through: u64,
935    ) -> Result<u64> {
936        match self
937            .request(DaemonAction::PersistReadReceipt {
938                client_id,
939                workspace_id,
940                session_id,
941                through,
942            })
943            .await?
944        {
945            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
946            reply => bail!("unexpected read-receipt reply {reply:?}"),
947        }
948    }
949
950    pub async fn persist_detached_session_state(
951        &mut self,
952        client_id: String,
953        workspace_id: String,
954        session_id: String,
955        through: u64,
956        owner_pid: u32,
957        draft: mj_core::storage::DetachedSessionDraft,
958    ) -> Result<()> {
959        match self
960            .request(DaemonAction::PersistDetachedSessionState {
961                client_id,
962                workspace_id,
963                session_id,
964                through,
965                owner_pid,
966                draft,
967            })
968            .await?
969        {
970            DaemonReply::Done => Ok(()),
971            reply => bail!("unexpected detached-session-state reply {reply:?}"),
972        }
973    }
974
975    pub async fn save_active_review(
976        &mut self,
977        session_id: String,
978        review: mj_core::storage::StoredReview,
979    ) -> Result<()> {
980        match self
981            .request(DaemonAction::SaveActiveReview { session_id, review })
982            .await?
983        {
984            DaemonReply::Done => Ok(()),
985            reply => bail!("unexpected save-review reply {reply:?}"),
986        }
987    }
988
989    pub async fn clear_active_review(&mut self, session_id: String) -> Result<()> {
990        match self
991            .request(DaemonAction::ClearActiveReview { session_id })
992            .await?
993        {
994            DaemonReply::Done => Ok(()),
995            reply => bail!("unexpected clear-review reply {reply:?}"),
996        }
997    }
998
999    pub async fn remember_reviewer_selection(
1000        &mut self,
1001        workspace_id: String,
1002        selection: mj_core::second_opinion::ReviewerSelection,
1003    ) -> Result<()> {
1004        match self
1005            .request(DaemonAction::RememberReviewerSelection {
1006                workspace_id,
1007                selection,
1008            })
1009            .await?
1010        {
1011            DaemonReply::Done => Ok(()),
1012            reply => bail!("unexpected reviewer-selection reply {reply:?}"),
1013        }
1014    }
1015
1016    pub async fn save_workspace_pane_sizes(
1017        &mut self,
1018        workspace_id: String,
1019        sizes: mj_core::workspace::PaneSizes,
1020    ) -> Result<()> {
1021        match self
1022            .request(DaemonAction::SaveWorkspacePaneSizes {
1023                workspace_id,
1024                sizes,
1025            })
1026            .await?
1027        {
1028            DaemonReply::Done => Ok(()),
1029            reply => bail!("unexpected pane-size save reply {reply:?}"),
1030        }
1031    }
1032
1033    pub async fn persist_imported_session(&mut self, session: SessionRecord) -> Result<()> {
1034        match self
1035            .request(DaemonAction::PersistImportedSession {
1036                session: Box::new(session),
1037            })
1038            .await?
1039        {
1040            DaemonReply::Done => Ok(()),
1041            reply => bail!("unexpected imported-session reply {reply:?}"),
1042        }
1043    }
1044
1045    pub async fn set_session_title(&mut self, session_id: String, title: String) -> Result<String> {
1046        match self
1047            .request(DaemonAction::SetSessionTitle { session_id, title })
1048            .await?
1049        {
1050            DaemonReply::Text(title) => Ok(title),
1051            reply => bail!("unexpected session-title reply {reply:?}"),
1052        }
1053    }
1054
1055    pub async fn set_session_container_settings(
1056        &mut self,
1057        session_id: String,
1058        cpus: Option<String>,
1059        memory: Option<String>,
1060        mounts: Vec<AdditionalMount>,
1061        mount_history: Vec<PathBuf>,
1062    ) -> Result<()> {
1063        match self
1064            .request(DaemonAction::SetSessionContainerSettings {
1065                session_id,
1066                cpus,
1067                memory,
1068                mounts,
1069                mount_history,
1070            })
1071            .await?
1072        {
1073            DaemonReply::Done => Ok(()),
1074            reply => bail!("unexpected container-settings reply {reply:?}"),
1075        }
1076    }
1077
1078    pub async fn set_session_acp_title(
1079        &mut self,
1080        session_id: String,
1081        title: Option<String>,
1082    ) -> Result<()> {
1083        match self
1084            .request(DaemonAction::SetSessionAcpTitle { session_id, title })
1085            .await?
1086        {
1087            DaemonReply::Done => Ok(()),
1088            reply => bail!("unexpected ACP-title reply {reply:?}"),
1089        }
1090    }
1091
1092    pub async fn mark_session_target_missing(
1093        &mut self,
1094        session_id: String,
1095        detail: String,
1096        updated_at: String,
1097    ) -> Result<Option<SessionState>> {
1098        match self
1099            .request(DaemonAction::MarkSessionTargetMissing {
1100                session_id,
1101                detail,
1102                updated_at,
1103            })
1104            .await?
1105        {
1106            DaemonReply::OptionalSessionState(state) => Ok(state),
1107            reply => bail!("unexpected target-missing reply {reply:?}"),
1108        }
1109    }
1110
1111    pub async fn checkpoint_session(
1112        &mut self,
1113        session_id: String,
1114    ) -> Result<mj_core::state::CheckpointMetadata> {
1115        match self
1116            .request(DaemonAction::CheckpointSession { session_id })
1117            .await?
1118        {
1119            DaemonReply::Checkpoint(checkpoint) => Ok(checkpoint),
1120            reply => bail!("unexpected checkpoint reply {reply:?}"),
1121        }
1122    }
1123
1124    /// Search the user's SessionWiki index, newest first when the query is
1125    /// empty and best match first otherwise. The reply carries the state of
1126    /// the index as well as the rows, so a caller can say the first build is
1127    /// still running.
1128    pub async fn wiki_search(&mut self, query: String, limit: usize) -> Result<WikiSearchPage> {
1129        match self
1130            .request(DaemonAction::WikiSearch { query, limit })
1131            .await?
1132        {
1133            DaemonReply::WikiRows(page) => Ok(page),
1134            reply => bail!("unexpected SessionWiki search reply {reply:?}"),
1135        }
1136    }
1137
1138    /// The markdown briefing for one indexed session.
1139    pub async fn wiki_brief(&mut self, wiki_id: String, max_chars: usize) -> Result<String> {
1140        match self
1141            .request(DaemonAction::WikiBrief { wiki_id, max_chars })
1142            .await?
1143        {
1144            DaemonReply::Text(markdown) => Ok(markdown),
1145            reply => bail!("unexpected SessionWiki brief reply {reply:?}"),
1146        }
1147    }
1148
1149    /// Start a new session carrying a hand-off compacted from an archived one.
1150    /// It answers like any other session start: the record exists and is
1151    /// provisioning, and the hand-off follows once the harness is ready.
1152    pub async fn wiki_restore(&mut self, request: WikiRestoreRequest) -> Result<RegisteredSession> {
1153        match self.request(DaemonAction::WikiRestore(request)).await? {
1154            DaemonReply::RegisteredSession(registered) => Ok(*registered),
1155            reply => bail!("unexpected SessionWiki restore reply {reply:?}"),
1156        }
1157    }
1158
1159    pub async fn scan_recovery(
1160        &mut self,
1161        all_instances: bool,
1162    ) -> Result<mj_core::state::RecoveryScan> {
1163        match self
1164            .request(DaemonAction::ScanRecovery { all_instances })
1165            .await?
1166        {
1167            DaemonReply::RecoveryScan(scan) => Ok(scan),
1168            reply => bail!("unexpected recovery-scan reply {reply:?}"),
1169        }
1170    }
1171
1172    pub async fn adopt_recovery(
1173        &mut self,
1174        session_id: String,
1175        target_id: String,
1176        profile: Option<String>,
1177        bundle: Option<String>,
1178        all_instances: bool,
1179    ) -> Result<()> {
1180        match self
1181            .request(DaemonAction::AdoptRecovery {
1182                session_id,
1183                target_id,
1184                profile,
1185                bundle,
1186                all_instances,
1187            })
1188            .await?
1189        {
1190            DaemonReply::Done => Ok(()),
1191            reply => bail!("unexpected recovery-adopt reply {reply:?}"),
1192        }
1193    }
1194
1195    pub async fn destroy_recovery(
1196        &mut self,
1197        session_id: String,
1198        target_id: String,
1199        confirmation: String,
1200        all_instances: bool,
1201    ) -> Result<()> {
1202        match self
1203            .request(DaemonAction::DestroyRecovery {
1204                session_id,
1205                target_id,
1206                confirmation,
1207                all_instances,
1208            })
1209            .await?
1210        {
1211            DaemonReply::Done => Ok(()),
1212            reply => bail!("unexpected recovery-destroy reply {reply:?}"),
1213        }
1214    }
1215
1216    pub async fn snapshot(&mut self, workspace_id: String) -> Result<WorkspaceSnapshot> {
1217        match self
1218            .request(DaemonAction::Snapshot { workspace_id })
1219            .await?
1220        {
1221            DaemonReply::Snapshot(snapshot) => Ok(snapshot),
1222            reply => bail!("unexpected snapshot reply {reply:?}"),
1223        }
1224    }
1225
1226    pub async fn runtime_snapshot(
1227        &mut self,
1228        workspace_id: String,
1229        after_revision: u64,
1230        all_workspaces: bool,
1231    ) -> Result<RuntimeSnapshot> {
1232        match self
1233            .request(DaemonAction::RuntimeSnapshot {
1234                workspace_id,
1235                after_revision,
1236                all_workspaces,
1237            })
1238            .await?
1239        {
1240            DaemonReply::RuntimeSnapshot(snapshot) => Ok(*snapshot),
1241            reply => bail!("unexpected runtime snapshot reply {reply:?}"),
1242        }
1243    }
1244
1245    pub async fn submit_session_command(
1246        &mut self,
1247        session_id: String,
1248        command_id: String,
1249        command: RelayCommand,
1250        inherited_draft: Option<String>,
1251    ) -> Result<u64> {
1252        match self
1253            .request(DaemonAction::SubmitSessionCommand {
1254                inherited_draft,
1255                session_id,
1256                command_id,
1257                command,
1258            })
1259            .await?
1260        {
1261            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1262            reply => bail!("unexpected session command reply {reply:?}"),
1263        }
1264    }
1265
1266    /// Ask the daemon to review the turn this session just finished.
1267    ///
1268    /// The refusal is a sentence for a person -- "prompts are queued", "set
1269    /// [review] profile in config.toml" -- so it travels as text rather than
1270    /// as a code every surface would have to translate.
1271    pub async fn start_turn_review(&mut self, session_id: String) -> Result<()> {
1272        match self
1273            .request(DaemonAction::StartTurnReview { session_id })
1274            .await?
1275        {
1276            DaemonReply::Done => Ok(()),
1277            reply => bail!("unexpected turn-review reply {reply:?}"),
1278        }
1279    }
1280
1281    pub async fn resolve_turn_review(
1282        &mut self,
1283        session_id: String,
1284        resolution: Resolution,
1285    ) -> Result<()> {
1286        match self
1287            .request(DaemonAction::ResolveTurnReview {
1288                session_id,
1289                resolution,
1290            })
1291            .await?
1292        {
1293            DaemonReply::Done => Ok(()),
1294            reply => bail!("unexpected turn-review resolution reply {reply:?}"),
1295        }
1296    }
1297
1298    pub async fn reviewer_action(
1299        &mut self,
1300        session_id: String,
1301        role: Option<String>,
1302        action: crate::session::ReviewerAction,
1303    ) -> Result<crate::session::ReviewerOutcome> {
1304        match self
1305            .request(DaemonAction::ReviewerAction {
1306                session_id,
1307                role,
1308                action,
1309            })
1310            .await?
1311        {
1312            DaemonReply::Reviewer(outcome) => Ok(*outcome),
1313            reply => bail!("unexpected reviewer reply {reply:?}"),
1314        }
1315    }
1316
1317    pub async fn sync_session(&mut self, session_id: String) -> Result<()> {
1318        match self
1319            .request(DaemonAction::SyncSession { session_id })
1320            .await?
1321        {
1322            DaemonReply::Done => Ok(()),
1323            reply => bail!("unexpected session sync reply {reply:?}"),
1324        }
1325    }
1326
1327    pub async fn respond_elicitation(
1328        &mut self,
1329        session_id: String,
1330        elicitation_id: String,
1331        response: ElicitationResponse,
1332    ) -> Result<()> {
1333        match self
1334            .request(DaemonAction::RespondElicitation {
1335                session_id,
1336                elicitation_id,
1337                response,
1338            })
1339            .await?
1340        {
1341            DaemonReply::Done => Ok(()),
1342            reply => bail!("unexpected elicitation reply {reply:?}"),
1343        }
1344    }
1345
1346    pub async fn stop_background_task(
1347        &mut self,
1348        session_id: String,
1349        background_task_id: String,
1350    ) -> Result<()> {
1351        match self
1352            .request(DaemonAction::StopBackgroundTask {
1353                session_id,
1354                background_task_id,
1355            })
1356            .await?
1357        {
1358            DaemonReply::Done => Ok(()),
1359            reply => bail!("unexpected background task stop reply {reply:?}"),
1360        }
1361    }
1362
1363    pub async fn close_session(&mut self, session_id: String) -> Result<()> {
1364        match self
1365            .request(DaemonAction::CloseSession { session_id })
1366            .await?
1367        {
1368            DaemonReply::Done => Ok(()),
1369            reply => bail!("unexpected close-session reply {reply:?}"),
1370        }
1371    }
1372
1373    pub async fn start_create_session(
1374        &mut self,
1375        request: CreateSessionRequest,
1376    ) -> Result<RegisteredSession> {
1377        match self
1378            .request(DaemonAction::StartCreateSession(request))
1379            .await?
1380        {
1381            DaemonReply::RegisteredSession(registered) => Ok(*registered),
1382            reply => bail!("unexpected start-create reply {reply:?}"),
1383        }
1384    }
1385
1386    pub async fn wait_create_session(&mut self, session_id: String) -> Result<()> {
1387        match self
1388            .request(DaemonAction::WaitCreateSession { session_id })
1389            .await?
1390        {
1391            DaemonReply::Done => Ok(()),
1392            reply => bail!("unexpected wait-create reply {reply:?}"),
1393        }
1394    }
1395
1396    pub async fn resume_session(&mut self, request: ResumeSessionRequest) -> Result<()> {
1397        match self.request(DaemonAction::ResumeSession(request)).await? {
1398            DaemonReply::Done => Ok(()),
1399            reply => bail!("unexpected resume-session reply {reply:?}"),
1400        }
1401    }
1402
1403    pub async fn force_stop_session(&mut self, session_id: String) -> Result<()> {
1404        match self
1405            .request(DaemonAction::ForceStopSession { session_id })
1406            .await?
1407        {
1408            DaemonReply::Done => Ok(()),
1409            reply => bail!("unexpected force-stop reply {reply:?}"),
1410        }
1411    }
1412
1413    pub async fn destroy_stopped_session(&mut self, session_id: String) -> Result<()> {
1414        match self
1415            .request(DaemonAction::DestroyStoppedSession { session_id })
1416            .await?
1417        {
1418            DaemonReply::Done => Ok(()),
1419            reply => bail!("unexpected destroy-stopped reply {reply:?}"),
1420        }
1421    }
1422
1423    pub async fn force_destroy_session(&mut self, session_id: String) -> Result<()> {
1424        match self
1425            .request(DaemonAction::ForceDestroySession { session_id })
1426            .await?
1427        {
1428            DaemonReply::Done => Ok(()),
1429            reply => bail!("unexpected force-destroy reply {reply:?}"),
1430        }
1431    }
1432
1433    pub async fn force_delete_workspace(&mut self, workspace_id: String) -> Result<()> {
1434        match self
1435            .request(DaemonAction::ForceDeleteWorkspace { workspace_id })
1436            .await?
1437        {
1438            DaemonReply::Done => Ok(()),
1439            reply => bail!("unexpected force-delete-workspace reply {reply:?}"),
1440        }
1441    }
1442
1443    pub async fn cancel_lifecycle(&mut self, session_id: String) -> Result<()> {
1444        match self
1445            .request(DaemonAction::CancelLifecycle { session_id })
1446            .await?
1447        {
1448            DaemonReply::Done => Ok(()),
1449            reply => bail!("unexpected cancel-lifecycle reply {reply:?}"),
1450        }
1451    }
1452
1453    pub async fn recover_draft(&mut self, draft_id: String) -> Result<()> {
1454        match self
1455            .request(DaemonAction::RecoverDraft { draft_id })
1456            .await?
1457        {
1458            DaemonReply::Done => Ok(()),
1459            reply => bail!("unexpected recover-draft reply {reply:?}"),
1460        }
1461    }
1462
1463    pub async fn stop(&mut self) -> Result<()> {
1464        match self.request(DaemonAction::Stop).await? {
1465            DaemonReply::Done => Ok(()),
1466            reply => bail!("unexpected stop reply {reply:?}"),
1467        }
1468    }
1469}
1470
1471pub async fn connect_existing() -> Result<DaemonClient> {
1472    let metadata = tokio::task::spawn_blocking(read_metadata)
1473        .await
1474        .context("read daemon metadata task failed")??;
1475    DaemonClient::connect(metadata).await
1476}
1477
1478/// A handle to whatever daemon the metadata file advertises, regardless of its
1479/// protocol version. It only exposes the frozen management subset (`Ping`,
1480/// `Status`, `Stop`), which encodes identically in every protocol version.
1481pub struct ManagementClient {
1482    inner: DaemonClient,
1483}
1484
1485impl ManagementClient {
1486    pub fn new(inner: DaemonClient) -> Self {
1487        Self { inner }
1488    }
1489    pub fn protocol_version(&self) -> u32 {
1490        self.inner.metadata.protocol_version
1491    }
1492
1493    pub async fn status(&mut self) -> Result<DaemonStatus> {
1494        self.inner.status().await
1495    }
1496
1497    pub async fn stop(&mut self) -> Result<()> {
1498        tokio::time::timeout(STOP_TIMEOUT, self.inner.stop())
1499            .await
1500            .context("Mjolnir daemon did not acknowledge the stop before the deadline")?
1501    }
1502
1503    /// Ask the daemon to stop and wait for its process to actually exit.
1504    pub async fn stop_and_wait(mut self) -> Result<()> {
1505        let pid = self.inner.metadata.pid;
1506        self.stop().await?;
1507        wait_for_exit(pid).await.with_context(|| {
1508            format!(
1509                "Mjolnir daemon {pid} accepted the stop but was still running after {}s",
1510                STOP_TIMEOUT.as_secs()
1511            )
1512        })
1513    }
1514}
1515
1516pub async fn connect_management() -> Result<ManagementClient> {
1517    Ok(ManagementClient {
1518        inner: DaemonClient::connect(read_metadata_any()?).await?,
1519    })
1520}
1521
1522pub fn ensure_supported_daemon_protocol(version: u32) -> Result<()> {
1523    ensure!(
1524        version <= PROTOCOL_VERSION,
1525        "the daemon uses a newer protocol ({version}) than this client ({PROTOCOL_VERSION}); restart this client with the updated mj binary"
1526    );
1527    Ok(())
1528}
1529pub const PROTOCOL_VERSION: u32 = 21;
1530pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
1531/// How long a daemon is given to exit after it accepts a stop.
1532///
1533/// Stopping cancels a token and returns immediately; the daemon then unwinds
1534/// its session manager, its phone server and its pollers. That is normally
1535/// fast, but a daemon whose database has been migrated out from under it fails
1536/// every read while it winds down and has been observed taking over five
1537/// seconds — which the previous five-second bound missed by a fraction,
1538/// reporting a stop that had in fact worked as `did not stop` and aborting the
1539/// restart that depended on it.
1540pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
1541pub const RETRY_DELAY: Duration = Duration::from_millis(40);
1542impl DaemonClient {
1543    pub async fn prepare_move_session(
1544        &mut self,
1545        selection: MoveSelection,
1546    ) -> Result<MovePreparation> {
1547        match self
1548            .request(DaemonAction::PrepareMoveSession(selection))
1549            .await?
1550        {
1551            DaemonReply::MovePreparation(preparation) => Ok(*preparation),
1552            _ => bail!("daemon returned an unexpected move preparation reply"),
1553        }
1554    }
1555
1556    pub async fn move_session(&mut self, request: MoveSessionRequest) -> Result<MoveOutcome> {
1557        match self.request(DaemonAction::MoveSession(request)).await? {
1558            DaemonReply::MoveOutcome(outcome) => Ok(outcome),
1559            _ => bail!("daemon returned an unexpected move reply"),
1560        }
1561    }
1562}