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