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