Skip to main content

mj_client/
daemon.rs

1//! Authenticated local daemon protocol and client transport.
2use crate::executable::describe_running_daemon_and_client_builds;
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::fs;
15use std::net::SocketAddr;
16use std::path::PathBuf;
17use std::time::{Duration, Instant};
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::TcpStream;
20pub fn metadata_path() -> PathBuf {
21    data_dir().join("daemon.json")
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct DaemonMetadata {
27    pub protocol_version: u32,
28    pub pid: u32,
29    pub address: SocketAddr,
30    pub token: String,
31    pub started_at: String,
32    pub build_version: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct WorkspaceListing {
38    pub workspace: WorkspaceRecord,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct SessionPreview {
44    pub id: String,
45    pub title: String,
46    pub project: String,
47    pub harness: String,
48    pub state: String,
49    pub active: bool,
50    pub updated_at: String,
51}
52
53/// One session in the user's SessionWiki index, as a control surface shows it.
54///
55/// It is the daemon's own shape rather than SessionWiki's row: it carries the
56/// search snippet that found the row and, for a Mjolnir session this daemon
57/// still holds, the id that resumes it instead of restoring it.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct WikiRow {
61    pub id: String,
62    pub tool: String,
63    pub project: String,
64    pub title: String,
65    pub started: Option<String>,
66    pub msgs: i64,
67    pub preview: Option<String>,
68    /// The tool deleted its own copy and SessionWiki kept the transcript.
69    pub archived: bool,
70    /// The session id the tool that ran it knows it by, when its stored path
71    /// carries one. It is what matches a row against an import scan.
72    pub native_id: Option<String>,
73    /// The matching text, when this row came from a search.
74    pub snippet: Option<String>,
75    /// The live Mjolnir session this row describes, when this daemon has it.
76    pub hel_session_id: Option<String>,
77    /// The Mjolnir target template the session ran under, when the index
78    /// carries it. Only Mjolnir's own rows have one: the daemon writes it into
79    /// the index as an `mj-target:` tag while the session is still known, so an
80    /// archived row can still say where it ran.
81    #[serde(default)]
82    pub target: Option<String>,
83    /// The Mjolnir harness profile the session last ran under, from the index's
84    /// `mj-profile:` tag. Only Mjolnir's own rows have one.
85    #[serde(default)]
86    pub profile: Option<String>,
87    /// The harness kind the session ran (`codex`, `claude`, `kimi`, `grok`,
88    /// `muse`), from the index's `mj-harness:` tag. It stays meaningful after
89    /// the profile id has been removed from the configuration.
90    #[serde(default)]
91    pub harness: Option<String>,
92}
93
94/// How far along the daemon's SessionWiki index is when a search answers.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum WikiIndexState {
98    /// The index has completed one full build; its answers are complete.
99    Ready,
100    /// The first full build has not finished yet, so a search can miss
101    /// sessions that exist. This is the state a fresh index starts in.
102    #[default]
103    Indexing,
104    /// The index file on disk was written by a different SessionWiki schema
105    /// version. Mjolnir will not open it, because opening it would drop and
106    /// rebuild the user's whole cache.
107    VersionMismatch,
108}
109
110/// What a search says about the index it answered from.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct WikiStatus {
114    pub state: WikiIndexState,
115    /// A sync is running now, so repeating the query may return more.
116    pub topping_up: bool,
117}
118
119/// Where in a session's conversation a text search matched. A match in a
120/// user message ranks ahead of one in an agent message; a match only in tool
121/// output is not a match at all.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum SessionTextMatchKind {
125    User,
126    Agent,
127}
128
129/// One live session whose conversation contains a search query.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct SessionTextMatch {
133    pub session_id: String,
134    pub kind: SessionTextMatchKind,
135}
136
137/// One page of search results with the state of the index behind them.
138#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct WikiSearchPage {
141    pub rows: Vec<WikiRow>,
142    pub status: WikiStatus,
143}
144
145/// One message of an indexed transcript, reduced to what a search preview
146/// shows: the text around the query's matches, with the matches located in it.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct WikiHitBlock {
150    /// `user`, `assistant` or `tool`.
151    pub role: String,
152    /// The message text, redacted, and windowed to the caller's per-message
153    /// budget when the message is longer than that.
154    pub text: String,
155    /// Byte ranges of the matches inside `text`, on character boundaries, in
156    /// order. A context message has none.
157    pub hits: Vec<(usize, usize)>,
158    /// Messages between the previous block and this one that no group covered.
159    /// Non-zero only on the first block of a group.
160    pub omitted_before: usize,
161    /// `text` is a window of the message rather than the whole of it.
162    pub truncated: bool,
163}
164
165/// The matching passages of one indexed transcript.
166#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct WikiHitTranscript {
169    pub blocks: Vec<WikiHitBlock>,
170    /// Messages after the last block that no group covered.
171    pub omitted_after: usize,
172}
173
174/// What one indexed session is, as far as continuing it is concerned.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum WikiSessionStatus {
178    /// A Mjolnir session this daemon still has a record of.
179    Mine,
180    /// A Mjolnir session whose record the archive job destroyed; only the
181    /// indexed transcript is left.
182    Archived,
183    /// Another tool's session, which Mjolnir would have to import.
184    Native,
185}
186
187/// One row of the SessionWiki index, with what Mjolnir knows about it.
188///
189/// This is what `mj resume --wiki` branches on and what `mj sessions
190/// --session` reports when the id names no Mjolnir session.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct WikiSessionInfo {
194    /// The SessionWiki id of the row.
195    pub wiki_id: String,
196    /// The tool that produced the session, as SessionWiki names it.
197    pub tool: String,
198    /// The transcript path the index stores for the row.
199    pub path: PathBuf,
200    pub status: WikiSessionStatus,
201    /// The Mjolnir session id, for a row Mjolnir itself published.
202    pub mjolnir_session_id: Option<String>,
203    /// The profile the session last ran under, from the index's own tags.
204    pub profile_id: Option<String>,
205    /// The target template the session ran on, from the index's own tags.
206    pub target_template_id: Option<String>,
207    /// The harness that drove the session, from the tags for a Mjolnir row and
208    /// from the tool name for a native one.
209    pub harness: Option<mj_core::config::HarnessKind>,
210    pub title: String,
211    pub project: String,
212    /// Set for an archived Mjolnir row whose transcript holds no prompt,
213    /// which `mj resume --wiki` has nothing to restore from. Sent only when
214    /// set, so a daemon that predates it and a client that predates it both
215    /// read every other row unchanged.
216    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217    pub nothing_to_restore: bool,
218}
219
220/// Start a new session carrying a compacted hand-off from an archived one.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct WikiRestoreRequest {
224    /// The SessionWiki session id to restore from.
225    pub wiki_id: String,
226    pub workspace_id: String,
227    pub profile_id: String,
228    pub target_template_id: String,
229    /// Where the new session opens. None takes the project the archived
230    /// session ran in, when that directory still exists.
231    #[serde(default)]
232    pub project_directory: Option<PathBuf>,
233    #[serde(default)]
234    pub additional_mounts: Vec<AdditionalMount>,
235    #[serde(default)]
236    pub resource_allocation: Option<SessionResourceAllocation>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct WorkspaceSnapshot {
242    pub workspace: WorkspaceRecord,
243    pub sessions: Vec<SessionPreview>,
244    pub drafts: Vec<DraftPreview>,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct RuntimeSessionView {
250    pub session_id: String,
251    pub projection_ordinal: u64,
252    pub projection_digest: String,
253    pub operational: Option<RelayOperationalState>,
254    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
255    pub connected: bool,
256    pub error: Option<ViewError>,
257}
258
259impl RuntimeSessionView {
260    pub fn from_managed(session_id: String, view: ManagedSessionView) -> Self {
261        let (projection_ordinal, projection_digest, operational, signal) =
262            view.snapshot
263                .map_or((0, String::new(), None, None), |snapshot| {
264                    (
265                        snapshot.materialized.applied_event_ordinal,
266                        snapshot.materialized.applied_event_digest,
267                        Some(snapshot.operational),
268                        snapshot.latest_credential_sync_signal,
269                    )
270                });
271        Self {
272            session_id,
273            projection_ordinal,
274            projection_digest,
275            operational,
276            latest_credential_sync_signal: signal,
277            connected: view.connected,
278            error: view.error,
279        }
280    }
281}
282
283/// Something the daemon did on its own that a surface should report once.
284///
285/// Background work has no lifecycle entry to hang a message on, so notices
286/// travel with the snapshot and carry an id: a surface reports the ones newer
287/// than the last it saw and nothing else, however often it polls.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct RuntimeNotice {
291    pub id: u64,
292    pub session_id: String,
293    pub text: String,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum RuntimeLifecycleKind {
299    Create,
300    Suspend,
301    Resume,
302    Move,
303    ForceStop,
304    DestroyStopped,
305    ForceDestroy,
306    /// A sub-agent stopped and removed because its parent is being
307    /// suspended. The same teardown as `ForceDestroy`, which surfaces call a
308    /// stop, as the suspend does.
309    StopSubagent,
310    Cleanup,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct RuntimeLifecycleView {
316    pub operation_id: String,
317    pub cancellable: bool,
318    pub session_id: String,
319    pub kind: RuntimeLifecycleKind,
320    pub started_at_epoch_seconds: u64,
321    pub active_stages: Vec<(ProvisionStage, u64)>,
322    pub resume_destination: Option<(String, String)>,
323    pub notice: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct ResumeSessionRequest {
329    pub session_id: String,
330    pub workspace_id: String,
331    pub profile_id: String,
332    pub target_template_id: String,
333    pub additional_mounts: Option<Vec<AdditionalMount>>,
334    pub resource_allocation: Option<SessionResourceAllocation>,
335    pub discard_queue: bool,
336    pub repository_preflight: Option<ResumeRepositorySourceReceipt>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct CreateSessionRequest {
342    #[serde(default)]
343    pub create_managed_worktree: Option<bool>,
344    /// Full commit object ID to start the workspace at: an exact checkout of
345    /// the bundle's primary repository, detached unless `branch` is given.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub at: Option<String>,
348    /// With `at`, the new branch created there; otherwise the existing branch
349    /// to check out in a new isolated workspace.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub branch: Option<String>,
352    /// Diff base, when it is not `at`. Without `at`, also the starting
353    /// revision for a raw managed worktree.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub base: Option<String>,
356    /// Omitted uses the selected profile's subagent setting.
357    #[serde(
358        default,
359        alias = "mjolnir_subagents",
360        deserialize_with = "mj_core::subagent::deserialize_optional_policy"
361    )]
362    pub subagents: Option<mj_core::subagent::SubagentPolicy>,
363    /// Omitted follows `[review]`.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub review: Option<mj_core::config::SessionReview>,
366    /// Start the session with no project memory. See
367    /// `mj_core::state::SessionRecord::no_project_memory`.
368    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
369    pub no_project_memory: bool,
370    /// Start the session with no agent mailbox. See
371    /// `mj_core::state::SessionRecord::no_mailbox`.
372    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
373    pub no_mailbox: bool,
374    #[serde(default)]
375    pub initial_prompt: Option<String>,
376    pub workspace_id: String,
377    pub profile_id: String,
378    pub bundle_id: String,
379    pub project_directory: Option<PathBuf>,
380    pub target_template_id: String,
381    pub additional_mounts: Vec<AdditionalMount>,
382    pub resource_allocation: Option<SessionResourceAllocation>,
383    pub title: String,
384    pub session_title_override: Option<String>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct RegisteredSession {
390    pub session: SessionRecord,
391    pub remembered_container_size: Option<(String, HostContainerSize)>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct DraftPreview {
397    pub id: String,
398    pub session_id: Option<String>,
399    pub source: String,
400    pub owner_pid: Option<u32>,
401    pub saved_at: String,
402}
403
404/// `Ping`, `Status`, and `Stop` form the frozen management subset: their wire
405/// encoding — together with `RequestEnvelope`, `ResponseEnvelope`,
406/// `DaemonStatus`, and `WebViewerStatus` — must never change shape, because
407/// clients and daemons of *any* protocol version rely on them to identify,
408/// stop, and replace each other. `PrepareUpgrade` and its `Done` /
409/// `UpgradePending` replies are also frozen from protocol 33 onward: they
410/// drain accepted work before closing admission atomically. Every other
411/// action may change freely behind a `PROTOCOL_VERSION` bump.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case", tag = "action", content = "arguments")]
414pub enum DaemonAction {
415    NativeAgentHistory {
416        owner: String,
417        child: String,
418        before: Option<(u64, String)>,
419    },
420    Ping,
421    /// Frozen upgrade handshake, available from daemon protocol 33 onward.
422    /// Busy replies leave every operation and control channel running.
423    PrepareUpgrade,
424    /// Names the daemon-owned work that is currently holding an automatic
425    /// handoff open, for the CLI's wait notice.
426    ///
427    /// Added after protocol 33, so it is not part of the frozen management
428    /// subset. A daemon that predates it cannot deserialize the frame: it
429    /// fails the request and closes the connection. Callers must therefore
430    /// treat any failure, including a dropped connection or an error frame,
431    /// as "unknown" and say nothing about what the upgrade is waiting for.
432    UpgradeBlockers,
433    Status,
434    WebViewerAccess,
435    RecoverWebViewer(crate::web::WebViewerRecovery),
436    InspectWebListener,
437    ListWorkspaces,
438    CreateWorkspace {
439        name: String,
440    },
441    RenameWorkspace {
442        workspace_id: String,
443        name: String,
444    },
445    TouchWorkspace {
446        workspace_id: String,
447    },
448    CloseWorkspace {
449        workspace_id: String,
450    },
451    CancelWorkspaceClose {
452        workspace_id: String,
453    },
454    DeleteWorkspace {
455        workspace_id: String,
456    },
457    Attach {
458        client_id: String,
459        pid: u32,
460    },
461    Detach {
462        client_id: String,
463    },
464    PersistReadReceipt {
465        client_id: String,
466        workspace_id: String,
467        session_id: String,
468        through: u64,
469    },
470    PersistDetachedSessionState {
471        client_id: String,
472        workspace_id: String,
473        session_id: String,
474        through: u64,
475        owner_pid: u32,
476        draft: mj_core::storage::DetachedSessionDraft,
477    },
478    SaveActiveReview {
479        session_id: String,
480        review: mj_core::storage::StoredReview,
481    },
482    ClearActiveReview {
483        session_id: String,
484    },
485    RecordAbandonedPrompt {
486        session_id: String,
487        bundle_id: String,
488        text: String,
489    },
490    SaveWorkspacePaneSizes {
491        workspace_id: String,
492        sizes: mj_core::workspace::PaneSizes,
493    },
494    SaveWorkspaceLayout {
495        workspace_id: String,
496        layout: mj_core::workspace::ConversationLayout,
497    },
498    PersistImportedSession {
499        session: Box<SessionRecord>,
500    },
501    SetSessionTitle {
502        session_id: String,
503        title: String,
504    },
505    SetSessionWorkspace {
506        session_id: String,
507        workspace_id: String,
508    },
509    SetSessionContainerSettings {
510        session_id: String,
511        cpus: Option<String>,
512        memory: Option<String>,
513        mounts: Vec<AdditionalMount>,
514        mount_history: Vec<PathBuf>,
515    },
516    SetSessionAcpTitle {
517        session_id: String,
518        title: Option<String>,
519    },
520    MarkSessionTargetMissing {
521        session_id: String,
522        detail: String,
523        updated_at: String,
524    },
525    CheckpointSession {
526        session_id: String,
527    },
528    /// Search the user's SessionWiki index. An empty query lists the most
529    /// recent sessions.
530    ProjectCatalog {
531        refresh: bool,
532        retry: bool,
533    },
534    CreateProject {
535        sources: Vec<String>,
536    },
537    WikiSearch {
538        query: String,
539        limit: usize,
540    },
541    /// The live sessions whose user or agent messages contain a query,
542    /// ignoring case. Tool calls and tool output do not count.
543    SessionTextSearch {
544        query: String,
545    },
546    /// The markdown briefing for one indexed session.
547    WikiBrief {
548        wiki_id: String,
549        max_chars: usize,
550    },
551    /// The passages of one indexed session that match a query, with context.
552    WikiHits {
553        wiki_id: String,
554        query: String,
555        context_messages: usize,
556        per_message_chars: usize,
557    },
558    /// What one indexed session is, and what continuing it would mean.
559    WikiSession {
560        wiki_id: String,
561    },
562    /// Start a new session from an archived one's transcript.
563    WikiRestore(WikiRestoreRequest),
564    ScanRecovery {
565        all_instances: bool,
566    },
567    AdoptRecovery {
568        session_id: String,
569        target_id: String,
570        profile: Option<String>,
571        bundle: Option<String>,
572        all_instances: bool,
573    },
574    DestroyRecovery {
575        session_id: String,
576        target_id: String,
577        confirmation: String,
578        all_instances: bool,
579    },
580    Snapshot {
581        workspace_id: String,
582    },
583    RuntimeChanges {
584        cursor: Option<crate::runtime_feed::RuntimeCursor>,
585        wait: bool,
586    },
587    /// One live session's transcript tail as it was at `cursor`, so the
588    /// deltas after that cursor apply to it exactly.
589    SessionTail {
590        session_id: String,
591        cursor: crate::runtime_feed::RuntimeCursor,
592    },
593    /// The sessions the resume dialog lists. The runtime feed carries only
594    /// live sessions, so the dialog asks for these when it opens.
595    ResumeCandidates,
596    /// One session's whole record, live or stopped. The resume wizard reads
597    /// it for the row picked in the resume dialog.
598    SessionRecord {
599        session_id: String,
600    },
601    /// The session `mj go` opens: the remembered one while it is still
602    /// eligible, otherwise the most recently updated eligible session of the
603    /// workspace, live or stopped.
604    GoStartupSession {
605        workspace_id: String,
606        last_session_id: Option<String>,
607    },
608    /// Ask the daemon's quota poller to probe now. The daemon is the only
609    /// process that probes; the result arrives in the runtime feed. Added in
610    /// protocol 43.
611    RefreshQuota,
612    /// Discover eligible subagent models in the daemon, which owns the cache.
613    SubagentOptions {
614        profile: String,
615        model: Option<String>,
616        /// Setup can probe an unsaved draft without changing live settings.
617        #[serde(default, skip_serializing_if = "Option::is_none")]
618        config: Option<Box<Config>>,
619    },
620    /// Ensure valid unsaved profile definitions are hydrating. Results arrive
621    /// through runtime publications; this request does no discovery itself.
622    WarmProfileCapabilities {
623        config: Box<Config>,
624    },
625    RenameProfile {
626        old_id: String,
627        new_id: String,
628    },
629    RenameTarget {
630        old_id: String,
631        new_id: String,
632    },
633    SubmitSessionCommand {
634        #[serde(default)]
635        inherited_draft: Option<String>,
636        session_id: String,
637        command_id: String,
638        command: RelayCommand,
639    },
640    /// Deliver a prompt typed while a session was still starting, once the
641    /// daemon sees that session's harness become ready. The daemon owns the
642    /// wait, so the prompt arrives whether or not this client is still
643    /// running or still showing that session.
644    QueueStartupPrompt {
645        session_id: String,
646        text: String,
647        /// The saved draft text this prompt was typed from, if the client
648        /// also persisted it. Cleared after a successful submit so the
649        /// delivered prompt does not reappear as a draft.
650        #[serde(default)]
651        inherited_draft: Option<String>,
652    },
653    /// Take back a prompt queued with `QueueStartupPrompt` that the daemon has
654    /// not started delivering, so the person can edit it. The daemon cancels
655    /// the newest queued prompt with this exact text and replies
656    /// `PromptWithdrawn(true)`, or `PromptWithdrawn(false)` when none is
657    /// waiting because delivery already started.
658    WithdrawStartupPrompt {
659        session_id: String,
660        text: String,
661    },
662    SyncSession {
663        session_id: String,
664    },
665    RespondElicitation {
666        session_id: String,
667        elicitation_id: String,
668        response: ElicitationResponse,
669    },
670    StopBackgroundTask {
671        session_id: String,
672        background_task_id: String,
673    },
674    /// Drive a session's second-opinion reviewer. The reviewer is a sidecar of
675    /// the session's worker, so it travels the session's own relay rather than
676    /// becoming a session of its own here.
677    ReviewerAction {
678        session_id: String,
679        /// Which reviewing role the action drives; absent means the default
680        /// one, which is what plan review uses.
681        #[serde(default, skip_serializing_if = "Option::is_none")]
682        role: Option<String>,
683        action: crate::session::ReviewerAction,
684    },
685    /// Review the turn this session just finished, on a surface's request.
686    StartTurnReview {
687        session_id: String,
688    },
689    /// Forward, dismiss, or cancel the open review.
690    ResolveTurnReview {
691        session_id: String,
692        resolution: Resolution,
693    },
694    SuspendSession {
695        session_id: String,
696        #[serde(default)]
697        acknowledge_unpublished_work: bool,
698    },
699    RestartSession {
700        session_id: String,
701    },
702    StartCreateSession(CreateSessionRequest),
703    WaitCreateSession {
704        session_id: String,
705    },
706    ResumeSession(ResumeSessionRequest),
707    PrepareMoveSession(MoveSelection),
708    MoveSession(MoveSessionRequest),
709    MoveSources {
710        session_id: String,
711        cleanup_operation_id: Option<String>,
712    },
713    DiscardSinceCheckpoint {
714        session_id: String,
715        checkpoint: mj_core::state::CheckpointMetadata,
716    },
717    DestroyStoppedSession {
718        session_id: String,
719        /// Whether to delete the session's managed git branch as well. The
720        /// branch can hold work the user still wants, so destroying keeps it
721        /// unless the request asks for the deletion.
722        delete_branch: bool,
723    },
724    ForceDestroySession {
725        session_id: String,
726        /// See [`DaemonAction::DestroyStoppedSession`].
727        delete_branch: bool,
728    },
729    CancelLifecycle {
730        session_id: String,
731    },
732    RecoverDraft {
733        draft_id: String,
734    },
735    Stop,
736}
737
738#[derive(Debug, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct RequestEnvelope {
741    pub protocol_version: u32,
742    pub request_id: u64,
743    pub token: String,
744    pub action: DaemonAction,
745}
746
747/// The daemon answered a request with an error. Unlike a lost connection,
748/// this is a complete round trip: the daemon read the request and said why it
749/// did not carry it out.
750#[derive(Debug)]
751pub struct DaemonRefusal(pub String);
752
753impl std::fmt::Display for DaemonRefusal {
754    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
755        f.write_str(&self.0)
756    }
757}
758
759impl std::error::Error for DaemonRefusal {}
760
761impl DaemonRefusal {
762    /// Whether the daemon itself could not confirm delivery. Its message is
763    /// then led by `session::DeliveryUnconfirmed`.
764    #[must_use]
765    pub fn delivery_unconfirmed(&self) -> bool {
766        self.0
767            .starts_with(&crate::session::DeliveryUnconfirmed.to_string())
768    }
769}
770
771#[derive(Debug, Serialize, Deserialize)]
772#[serde(deny_unknown_fields)]
773pub struct ResponseEnvelope {
774    pub protocol_version: u32,
775    pub request_id: u64,
776    pub result: std::result::Result<DaemonReply, String>,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case", tag = "reply", content = "value")]
781pub enum DaemonReply {
782    NativeAgentHistory(mj_core::native_agent::NativeAgentHistoryPage),
783    Pong,
784    UpgradePending,
785    /// Labels for the work holding an automatic handoff open, empty when
786    /// nothing is. Answers `DaemonAction::UpgradeBlockers`.
787    UpgradeBlockers(Vec<String>),
788    Status(DaemonStatus),
789    WebViewerAccess(crate::web::WebViewerAccess),
790    WebListeners(Vec<crate::web::WebListenerProcess>),
791    SubagentOptions(mj_core::subagent::SubagentOptions),
792    Workspaces(Vec<WorkspaceListing>),
793    Workspace(WorkspaceRecord),
794    Snapshot(WorkspaceSnapshot),
795    RuntimeChanges(Box<crate::runtime_feed::RuntimeFrame>),
796    SessionTail(Box<crate::runtime_feed::SessionTailReply>),
797    ResumeCandidates(Box<ResumeCandidates>),
798    GoStartupSession(Option<Box<SessionRecord>>),
799    SessionRecord(Option<Box<SessionRecord>>),
800    /// Transport fragments of one chunked reply (see
801    /// [`DaemonReply::is_chunked`]); never exposed to consumers.
802    ReplyChunk {
803        bytes: Vec<u8>,
804        finished: bool,
805    },
806    RegisteredSession(Box<RegisteredSession>),
807    MovePreparation(Box<MovePreparation>),
808    MoveOutcome(MoveOutcome),
809    MoveSources(Vec<mj_core::move_workspace::RetainedMoveSource>),
810    Ordinal(u64),
811    Text(String),
812    OptionalSessionState(Option<SessionState>),
813    Checkpoint(mj_core::state::CheckpointMetadata),
814    RecoveryScan(mj_core::state::RecoveryScan),
815    WikiRows(WikiSearchPage),
816    ProjectCatalog(mj_core::project_catalog::ProjectCatalogView),
817    ProjectCreated(mj_core::project_catalog::SavedProject),
818    SessionTextMatches(Vec<SessionTextMatch>),
819    WikiHits(Option<WikiHitTranscript>),
820    WikiSession(Option<Box<WikiSessionInfo>>),
821    Reviewer(Box<crate::session::ReviewerOutcome>),
822    /// Whether a queued startup prompt was withdrawn before delivery.
823    PromptWithdrawn(bool),
824    Done,
825}
826
827impl DaemonReply {
828    /// Replies that can outgrow one frame. The daemon sends them as
829    /// [`DaemonReply::ReplyChunk`] fragments and the client reassembles them.
830    pub fn is_chunked(&self) -> bool {
831        matches!(
832            self,
833            Self::RuntimeChanges(_) | Self::SessionTail(_) | Self::ResumeCandidates(_)
834        )
835    }
836}
837
838/// What the resume dialog lists, read on demand.
839#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
840#[serde(deny_unknown_fields)]
841pub struct ResumeCandidates {
842    /// One row for every inactive session that is not a sub-agent.
843    pub candidates: Vec<ResumeCandidate>,
844    /// Durable moves of those sessions, for "move needs recovery" marks.
845    pub moves: Vec<MoveOperation>,
846    /// Native sessions that some record, live or not, already holds. The
847    /// import list must not offer them again.
848    pub adopted_native_sessions: Vec<(mj_core::config::HarnessKind, String)>,
849    /// Local checkouts that Mjolnir created for its sessions. Every native
850    /// thread that ran inside one belongs to that session.
851    pub local_checkout_roots: Vec<PathBuf>,
852}
853
854/// One row of the resume dialog's Mjolnir tab: what the row shows, without
855/// the rest of the record. A session's title can be tens of kilobytes, and
856/// most of a store's history is never resumed, so the resume wizard fetches
857/// the whole record (`DaemonAction::SessionRecord`) only for the row picked.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859#[serde(deny_unknown_fields)]
860pub struct ResumeCandidate {
861    pub session_id: String,
862    pub state: SessionState,
863    pub last_profile: String,
864    /// The listed title, cut to [`ResumeCandidate::TITLE_CHARS`].
865    pub title: String,
866    /// Where the session ran, as the session list names it.
867    pub origin: String,
868    pub project: String,
869    pub has_checkpoint: bool,
870    /// The checkpoint's creation time, or the record's last update without
871    /// one; `None` when neither parses.
872    pub last_activity_ms: Option<i64>,
873    pub publication: Option<PublicationState>,
874    /// The checkpoint archive whose size the row shows. Only a stopped
875    /// record is described by its checkpoint.
876    pub checkpoint_archive: Option<PathBuf>,
877    /// Whether destroying the session can also delete its worktree branch.
878    pub worktree_checkout: bool,
879}
880
881impl ResumeCandidate {
882    /// Longer than any row draws it.
883    pub const TITLE_CHARS: usize = 200;
884
885    /// The row for `record`. The daemon and the dashboard both build rows
886    /// here, so a row reads the same whichever of them held the record.
887    pub fn of(record: &SessionRecord, config: &Config) -> Self {
888        let timestamp_ms = |timestamp: &str| {
889            chrono::DateTime::parse_from_rfc3339(timestamp)
890                .ok()
891                .map(|parsed| parsed.timestamp_millis())
892        };
893        Self {
894            session_id: record.id.clone(),
895            state: record.state,
896            last_profile: record.last_profile.clone(),
897            title: record
898                .listed_title()
899                .chars()
900                .take(Self::TITLE_CHARS)
901                .collect(),
902            origin: record.project_target(config, &record.target_template_id),
903            project: record.project_name(config),
904            has_checkpoint: record.checkpoint.is_some(),
905            last_activity_ms: record
906                .checkpoint
907                .as_ref()
908                .and_then(|checkpoint| timestamp_ms(&checkpoint.created_at))
909                .or_else(|| timestamp_ms(&record.updated_at)),
910            publication: record.publication_state(),
911            checkpoint_archive: record
912                .checkpoint
913                .as_ref()
914                .filter(|_| record.state == SessionState::Stopped)
915                .map(|checkpoint| checkpoint.archive_path.clone()),
916            worktree_checkout: record
917                .checkout()
918                .managed_worktree()
919                .is_some_and(|owned| owned.kind == ManagedCheckoutKind::Worktree),
920        }
921    }
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize)]
925#[serde(deny_unknown_fields)]
926pub struct DaemonStatus {
927    pub pid: u32,
928    pub started_at: String,
929    pub build_version: String,
930    pub attached_clients: usize,
931    pub phone_status: WebViewerStatus,
932}
933
934#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
935#[serde(rename_all = "snake_case", tag = "state")]
936pub enum WebViewerStatus {
937    Disabled,
938    Starting,
939    Ready {
940        viewer_url: String,
941        viewer_code: String,
942        qr_login_url: Option<String>,
943        fallback_reason: Option<String>,
944    },
945    Stopped,
946    Error {
947        message: String,
948    },
949}
950
951impl std::fmt::Debug for WebViewerStatus {
952    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        match self {
954            Self::Ready {
955                viewer_url,
956                viewer_code,
957                fallback_reason,
958                ..
959            } => formatter
960                .debug_struct("Ready")
961                .field("viewer_url", viewer_url)
962                .field("viewer_code", viewer_code)
963                .field("qr_login_url", &"[redacted]")
964                .field("fallback_reason", fallback_reason)
965                .finish(),
966            Self::Disabled => formatter.write_str("Disabled"),
967            Self::Starting => formatter.write_str("Starting"),
968            Self::Stopped => formatter.write_str("Stopped"),
969            Self::Error { message } => formatter.debug_tuple("Error").field(message).finish(),
970        }
971    }
972}
973
974impl std::fmt::Display for WebViewerStatus {
975    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976        match self {
977            Self::Disabled => formatter.write_str("disabled"),
978            Self::Starting => formatter.write_str("starting"),
979            Self::Stopped => formatter.write_str("stopped unexpectedly"),
980            Self::Error { message } => write!(formatter, "error: {message}"),
981            Self::Ready {
982                viewer_url,
983                viewer_code,
984                fallback_reason,
985                ..
986            } => {
987                write!(formatter, "{viewer_url}; viewer code {viewer_code}")?;
988                if let Some(reason) = fallback_reason {
989                    write!(
990                        formatter,
991                        "; local only because Tailscale HTTPS is unavailable: {reason}"
992                    )?;
993                }
994                Ok(())
995            }
996        }
997    }
998}
999
1000/// Whether a non-child process has exited but not yet been reaped.
1001///
1002/// A zombie still answers `kill(pid, 0)`, because its process-table entry
1003/// survives until its parent waits for it — so an existence probe alone calls
1004/// it alive forever and anything waiting for it to leave waits forever. That is
1005/// exactly the shape of `mj daemon restart` refusing to restart a daemon that
1006/// had already stopped: `spawn_detached` used to leave the daemon a child of a
1007/// long-lived Mjolnir process that never reaped it. It now double-forks, so the
1008/// daemon is init's to reap, but any other unreaped child of this process would
1009/// look the same, and the check stays cheap.
1010///
1011/// Treating a zombie as gone is also safe in the direction that matters: a
1012/// zombie's PID cannot be reused until it is reaped, so nothing else can be
1013/// occupying that number while this returns true.
1014///
1015/// On Linux this reads the one `/proc/<pid>/stat` file. `sysinfo` scans every
1016/// process on the machine for a single-PID refresh, so it is not used here:
1017/// this probe runs every 250 ms while an upgrade waits on an older daemon.
1018#[cfg(target_os = "linux")]
1019pub fn process_is_zombie(pid: u32) -> bool {
1020    let Ok(stat) = std::fs::read(format!("/proc/{pid}/stat")) else {
1021        return false;
1022    };
1023    // The command name in parentheses may itself contain ") ", so the state
1024    // is the first field after the last closing parenthesis.
1025    stat.iter()
1026        .rposition(|byte| *byte == b')')
1027        .and_then(|end| stat.get(end + 2))
1028        .is_some_and(|state| *state == b'Z')
1029}
1030
1031/// On macOS this asks the kernel for the one process's BSD info. `sysinfo`
1032/// cannot be used: its lookups skip zombies, so it reports an exited, unreaped
1033/// process as absent rather than as a zombie, and `kill(pid, 0)` then calls it
1034/// alive forever. A nonzero `arg` to `PROC_PIDTBSDINFO` makes the kernel
1035/// include zombies.
1036#[cfg(target_os = "macos")]
1037pub fn process_is_zombie(pid: u32) -> bool {
1038    let Ok(raw_pid) = libc::c_int::try_from(pid) else {
1039        return false;
1040    };
1041    let size = std::mem::size_of::<libc::proc_bsdinfo>();
1042    // SAFETY: proc_bsdinfo is plain old data, so all-zero is a valid value.
1043    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
1044    // SAFETY: the buffer is a writable proc_bsdinfo of exactly `size` bytes.
1045    let written = unsafe {
1046        libc::proc_pidinfo(
1047            raw_pid,
1048            libc::PROC_PIDTBSDINFO,
1049            1,
1050            (&mut info as *mut libc::proc_bsdinfo).cast(),
1051            size as libc::c_int,
1052        )
1053    };
1054    usize::try_from(written).is_ok_and(|written| written == size) && info.pbi_status == libc::SZOMB
1055}
1056
1057#[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))]
1058pub fn process_is_zombie(pid: u32) -> bool {
1059    let pid = sysinfo::Pid::from_u32(pid);
1060    let mut system = sysinfo::System::new();
1061    system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
1062    system
1063        .process(pid)
1064        .is_some_and(|process| process.status() == sysinfo::ProcessStatus::Zombie)
1065}
1066
1067/// Wait for a process to leave, within [`STOP_TIMEOUT`].
1068///
1069/// The error says the process was still running rather than that it "did not
1070/// stop": a daemon that is still winding down has not refused, and the two
1071/// read very differently to somebody deciding whether to reach for a kill.
1072pub async fn wait_for_exit(pid: u32) -> Result<()> {
1073    wait_for_exit_within(pid, STOP_TIMEOUT).await
1074}
1075
1076async fn wait_for_exit_within(pid: u32, timeout: Duration) -> Result<()> {
1077    let deadline = Instant::now() + timeout;
1078    while daemon_process_is_alive(pid) {
1079        ensure!(Instant::now() < deadline, "process {pid} is still running");
1080        tokio::time::sleep(RETRY_DELAY).await;
1081    }
1082    Ok(())
1083}
1084
1085/// Whether a daemon that Mjolnir launched in its own process group is alive.
1086///
1087/// Reaping is deliberately confined to this daemon-specific path. Attachment
1088/// PIDs are merely observations and may alias unrelated children owned by this
1089/// process, so their liveness probe below must never call `waitpid`.
1090pub fn daemon_process_is_alive(pid: u32) -> bool {
1091    #[cfg(unix)]
1092    {
1093        if pid == 0 {
1094            return false;
1095        }
1096        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
1097            return false;
1098        };
1099        let mut status = 0;
1100        // SAFETY: `status` is writable for the call and WNOHANG never blocks.
1101        // A non-child fails with ECHILD without changing any process state.
1102        let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
1103        if waited == raw_pid {
1104            return false;
1105        }
1106        if waited == 0 {
1107            return true;
1108        }
1109        let wait_error = std::io::Error::last_os_error();
1110        if wait_error.raw_os_error() != Some(libc::ECHILD) {
1111            return true;
1112        }
1113
1114        #[cfg(target_os = "macos")]
1115        return owned_daemon_group_is_alive(raw_pid);
1116
1117        #[cfg(not(target_os = "macos"))]
1118        process_is_alive(pid)
1119    }
1120    #[cfg(not(unix))]
1121    process_is_alive(pid)
1122}
1123
1124#[cfg(target_os = "macos")]
1125pub fn owned_daemon_group_is_alive(pid: libc::pid_t) -> bool {
1126    // `spawn_detached` makes the daemon a process-group leader. Darwin
1127    // excludes zombies from group signal probes: ESRCH means the group is gone
1128    // and EPERM means only exiting members remain. The latter is safe here
1129    // because this is a group we created for our own same-user child, not an
1130    // arbitrary process group.
1131    // SAFETY: signal 0 is only an existence probe, and the negative PID targets
1132    // the daemon-owned group rather than another process.
1133    if unsafe { libc::kill(-pid, 0) } == 0 {
1134        return true;
1135    }
1136    let error = std::io::Error::last_os_error();
1137    !matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM))
1138}
1139
1140pub fn process_is_alive(pid: u32) -> bool {
1141    #[cfg(unix)]
1142    {
1143        if pid == 0 {
1144            return false;
1145        }
1146        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
1147            return false;
1148        };
1149        // SAFETY: kill(pid, 0) sends no signal and is the standard existence
1150        // probe. EPERM still means the process exists.
1151        let result = unsafe { libc::kill(raw_pid, 0) };
1152        let exists =
1153            result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
1154        exists && !process_is_zombie(pid)
1155    }
1156    #[cfg(not(unix))]
1157    {
1158        let process_id = sysinfo::Pid::from_u32(pid);
1159        let mut system = sysinfo::System::new();
1160        system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[process_id]), true);
1161        system.process(process_id).is_some()
1162    }
1163}
1164
1165pub fn read_metadata() -> Result<DaemonMetadata> {
1166    let metadata = read_metadata_any()?;
1167    ensure!(
1168        metadata.protocol_version == PROTOCOL_VERSION,
1169        "daemon protocol {} is incompatible with client protocol {}",
1170        metadata.protocol_version,
1171        PROTOCOL_VERSION
1172    );
1173    Ok(metadata)
1174}
1175
1176/// No daemon has published its endpoint: `daemon.json` does not exist.
1177///
1178/// This is the ordinary stopped state, not a failure. A daemon removes the
1179/// file when it stops, so callers show "not running" rather than the raw
1180/// file error.
1181#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct DaemonNotRunning {
1183    /// Where the endpoint would be, which also names the instance.
1184    pub metadata_path: std::path::PathBuf,
1185}
1186
1187/// What a caller shows for [`DaemonNotRunning`].
1188pub const DAEMON_NOT_RUNNING_MESSAGE: &str = "the Mjolnir daemon is not running";
1189
1190impl std::fmt::Display for DaemonNotRunning {
1191    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192        formatter.write_str(DAEMON_NOT_RUNNING_MESSAGE)
1193    }
1194}
1195
1196impl std::error::Error for DaemonNotRunning {}
1197
1198/// The stopped state, if `error` reports it anywhere in its chain.
1199pub fn daemon_not_running(error: &anyhow::Error) -> Option<&DaemonNotRunning> {
1200    error
1201        .chain()
1202        .find_map(|cause| cause.downcast_ref::<DaemonNotRunning>())
1203}
1204
1205pub fn read_metadata_any() -> Result<DaemonMetadata> {
1206    read_metadata_at(&metadata_path())
1207}
1208
1209fn read_metadata_at(path: &std::path::Path) -> Result<DaemonMetadata> {
1210    let body = match fs::read(path) {
1211        Ok(body) => body,
1212        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1213            return Err(DaemonNotRunning {
1214                metadata_path: path.to_owned(),
1215            }
1216            .into());
1217        }
1218        Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
1219    };
1220    let metadata: DaemonMetadata =
1221        serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1222    Ok(metadata)
1223}
1224
1225pub async fn write_frame<T: Serialize>(stream: &mut TcpStream, value: &T) -> Result<()> {
1226    let body = serde_json::to_vec(value)?;
1227    write_encoded_frame(stream, &body).await
1228}
1229
1230pub async fn write_encoded_frame(stream: &mut TcpStream, body: &[u8]) -> Result<()> {
1231    ensure!(
1232        body.len() <= MAX_FRAME_BYTES,
1233        "daemon frame is too large: {} bytes exceeds {MAX_FRAME_BYTES}",
1234        body.len()
1235    );
1236    stream.write_u32(body.len() as u32).await?;
1237    stream.write_all(body).await?;
1238    stream.flush().await?;
1239    Ok(())
1240}
1241
1242pub async fn read_frame<T: for<'de> Deserialize<'de>>(stream: &mut TcpStream) -> Result<T> {
1243    let length = stream.read_u32().await? as usize;
1244    ensure!(
1245        length <= MAX_FRAME_BYTES,
1246        "daemon frame exceeds {MAX_FRAME_BYTES} bytes"
1247    );
1248    let mut body = vec![0_u8; length];
1249    stream.read_exact(&mut body).await?;
1250    serde_json::from_slice(&body).context("decode daemon frame")
1251}
1252
1253/// Read one reply, reassembling a chunked one before exposing it. All
1254/// fragments belong to the same response; disconnects discard partial state.
1255pub async fn read_response(stream: &mut TcpStream) -> Result<ResponseEnvelope> {
1256    let mut response: ResponseEnvelope = read_frame(stream).await?;
1257    if !matches!(response.result, Ok(DaemonReply::ReplyChunk { .. })) {
1258        return Ok(response);
1259    }
1260    let protocol_version = response.protocol_version;
1261    let request_id = response.request_id;
1262    let mut body = Vec::new();
1263    loop {
1264        ensure!(
1265            response.protocol_version == protocol_version && response.request_id == request_id,
1266            "daemon crossed reply fragment identities"
1267        );
1268        let Ok(DaemonReply::ReplyChunk { bytes, finished }) = response.result else {
1269            bail!("daemon interrupted a chunked reply");
1270        };
1271        ensure!(!bytes.is_empty(), "empty reply fragment");
1272        body.extend(bytes);
1273        if finished {
1274            break;
1275        }
1276        response = read_frame(stream).await?;
1277    }
1278    let reply: DaemonReply = tokio::task::spawn_blocking(move || serde_json::from_slice(&body))
1279        .await
1280        .context("reply decoder task failed")??;
1281    ensure!(
1282        reply.is_chunked(),
1283        "daemon chunked a reply that is never chunked"
1284    );
1285    Ok(ResponseEnvelope {
1286        protocol_version,
1287        request_id,
1288        result: Ok(reply),
1289    })
1290}
1291
1292pub struct DaemonClient {
1293    metadata: DaemonMetadata,
1294    stream: TcpStream,
1295    next_request_id: u64,
1296}
1297
1298impl DaemonClient {
1299    pub async fn connect(metadata: DaemonMetadata) -> Result<Self> {
1300        let stream =
1301            tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(metadata.address))
1302                .await
1303                .context("time out connecting to Mjolnir daemon")??;
1304        Ok(Self {
1305            metadata,
1306            stream,
1307            next_request_id: 1,
1308        })
1309    }
1310
1311    /// The process this client is connected to.
1312    pub fn daemon_pid(&self) -> u32 {
1313        self.metadata.pid
1314    }
1315
1316    /// Speak the daemon's advertised dialect, not this build's: management
1317    /// requests must reach daemons of any protocol version, and the frozen
1318    /// subset encodes identically across all of them.
1319    pub async fn request(&mut self, action: DaemonAction) -> Result<DaemonReply> {
1320        self.request_with_reconnect(action, || async {
1321            loop {
1322                if let Ok(client) = connect_existing().await {
1323                    return Ok(client);
1324                }
1325                tokio::time::sleep(Duration::from_millis(250)).await;
1326            }
1327        })
1328        .await
1329    }
1330
1331    async fn request_with_reconnect<F, Fut>(
1332        &mut self,
1333        action: DaemonAction,
1334        mut reconnect: F,
1335    ) -> Result<DaemonReply>
1336    where
1337        F: FnMut() -> Fut,
1338        Fut: Future<Output = Result<Self>>,
1339    {
1340        loop {
1341            let response = self.request_once(action.clone()).await?;
1342            if matches!(response, DaemonReply::UpgradePending)
1343                && !matches!(action, DaemonAction::PrepareUpgrade)
1344            {
1345                // Only an explicit refusal guarantees non-admission. Never
1346                // replay arbitrary mutations after a lost acknowledgement.
1347                tokio::time::sleep(Duration::from_millis(100)).await;
1348                *self = reconnect().await?;
1349            } else {
1350                return Ok(response);
1351            }
1352        }
1353    }
1354
1355    async fn request_once(&mut self, action: DaemonAction) -> Result<DaemonReply> {
1356        let protocol_version = self.metadata.protocol_version;
1357        let request_id = self.next_request_id;
1358        self.next_request_id += 1;
1359        write_frame(
1360            &mut self.stream,
1361            &RequestEnvelope {
1362                protocol_version,
1363                request_id,
1364                token: self.metadata.token.clone(),
1365                action,
1366            },
1367        )
1368        .await?;
1369        let response = read_response(&mut self.stream).await?;
1370        ensure!(
1371            response.protocol_version == protocol_version,
1372            "daemon changed protocol"
1373        );
1374        ensure!(
1375            response.request_id == request_id,
1376            "daemon crossed request IDs"
1377        );
1378        response
1379            .result
1380            .map_err(|message| anyhow::Error::new(DaemonRefusal(message)))
1381    }
1382
1383    pub async fn status(&mut self) -> Result<DaemonStatus> {
1384        match self.request(DaemonAction::Status).await? {
1385            DaemonReply::Status(status) => Ok(status),
1386            reply => bail!("unexpected daemon status reply {reply:?}"),
1387        }
1388    }
1389
1390    pub async fn web_access(&mut self) -> Result<crate::web::WebViewerAccess> {
1391        match self.request(DaemonAction::WebViewerAccess).await? {
1392            DaemonReply::WebViewerAccess(access) => Ok(access),
1393            reply => bail!("unexpected web viewer reply {reply:?}"),
1394        }
1395    }
1396
1397    pub async fn subagent_options(
1398        &mut self,
1399        profile: String,
1400        model: Option<String>,
1401        config: Option<Config>,
1402    ) -> Result<mj_core::subagent::SubagentOptions> {
1403        match self
1404            .request(DaemonAction::SubagentOptions {
1405                profile,
1406                model,
1407                config: config.map(Box::new),
1408            })
1409            .await?
1410        {
1411            DaemonReply::SubagentOptions(options) => Ok(options),
1412            reply => bail!("unexpected subagent options reply {reply:?}"),
1413        }
1414    }
1415
1416    pub async fn warm_profile_capabilities(&mut self, config: Config) -> Result<()> {
1417        match self
1418            .request(DaemonAction::WarmProfileCapabilities {
1419                config: Box::new(config),
1420            })
1421            .await?
1422        {
1423            DaemonReply::Done => Ok(()),
1424            reply => bail!("unexpected capability hydration reply {reply:?}"),
1425        }
1426    }
1427
1428    pub async fn recover_web_viewer(
1429        &mut self,
1430        action: crate::web::WebViewerRecovery,
1431    ) -> Result<()> {
1432        match self.request(DaemonAction::RecoverWebViewer(action)).await? {
1433            DaemonReply::Done => Ok(()),
1434            reply => bail!("unexpected web viewer recovery reply {reply:?}"),
1435        }
1436    }
1437
1438    pub async fn inspect_web_listener(&mut self) -> Result<Vec<crate::web::WebListenerProcess>> {
1439        match self.request(DaemonAction::InspectWebListener).await? {
1440            DaemonReply::WebListeners(processes) => Ok(processes),
1441            reply => bail!("unexpected listener inspection reply {reply:?}"),
1442        }
1443    }
1444
1445    pub async fn list_workspaces(&mut self) -> Result<Vec<WorkspaceListing>> {
1446        match self.request(DaemonAction::ListWorkspaces).await? {
1447            DaemonReply::Workspaces(workspaces) => Ok(workspaces),
1448            reply => bail!("unexpected daemon workspace reply {reply:?}"),
1449        }
1450    }
1451
1452    /// Ask the daemon to probe every profile that is not on hold. Returns when
1453    /// the daemon has accepted the request, not when the probes finish.
1454    pub async fn refresh_quota(&mut self) -> Result<()> {
1455        match self.request(DaemonAction::RefreshQuota).await? {
1456            DaemonReply::Done => Ok(()),
1457            reply => bail!("unexpected refresh-quota reply {reply:?}"),
1458        }
1459    }
1460
1461    pub async fn rename_profile(&mut self, old_id: String, new_id: String) -> Result<()> {
1462        match self
1463            .request(DaemonAction::RenameProfile { old_id, new_id })
1464            .await?
1465        {
1466            DaemonReply::Done => Ok(()),
1467            reply => bail!("unexpected rename-profile reply {reply:?}"),
1468        }
1469    }
1470
1471    pub async fn rename_target(&mut self, old_id: String, new_id: String) -> Result<()> {
1472        match self
1473            .request(DaemonAction::RenameTarget { old_id, new_id })
1474            .await?
1475        {
1476            DaemonReply::Done => Ok(()),
1477            reply => bail!("unexpected rename-target reply {reply:?}"),
1478        }
1479    }
1480
1481    pub async fn create_workspace(&mut self, name: String) -> Result<WorkspaceRecord> {
1482        match self.request(DaemonAction::CreateWorkspace { name }).await? {
1483            DaemonReply::Workspace(workspace) => Ok(workspace),
1484            reply => bail!("unexpected create-workspace reply {reply:?}"),
1485        }
1486    }
1487
1488    pub async fn rename_workspace(&mut self, workspace_id: String, name: String) -> Result<()> {
1489        match self
1490            .request(DaemonAction::RenameWorkspace { workspace_id, name })
1491            .await?
1492        {
1493            DaemonReply::Done => Ok(()),
1494            reply => bail!("unexpected rename-workspace reply {reply:?}"),
1495        }
1496    }
1497
1498    pub async fn touch_workspace(&mut self, workspace_id: String) -> Result<()> {
1499        match self
1500            .request(DaemonAction::TouchWorkspace { workspace_id })
1501            .await?
1502        {
1503            DaemonReply::Done => Ok(()),
1504            reply => bail!("unexpected touch-workspace reply {reply:?}"),
1505        }
1506    }
1507
1508    pub async fn cancel_workspace_close(&mut self, workspace_id: String) -> Result<()> {
1509        match self
1510            .request(DaemonAction::CancelWorkspaceClose { workspace_id })
1511            .await?
1512        {
1513            DaemonReply::Done => Ok(()),
1514            reply => bail!("unexpected cancel workspace close reply: {reply:?}"),
1515        }
1516    }
1517
1518    pub async fn close_workspace(&mut self, workspace_id: String) -> Result<()> {
1519        match self
1520            .request(DaemonAction::CloseWorkspace { workspace_id })
1521            .await?
1522        {
1523            DaemonReply::Done => Ok(()),
1524            reply => bail!("unexpected close workspace reply: {reply:?}"),
1525        }
1526    }
1527
1528    pub async fn delete_workspace(&mut self, workspace_id: String) -> Result<()> {
1529        match self
1530            .request(DaemonAction::DeleteWorkspace { workspace_id })
1531            .await?
1532        {
1533            DaemonReply::Done => Ok(()),
1534            reply => bail!("unexpected delete-workspace reply {reply:?}"),
1535        }
1536    }
1537
1538    pub async fn attach(&mut self, client_id: String, pid: u32) -> Result<()> {
1539        match self
1540            .request(DaemonAction::Attach { client_id, pid })
1541            .await?
1542        {
1543            DaemonReply::Done => Ok(()),
1544            reply => bail!("unexpected attach reply {reply:?}"),
1545        }
1546    }
1547
1548    pub async fn detach(&mut self, client_id: String) -> Result<()> {
1549        match self.request(DaemonAction::Detach { client_id }).await? {
1550            DaemonReply::Done => Ok(()),
1551            reply => bail!("unexpected detach reply {reply:?}"),
1552        }
1553    }
1554
1555    pub async fn persist_read_receipt(
1556        &mut self,
1557        client_id: String,
1558        workspace_id: String,
1559        session_id: String,
1560        through: u64,
1561    ) -> Result<u64> {
1562        match self
1563            .request(DaemonAction::PersistReadReceipt {
1564                client_id,
1565                workspace_id,
1566                session_id,
1567                through,
1568            })
1569            .await?
1570        {
1571            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1572            reply => bail!("unexpected read-receipt reply {reply:?}"),
1573        }
1574    }
1575
1576    pub async fn persist_detached_session_state(
1577        &mut self,
1578        client_id: String,
1579        workspace_id: String,
1580        session_id: String,
1581        through: u64,
1582        owner_pid: u32,
1583        draft: mj_core::storage::DetachedSessionDraft,
1584    ) -> Result<()> {
1585        match self
1586            .request(DaemonAction::PersistDetachedSessionState {
1587                client_id,
1588                workspace_id,
1589                session_id,
1590                through,
1591                owner_pid,
1592                draft,
1593            })
1594            .await?
1595        {
1596            DaemonReply::Done => Ok(()),
1597            reply => bail!("unexpected detached-session-state reply {reply:?}"),
1598        }
1599    }
1600
1601    pub async fn save_active_review(
1602        &mut self,
1603        session_id: String,
1604        review: mj_core::storage::StoredReview,
1605    ) -> Result<()> {
1606        match self
1607            .request(DaemonAction::SaveActiveReview { session_id, review })
1608            .await?
1609        {
1610            DaemonReply::Done => Ok(()),
1611            reply => bail!("unexpected save-review reply {reply:?}"),
1612        }
1613    }
1614
1615    pub async fn clear_active_review(&mut self, session_id: String) -> Result<()> {
1616        match self
1617            .request(DaemonAction::ClearActiveReview { session_id })
1618            .await?
1619        {
1620            DaemonReply::Done => Ok(()),
1621            reply => bail!("unexpected clear-review reply {reply:?}"),
1622        }
1623    }
1624
1625    /// Persist a prompt the user abandoned in the chat composer.
1626    pub async fn record_abandoned_prompt(
1627        &mut self,
1628        session_id: String,
1629        bundle_id: String,
1630        text: String,
1631    ) -> Result<()> {
1632        match self
1633            .request(DaemonAction::RecordAbandonedPrompt {
1634                session_id,
1635                bundle_id,
1636                text,
1637            })
1638            .await?
1639        {
1640            DaemonReply::Done => Ok(()),
1641            reply => bail!("unexpected abandoned-prompt reply {reply:?}"),
1642        }
1643    }
1644
1645    pub async fn save_workspace_pane_sizes(
1646        &mut self,
1647        workspace_id: String,
1648        sizes: mj_core::workspace::PaneSizes,
1649    ) -> Result<()> {
1650        match self
1651            .request(DaemonAction::SaveWorkspacePaneSizes {
1652                workspace_id,
1653                sizes,
1654            })
1655            .await?
1656        {
1657            DaemonReply::Done => Ok(()),
1658            reply => bail!("unexpected pane-size save reply {reply:?}"),
1659        }
1660    }
1661
1662    pub async fn save_workspace_layout(
1663        &mut self,
1664        workspace_id: String,
1665        layout: mj_core::workspace::ConversationLayout,
1666    ) -> Result<()> {
1667        match self
1668            .request(DaemonAction::SaveWorkspaceLayout {
1669                workspace_id,
1670                layout,
1671            })
1672            .await?
1673        {
1674            DaemonReply::Done => Ok(()),
1675            reply => bail!("unexpected layout save reply {reply:?}"),
1676        }
1677    }
1678
1679    pub async fn persist_imported_session(&mut self, session: SessionRecord) -> Result<()> {
1680        match self
1681            .request(DaemonAction::PersistImportedSession {
1682                session: Box::new(session),
1683            })
1684            .await?
1685        {
1686            DaemonReply::Done => Ok(()),
1687            reply => bail!("unexpected imported-session reply {reply:?}"),
1688        }
1689    }
1690
1691    pub async fn set_session_title(&mut self, session_id: String, title: String) -> Result<String> {
1692        match self
1693            .request(DaemonAction::SetSessionTitle { session_id, title })
1694            .await?
1695        {
1696            DaemonReply::Text(title) => Ok(title),
1697            reply => bail!("unexpected session-title reply {reply:?}"),
1698        }
1699    }
1700
1701    pub async fn set_session_workspace(
1702        &mut self,
1703        session_id: String,
1704        workspace_id: String,
1705    ) -> Result<()> {
1706        match self
1707            .request(DaemonAction::SetSessionWorkspace {
1708                session_id,
1709                workspace_id,
1710            })
1711            .await?
1712        {
1713            DaemonReply::Done => Ok(()),
1714            reply => bail!("unexpected session-workspace reply {reply:?}"),
1715        }
1716    }
1717
1718    pub async fn set_session_container_settings(
1719        &mut self,
1720        session_id: String,
1721        cpus: Option<String>,
1722        memory: Option<String>,
1723        mounts: Vec<AdditionalMount>,
1724        mount_history: Vec<PathBuf>,
1725    ) -> Result<()> {
1726        match self
1727            .request(DaemonAction::SetSessionContainerSettings {
1728                session_id,
1729                cpus,
1730                memory,
1731                mounts,
1732                mount_history,
1733            })
1734            .await?
1735        {
1736            DaemonReply::Done => Ok(()),
1737            reply => bail!("unexpected container-settings reply {reply:?}"),
1738        }
1739    }
1740
1741    pub async fn set_session_acp_title(
1742        &mut self,
1743        session_id: String,
1744        title: Option<String>,
1745    ) -> Result<()> {
1746        match self
1747            .request(DaemonAction::SetSessionAcpTitle { session_id, title })
1748            .await?
1749        {
1750            DaemonReply::Done => Ok(()),
1751            reply => bail!("unexpected ACP-title reply {reply:?}"),
1752        }
1753    }
1754
1755    pub async fn mark_session_target_missing(
1756        &mut self,
1757        session_id: String,
1758        detail: String,
1759        updated_at: String,
1760    ) -> Result<Option<SessionState>> {
1761        match self
1762            .request(DaemonAction::MarkSessionTargetMissing {
1763                session_id,
1764                detail,
1765                updated_at,
1766            })
1767            .await?
1768        {
1769            DaemonReply::OptionalSessionState(state) => Ok(state),
1770            reply => bail!("unexpected target-missing reply {reply:?}"),
1771        }
1772    }
1773
1774    pub async fn checkpoint_session(
1775        &mut self,
1776        session_id: String,
1777    ) -> Result<mj_core::state::CheckpointMetadata> {
1778        match self
1779            .request(DaemonAction::CheckpointSession { session_id })
1780            .await?
1781        {
1782            DaemonReply::Checkpoint(checkpoint) => Ok(checkpoint),
1783            reply => bail!("unexpected checkpoint reply {reply:?}"),
1784        }
1785    }
1786
1787    /// Search the user's SessionWiki index, newest first when the query is
1788    /// empty and best match first otherwise. The reply carries the state of
1789    /// the index as well as the rows, so a caller can say the first build is
1790    /// still running.
1791    pub async fn project_catalog(
1792        &mut self,
1793        refresh: bool,
1794        retry: bool,
1795    ) -> Result<mj_core::project_catalog::ProjectCatalogView> {
1796        match self
1797            .request(DaemonAction::ProjectCatalog { refresh, retry })
1798            .await?
1799        {
1800            DaemonReply::ProjectCatalog(view) => Ok(view),
1801            other => bail!("unexpected project catalog reply: {other:?}"),
1802        }
1803    }
1804
1805    pub async fn create_project(
1806        &mut self,
1807        sources: Vec<String>,
1808    ) -> Result<mj_core::project_catalog::SavedProject> {
1809        match self
1810            .request(DaemonAction::CreateProject { sources })
1811            .await?
1812        {
1813            DaemonReply::ProjectCreated(project) => Ok(project),
1814            other => bail!("unexpected project creation reply: {other:?}"),
1815        }
1816    }
1817
1818    pub async fn wiki_search(&mut self, query: String, limit: usize) -> Result<WikiSearchPage> {
1819        match self
1820            .request(DaemonAction::WikiSearch { query, limit })
1821            .await?
1822        {
1823            DaemonReply::WikiRows(page) => Ok(page),
1824            reply => bail!("unexpected SessionWiki search reply {reply:?}"),
1825        }
1826    }
1827
1828    /// The live sessions whose user or agent messages contain `query`, and
1829    /// which of the two matched. Answers from the SessionWiki index, so a very
1830    /// new message can be missing until the next sync.
1831    pub async fn session_text_search(&mut self, query: String) -> Result<Vec<SessionTextMatch>> {
1832        match self
1833            .request(DaemonAction::SessionTextSearch { query })
1834            .await?
1835        {
1836            DaemonReply::SessionTextMatches(matches) => Ok(matches),
1837            reply => bail!("unexpected session text search reply {reply:?}"),
1838        }
1839    }
1840
1841    /// The markdown briefing for one indexed session.
1842    pub async fn wiki_brief(&mut self, wiki_id: String, max_chars: usize) -> Result<String> {
1843        match self
1844            .request(DaemonAction::WikiBrief { wiki_id, max_chars })
1845            .await?
1846        {
1847            DaemonReply::Text(markdown) => Ok(markdown),
1848            reply => bail!("unexpected SessionWiki brief reply {reply:?}"),
1849        }
1850    }
1851
1852    /// The passages of one indexed session that match a query, each matching
1853    /// message with `context_messages` neighbours on either side and its text
1854    /// capped at `per_message_chars`. `None` when the index holds no session
1855    /// with that id.
1856    pub async fn wiki_hits(
1857        &mut self,
1858        wiki_id: String,
1859        query: String,
1860        context_messages: usize,
1861        per_message_chars: usize,
1862    ) -> Result<Option<WikiHitTranscript>> {
1863        match self
1864            .request(DaemonAction::WikiHits {
1865                wiki_id,
1866                query,
1867                context_messages,
1868                per_message_chars,
1869            })
1870            .await?
1871        {
1872            DaemonReply::WikiHits(transcript) => Ok(transcript),
1873            reply => bail!("unexpected SessionWiki hits reply {reply:?}"),
1874        }
1875    }
1876
1877    /// What the index knows about one session, or `None` when the index holds
1878    /// no session with that id.
1879    pub async fn wiki_session(&mut self, wiki_id: String) -> Result<Option<WikiSessionInfo>> {
1880        match self.request(DaemonAction::WikiSession { wiki_id }).await? {
1881            DaemonReply::WikiSession(info) => Ok(info.map(|info| *info)),
1882            reply => bail!("unexpected SessionWiki session reply {reply:?}"),
1883        }
1884    }
1885
1886    /// Start a new session carrying a hand-off compacted from an archived one.
1887    /// It answers like any other session start: the record exists and is
1888    /// provisioning, and the hand-off follows once the harness is ready.
1889    pub async fn wiki_restore(&mut self, request: WikiRestoreRequest) -> Result<RegisteredSession> {
1890        match self.request(DaemonAction::WikiRestore(request)).await? {
1891            DaemonReply::RegisteredSession(registered) => Ok(*registered),
1892            reply => bail!("unexpected SessionWiki restore reply {reply:?}"),
1893        }
1894    }
1895
1896    pub async fn scan_recovery(
1897        &mut self,
1898        all_instances: bool,
1899    ) -> Result<mj_core::state::RecoveryScan> {
1900        match self
1901            .request(DaemonAction::ScanRecovery { all_instances })
1902            .await?
1903        {
1904            DaemonReply::RecoveryScan(scan) => Ok(scan),
1905            reply => bail!("unexpected recovery-scan reply {reply:?}"),
1906        }
1907    }
1908
1909    pub async fn adopt_recovery(
1910        &mut self,
1911        session_id: String,
1912        target_id: String,
1913        profile: Option<String>,
1914        bundle: Option<String>,
1915        all_instances: bool,
1916    ) -> Result<()> {
1917        match self
1918            .request(DaemonAction::AdoptRecovery {
1919                session_id,
1920                target_id,
1921                profile,
1922                bundle,
1923                all_instances,
1924            })
1925            .await?
1926        {
1927            DaemonReply::Done => Ok(()),
1928            reply => bail!("unexpected recovery-adopt reply {reply:?}"),
1929        }
1930    }
1931
1932    pub async fn destroy_recovery(
1933        &mut self,
1934        session_id: String,
1935        target_id: String,
1936        confirmation: String,
1937        all_instances: bool,
1938    ) -> Result<()> {
1939        match self
1940            .request(DaemonAction::DestroyRecovery {
1941                session_id,
1942                target_id,
1943                confirmation,
1944                all_instances,
1945            })
1946            .await?
1947        {
1948            DaemonReply::Done => Ok(()),
1949            reply => bail!("unexpected recovery-destroy reply {reply:?}"),
1950        }
1951    }
1952
1953    pub async fn snapshot(&mut self, workspace_id: String) -> Result<WorkspaceSnapshot> {
1954        match self
1955            .request(DaemonAction::Snapshot { workspace_id })
1956            .await?
1957        {
1958            DaemonReply::Snapshot(snapshot) => Ok(snapshot),
1959            reply => bail!("unexpected snapshot reply {reply:?}"),
1960        }
1961    }
1962
1963    pub async fn runtime_changes(
1964        &mut self,
1965        cursor: Option<crate::runtime_feed::RuntimeCursor>,
1966        wait: bool,
1967    ) -> Result<crate::runtime_feed::RuntimeFrame> {
1968        match self
1969            .request(DaemonAction::RuntimeChanges { cursor, wait })
1970            .await?
1971        {
1972            DaemonReply::RuntimeChanges(frame) => Ok(*frame),
1973            reply => bail!("unexpected runtime changes reply {reply:?}"),
1974        }
1975    }
1976
1977    pub async fn session_tail(
1978        &mut self,
1979        session_id: String,
1980        cursor: crate::runtime_feed::RuntimeCursor,
1981    ) -> Result<crate::runtime_feed::SessionTailReply> {
1982        match self
1983            .request(DaemonAction::SessionTail { session_id, cursor })
1984            .await?
1985        {
1986            DaemonReply::SessionTail(reply) => Ok(*reply),
1987            reply => bail!("unexpected session tail reply {reply:?}"),
1988        }
1989    }
1990
1991    pub async fn resume_candidates(&mut self) -> Result<ResumeCandidates> {
1992        match self.request(DaemonAction::ResumeCandidates).await? {
1993            DaemonReply::ResumeCandidates(candidates) => Ok(*candidates),
1994            reply => bail!("unexpected resume candidates reply {reply:?}"),
1995        }
1996    }
1997
1998    pub async fn session_record(&mut self, session_id: String) -> Result<Option<SessionRecord>> {
1999        match self
2000            .request(DaemonAction::SessionRecord { session_id })
2001            .await?
2002        {
2003            DaemonReply::SessionRecord(record) => Ok(record.map(|record| *record)),
2004            reply => bail!("unexpected session record reply {reply:?}"),
2005        }
2006    }
2007
2008    pub async fn go_startup_session(
2009        &mut self,
2010        workspace_id: String,
2011        last_session_id: Option<String>,
2012    ) -> Result<Option<SessionRecord>> {
2013        match self
2014            .request(DaemonAction::GoStartupSession {
2015                workspace_id,
2016                last_session_id,
2017            })
2018            .await?
2019        {
2020            DaemonReply::GoStartupSession(record) => Ok(record.map(|record| *record)),
2021            reply => bail!("unexpected go startup session reply {reply:?}"),
2022        }
2023    }
2024
2025    pub async fn submit_session_command(
2026        &mut self,
2027        session_id: String,
2028        command_id: String,
2029        command: RelayCommand,
2030        inherited_draft: Option<String>,
2031    ) -> Result<u64> {
2032        match self
2033            .request(DaemonAction::SubmitSessionCommand {
2034                inherited_draft,
2035                session_id,
2036                command_id,
2037                command,
2038            })
2039            .await?
2040        {
2041            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
2042            reply => bail!("unexpected session command reply {reply:?}"),
2043        }
2044    }
2045
2046    /// Hand the daemon a prompt for a session that is still starting. The
2047    /// daemon replies as soon as the prompt is queued, not when it is
2048    /// delivered; delivery failures come back as a session notice.
2049    pub async fn queue_startup_prompt(
2050        &mut self,
2051        session_id: String,
2052        text: String,
2053        inherited_draft: Option<String>,
2054    ) -> Result<()> {
2055        match self
2056            .request(DaemonAction::QueueStartupPrompt {
2057                session_id,
2058                text,
2059                inherited_draft,
2060            })
2061            .await?
2062        {
2063            DaemonReply::Done => Ok(()),
2064            reply => bail!("unexpected startup prompt reply {reply:?}"),
2065        }
2066    }
2067
2068    /// Take a prompt back from the daemon's startup queue before delivery.
2069    /// `Ok(true)` means it is withdrawn and will not be sent; `Ok(false)`
2070    /// means delivery had already started, so it was sent. An error means the
2071    /// daemon could not be asked, and the prompt may still be queued.
2072    pub async fn withdraw_startup_prompt(
2073        &mut self,
2074        session_id: String,
2075        text: String,
2076    ) -> Result<bool> {
2077        match self
2078            .request(DaemonAction::WithdrawStartupPrompt { session_id, text })
2079            .await?
2080        {
2081            DaemonReply::PromptWithdrawn(withdrawn) => Ok(withdrawn),
2082            reply => bail!("unexpected startup prompt withdrawal reply {reply:?}"),
2083        }
2084    }
2085
2086    /// Ask the daemon to review the turn this session just finished.
2087    ///
2088    /// The refusal is a sentence for a person -- "prompts are queued", "set
2089    /// [review] profile in config.toml" -- so it travels as text rather than
2090    /// as a code every surface would have to translate.
2091    pub async fn start_turn_review(&mut self, session_id: String) -> Result<()> {
2092        match self
2093            .request(DaemonAction::StartTurnReview { session_id })
2094            .await?
2095        {
2096            DaemonReply::Done => Ok(()),
2097            reply => bail!("unexpected turn-review reply {reply:?}"),
2098        }
2099    }
2100
2101    pub async fn resolve_turn_review(
2102        &mut self,
2103        session_id: String,
2104        resolution: Resolution,
2105    ) -> Result<()> {
2106        match self
2107            .request(DaemonAction::ResolveTurnReview {
2108                session_id,
2109                resolution,
2110            })
2111            .await?
2112        {
2113            DaemonReply::Done => Ok(()),
2114            reply => bail!("unexpected turn-review resolution reply {reply:?}"),
2115        }
2116    }
2117
2118    pub async fn reviewer_action(
2119        &mut self,
2120        session_id: String,
2121        role: Option<String>,
2122        action: crate::session::ReviewerAction,
2123    ) -> Result<crate::session::ReviewerOutcome> {
2124        match self
2125            .request(DaemonAction::ReviewerAction {
2126                session_id,
2127                role,
2128                action,
2129            })
2130            .await?
2131        {
2132            DaemonReply::Reviewer(outcome) => Ok(*outcome),
2133            reply => bail!("unexpected reviewer reply {reply:?}"),
2134        }
2135    }
2136
2137    pub async fn sync_session(&mut self, session_id: String) -> Result<()> {
2138        match self
2139            .request(DaemonAction::SyncSession { session_id })
2140            .await?
2141        {
2142            DaemonReply::Done => Ok(()),
2143            reply => bail!("unexpected session sync reply {reply:?}"),
2144        }
2145    }
2146
2147    pub async fn respond_elicitation(
2148        &mut self,
2149        session_id: String,
2150        elicitation_id: String,
2151        response: ElicitationResponse,
2152    ) -> Result<()> {
2153        match self
2154            .request(DaemonAction::RespondElicitation {
2155                session_id,
2156                elicitation_id,
2157                response,
2158            })
2159            .await?
2160        {
2161            DaemonReply::Done => Ok(()),
2162            reply => bail!("unexpected elicitation reply {reply:?}"),
2163        }
2164    }
2165
2166    pub async fn native_agent_history(
2167        &mut self,
2168        owner: String,
2169        child: String,
2170        before: Option<(u64, String)>,
2171    ) -> Result<mj_core::native_agent::NativeAgentHistoryPage> {
2172        match self
2173            .request(DaemonAction::NativeAgentHistory {
2174                owner,
2175                child,
2176                before,
2177            })
2178            .await?
2179        {
2180            DaemonReply::NativeAgentHistory(page) => Ok(page),
2181            reply => bail!("unexpected native agent history reply {reply:?}"),
2182        }
2183    }
2184
2185    pub async fn stop_background_task(
2186        &mut self,
2187        session_id: String,
2188        background_task_id: String,
2189    ) -> Result<()> {
2190        match self
2191            .request(DaemonAction::StopBackgroundTask {
2192                session_id,
2193                background_task_id,
2194            })
2195            .await?
2196        {
2197            DaemonReply::Done => Ok(()),
2198            reply => bail!("unexpected background task stop reply {reply:?}"),
2199        }
2200    }
2201
2202    pub async fn suspend_session(&mut self, session_id: String) -> Result<()> {
2203        self.suspend_session_with_ack(session_id, false).await
2204    }
2205
2206    pub async fn suspend_session_with_ack(
2207        &mut self,
2208        session_id: String,
2209        acknowledge_unpublished_work: bool,
2210    ) -> Result<()> {
2211        match self
2212            .request(DaemonAction::SuspendSession {
2213                session_id,
2214                acknowledge_unpublished_work,
2215            })
2216            .await?
2217        {
2218            DaemonReply::Done => Ok(()),
2219            reply => bail!("unexpected close-session reply {reply:?}"),
2220        }
2221    }
2222
2223    pub async fn restart_session(&mut self, session_id: String) -> Result<()> {
2224        ensure!(
2225            self.metadata.protocol_version == PROTOCOL_VERSION,
2226            "cannot Restart a session: daemon protocol {} does not match client protocol {}; restart the daemon, then try again",
2227            self.metadata.protocol_version,
2228            PROTOCOL_VERSION
2229        );
2230        match self
2231            .request(DaemonAction::RestartSession { session_id })
2232            .await?
2233        {
2234            DaemonReply::Done => Ok(()),
2235            reply => bail!("unexpected restart-session reply {reply:?}"),
2236        }
2237    }
2238
2239    pub async fn start_create_session(
2240        &mut self,
2241        request: CreateSessionRequest,
2242    ) -> Result<RegisteredSession> {
2243        match self
2244            .request(DaemonAction::StartCreateSession(request))
2245            .await?
2246        {
2247            DaemonReply::RegisteredSession(registered) => Ok(*registered),
2248            reply => bail!("unexpected start-create reply {reply:?}"),
2249        }
2250    }
2251
2252    pub async fn wait_create_session(&mut self, session_id: String) -> Result<()> {
2253        match self
2254            .request(DaemonAction::WaitCreateSession { session_id })
2255            .await?
2256        {
2257            DaemonReply::Done => Ok(()),
2258            reply => bail!("unexpected wait-create reply {reply:?}"),
2259        }
2260    }
2261
2262    pub async fn resume_session(&mut self, request: ResumeSessionRequest) -> Result<()> {
2263        match self.request(DaemonAction::ResumeSession(request)).await? {
2264            DaemonReply::Done => Ok(()),
2265            reply => bail!("unexpected resume-session reply {reply:?}"),
2266        }
2267    }
2268
2269    pub async fn discard_since_checkpoint(
2270        &mut self,
2271        session_id: String,
2272        checkpoint: mj_core::state::CheckpointMetadata,
2273    ) -> Result<()> {
2274        match self
2275            .request(DaemonAction::DiscardSinceCheckpoint {
2276                session_id,
2277                checkpoint,
2278            })
2279            .await?
2280        {
2281            DaemonReply::Done => Ok(()),
2282            reply => bail!("unexpected force-stop reply {reply:?}"),
2283        }
2284    }
2285
2286    pub async fn destroy_stopped_session(
2287        &mut self,
2288        session_id: String,
2289        delete_branch: bool,
2290    ) -> Result<()> {
2291        match self
2292            .request(DaemonAction::DestroyStoppedSession {
2293                session_id,
2294                delete_branch,
2295            })
2296            .await?
2297        {
2298            DaemonReply::Done => Ok(()),
2299            reply => bail!("unexpected destroy-stopped reply {reply:?}"),
2300        }
2301    }
2302
2303    pub async fn force_destroy_session(
2304        &mut self,
2305        session_id: String,
2306        delete_branch: bool,
2307    ) -> Result<()> {
2308        match self
2309            .request(DaemonAction::ForceDestroySession {
2310                session_id,
2311                delete_branch,
2312            })
2313            .await?
2314        {
2315            DaemonReply::Done => Ok(()),
2316            reply => bail!("unexpected force-destroy reply {reply:?}"),
2317        }
2318    }
2319
2320    pub async fn cancel_lifecycle(&mut self, session_id: String) -> Result<()> {
2321        match self
2322            .request(DaemonAction::CancelLifecycle { session_id })
2323            .await?
2324        {
2325            DaemonReply::Done => Ok(()),
2326            reply => bail!("unexpected cancel-lifecycle reply {reply:?}"),
2327        }
2328    }
2329
2330    pub async fn recover_draft(&mut self, draft_id: String) -> Result<()> {
2331        match self
2332            .request(DaemonAction::RecoverDraft { draft_id })
2333            .await?
2334        {
2335            DaemonReply::Done => Ok(()),
2336            reply => bail!("unexpected recover-draft reply {reply:?}"),
2337        }
2338    }
2339
2340    pub async fn stop(&mut self) -> Result<()> {
2341        match self.request(DaemonAction::Stop).await? {
2342            DaemonReply::Done => Ok(()),
2343            reply => bail!("unexpected stop reply {reply:?}"),
2344        }
2345    }
2346}
2347
2348pub async fn connect_existing() -> Result<DaemonClient> {
2349    let metadata = tokio::task::spawn_blocking(read_metadata)
2350        .await
2351        .context("read daemon metadata task failed")??;
2352    DaemonClient::connect(metadata).await
2353}
2354
2355/// A handle to whatever daemon the metadata file advertises, regardless of its
2356/// protocol version. It only exposes the frozen management subset (`Ping`,
2357/// `Status`, `Stop`), which encodes identically in every protocol version.
2358pub struct ManagementClient {
2359    inner: DaemonClient,
2360}
2361
2362impl ManagementClient {
2363    pub fn new(inner: DaemonClient) -> Self {
2364        Self { inner }
2365    }
2366    pub fn protocol_version(&self) -> u32 {
2367        self.inner.metadata.protocol_version
2368    }
2369
2370    pub async fn status(&mut self) -> Result<DaemonStatus> {
2371        self.inner.status().await
2372    }
2373
2374    pub async fn stop(&mut self) -> Result<()> {
2375        tokio::time::timeout(STOP_TIMEOUT, self.inner.stop())
2376            .await
2377            .context("Mjolnir daemon did not acknowledge the stop before the deadline")?
2378    }
2379
2380    /// Ask the daemon to stop and wait for its process to actually exit.
2381    pub async fn stop_and_wait(mut self) -> Result<()> {
2382        let pid = self.inner.metadata.pid;
2383        self.stop().await?;
2384        wait_for_exit_within(pid, STOP_DRAIN_TIMEOUT)
2385            .await
2386            .with_context(|| {
2387                format!(
2388                    "Mjolnir daemon {pid} accepted the stop but was still running after {}s",
2389                    STOP_DRAIN_TIMEOUT.as_secs()
2390                )
2391            })
2392    }
2393}
2394
2395pub async fn connect_management() -> Result<ManagementClient> {
2396    Ok(ManagementClient {
2397        inner: DaemonClient::connect(read_metadata_any()?).await?,
2398    })
2399}
2400
2401/// Refuse to speak to a daemon whose protocol this build does not know.
2402///
2403/// The two protocol numbers alone do not say which `mj` ran. The usual cause is
2404/// a second installation: a `cargo install`ed client sits earlier on PATH than
2405/// the build whose daemon is running, so every command fails here while the
2406/// other binary works, and nothing in the message says where either lives. It
2407/// therefore names both executables and both versions.
2408pub fn ensure_supported_daemon_protocol(metadata: &DaemonMetadata) -> Result<()> {
2409    ensure!(
2410        metadata.protocol_version <= PROTOCOL_VERSION,
2411        "{}",
2412        unsupported_daemon_protocol_message(
2413            metadata.protocol_version,
2414            &describe_running_daemon_and_client_builds(metadata.pid, &metadata.build_version),
2415        )
2416    );
2417    Ok(())
2418}
2419
2420/// The message [`ensure_supported_daemon_protocol`] fails with, given the
2421/// sentence that names both builds, so it can be read without a daemon.
2422fn unsupported_daemon_protocol_message(daemon_protocol: u32, builds: &str) -> String {
2423    format!(
2424        "the daemon uses a newer protocol ({daemon_protocol}) than this client ({PROTOCOL_VERSION}). {builds}. \
2425         Put the daemon's directory first on PATH, or reinstall this client from that build."
2426    )
2427}
2428// Settings can warm draft profile capabilities through the daemon-owned catalog.
2429// Runtime deltas carry transcript item changes, and a client fetches a
2430// session's tail from the daemon at its cursor instead of reading SQLite.
2431// Lifecycle stages can name the harness preparation step a worker reports.
2432pub const PROTOCOL_VERSION: u32 = 56;
2433pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
2434/// How long a daemon is given to exit after it accepts a stop.
2435///
2436/// Stopping cancels a token and returns immediately; the daemon then unwinds
2437/// its session manager, its phone server and its pollers. That is normally
2438/// fast, but a daemon whose database has been migrated out from under it fails
2439/// every read while it winds down and has been observed taking over five
2440/// seconds — which the previous five-second bound missed by a fraction,
2441/// reporting a stop that had in fact worked as `did not stop` and aborting the
2442/// restart that depended on it.
2443pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
2444/// How long a requested stop may take: the daemon waits up to 60 seconds for
2445/// session destroys in flight (#1191) before it winds down.
2446pub const STOP_DRAIN_TIMEOUT: Duration = Duration::from_secs(90);
2447pub const RETRY_DELAY: Duration = Duration::from_millis(40);
2448impl DaemonClient {
2449    pub async fn prepare_move_session(
2450        &mut self,
2451        selection: MoveSelection,
2452    ) -> Result<MovePreparation> {
2453        match self
2454            .request(DaemonAction::PrepareMoveSession(selection))
2455            .await?
2456        {
2457            DaemonReply::MovePreparation(preparation) => Ok(*preparation),
2458            _ => bail!("daemon returned an unexpected move preparation reply"),
2459        }
2460    }
2461
2462    pub async fn move_sources(
2463        &mut self,
2464        session_id: String,
2465        cleanup_operation_id: Option<String>,
2466    ) -> Result<Vec<mj_core::move_workspace::RetainedMoveSource>> {
2467        match self
2468            .request(DaemonAction::MoveSources {
2469                session_id,
2470                cleanup_operation_id,
2471            })
2472            .await?
2473        {
2474            DaemonReply::MoveSources(sources) => Ok(sources),
2475            reply => bail!("unexpected Move sources reply: {reply:?}"),
2476        }
2477    }
2478
2479    pub async fn move_session(&mut self, request: MoveSessionRequest) -> Result<MoveOutcome> {
2480        match self.request(DaemonAction::MoveSession(request)).await? {
2481            DaemonReply::MoveOutcome(outcome) => Ok(outcome),
2482            _ => bail!("daemon returned an unexpected move reply"),
2483        }
2484    }
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489    use super::*;
2490    use crate::executable::{BuildDescription, describe_daemon_and_client_builds};
2491    use std::path::Path;
2492
2493    // Hard-won: c672eb41a1bf: macOS startup hang waited forever for an exited but unreaped daemon
2494    #[cfg(unix)]
2495    #[test]
2496    fn an_exited_unreaped_process_is_a_zombie_and_not_alive() {
2497        // An upgrade waits for the old daemon to leave. If that daemon exited
2498        // but is still a child nobody has reaped, `kill(pid, 0)` keeps finding
2499        // it, so only the zombie check lets the wait end.
2500        use std::time::{Duration, Instant};
2501
2502        let mut running = std::process::Command::new("sleep")
2503            .arg("30")
2504            .spawn()
2505            .unwrap();
2506        assert!(process_is_alive(running.id()));
2507        assert!(!process_is_zombie(running.id()));
2508        running.kill().unwrap();
2509        running.wait().unwrap();
2510
2511        let mut exited = std::process::Command::new("sh")
2512            .args(["-c", "exit 0"])
2513            .spawn()
2514            .unwrap();
2515        let pid = exited.id();
2516        let deadline = Instant::now() + Duration::from_secs(10);
2517        while !process_is_zombie(pid) {
2518            assert!(
2519                Instant::now() < deadline,
2520                "an exited, unreaped child was never reported as a zombie"
2521            );
2522            std::thread::sleep(Duration::from_millis(20));
2523        }
2524        assert!(!process_is_alive(pid));
2525        exited.wait().unwrap();
2526    }
2527
2528    // Hard-won: 372572b0b7b6: stopped daemon endpoint removal broke status and session refresh
2529    #[test]
2530    fn a_missing_endpoint_file_reads_as_a_stopped_daemon() {
2531        let directory = tempfile::tempdir().unwrap();
2532        let path = directory.path().join("daemon.json");
2533        let error = read_metadata_at(&path).unwrap_err();
2534        let stopped = daemon_not_running(&error).expect("stopped, not an I/O failure");
2535        assert_eq!(stopped.metadata_path, path);
2536        assert_eq!(format!("{error:#}"), "the Mjolnir daemon is not running");
2537
2538        std::fs::write(&path, b"not json").unwrap();
2539        let error = read_metadata_at(&path).unwrap_err();
2540        assert!(daemon_not_running(&error).is_none());
2541    }
2542
2543    #[tokio::test]
2544    async fn restart_session_rejects_an_old_daemon_before_sending_the_action() {
2545        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2546        let metadata = DaemonMetadata {
2547            protocol_version: PROTOCOL_VERSION - 1,
2548            pid: std::process::id(),
2549            address: listener.local_addr().unwrap(),
2550            token: "old-restart-daemon".into(),
2551            started_at: "test".into(),
2552            build_version: env!("CARGO_PKG_VERSION").into(),
2553        };
2554        let server = tokio::spawn(async move {
2555            let (mut stream, _) = listener.accept().await.unwrap();
2556            let mut byte = [0_u8; 1];
2557            assert_eq!(stream.read(&mut byte).await.unwrap(), 0);
2558        });
2559        let mut client = DaemonClient::connect(metadata).await.unwrap();
2560
2561        let error = client
2562            .restart_session("session-1".into())
2563            .await
2564            .unwrap_err();
2565        let message = format!("{error:#}");
2566        assert!(message.contains(&format!("daemon protocol {}", PROTOCOL_VERSION - 1)));
2567        assert!(message.contains(&format!("client protocol {PROTOCOL_VERSION}")));
2568        assert!(message.contains("restart the daemon"));
2569        drop(client);
2570        server.await.unwrap();
2571    }
2572
2573    #[tokio::test]
2574    async fn abandoned_prompt_history_uses_the_daemon_protocol_action() {
2575        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2576        let metadata = DaemonMetadata {
2577            protocol_version: PROTOCOL_VERSION,
2578            pid: std::process::id(),
2579            address: listener.local_addr().unwrap(),
2580            token: "abandoned-prompt-test".into(),
2581            started_at: "test".into(),
2582            build_version: env!("CARGO_PKG_VERSION").into(),
2583        };
2584        let server = tokio::spawn(async move {
2585            let (mut stream, _) = listener.accept().await.unwrap();
2586            let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2587            assert_eq!(request.protocol_version, PROTOCOL_VERSION);
2588            assert_eq!(request.token, "abandoned-prompt-test");
2589            assert_eq!(
2590                serde_json::to_value(request.action).unwrap(),
2591                serde_json::json!({
2592                    "action": "record_abandoned_prompt",
2593                    "arguments": {
2594                        "session_id": "session-1",
2595                        "bundle_id": "project-1",
2596                        "text": "unfinished draft"
2597                    }
2598                })
2599            );
2600            write_frame(
2601                &mut stream,
2602                &ResponseEnvelope {
2603                    protocol_version: request.protocol_version,
2604                    request_id: request.request_id,
2605                    result: Ok(DaemonReply::Done),
2606                },
2607            )
2608            .await
2609            .unwrap();
2610        });
2611        let mut client = DaemonClient::connect(metadata).await.unwrap();
2612
2613        client
2614            .record_abandoned_prompt(
2615                "session-1".into(),
2616                "project-1".into(),
2617                "unfinished draft".into(),
2618            )
2619            .await
2620            .unwrap();
2621        server.await.unwrap();
2622    }
2623
2624    // Hard-won: c502e9bdf662: cold-cache discovery failed to save and left Codex profiles out of model choices
2625    #[tokio::test]
2626    async fn subagent_discovery_uses_the_daemon_and_preserves_choices_and_failures() {
2627        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2628        let metadata = DaemonMetadata {
2629            protocol_version: PROTOCOL_VERSION,
2630            pid: std::process::id(),
2631            address: listener.local_addr().unwrap(),
2632            token: "subagent-discovery-test".into(),
2633            started_at: "test".into(),
2634            build_version: env!("CARGO_PKG_VERSION").into(),
2635        };
2636        let options: mj_core::subagent::SubagentOptions =
2637            serde_json::from_value(serde_json::json!({
2638                "models": [{"value": "gpt-6-luna", "name": "Luna", "description": null}],
2639                "efforts": [{"value": "high", "name": "High", "description": null}],
2640                "unavailable": ["another-profile: authentication failed"]
2641            }))
2642            .unwrap();
2643        let expected = serde_json::to_value(&options).unwrap();
2644        let mut draft = Config::default();
2645        draft.profiles.insert(
2646            "codex".into(),
2647            serde_json::from_value(serde_json::json!({
2648                "kind": "codex", "home": "/unsaved/profile",
2649                "subagents": {"mode": "single_model", "model": "gpt-6-luna", "effort": "high"}
2650            }))
2651            .unwrap(),
2652        );
2653        let expected_draft = serde_json::to_value(&draft).unwrap();
2654        let server = tokio::spawn(async move {
2655            let (mut stream, _) = listener.accept().await.unwrap();
2656            for (index, model) in [
2657                None,
2658                Some("gpt-6-luna".to_owned()),
2659                Some("gpt-6-luna".to_owned()),
2660            ]
2661            .into_iter()
2662            .enumerate()
2663            {
2664                let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2665                assert_eq!(request.token, "subagent-discovery-test");
2666                let DaemonAction::SubagentOptions {
2667                    profile,
2668                    model: requested,
2669                    config,
2670                } = request.action
2671                else {
2672                    panic!("unexpected discovery request")
2673                };
2674                assert_eq!(profile, "codex");
2675                assert_eq!(requested, model);
2676                assert_eq!(
2677                    serde_json::to_value(config).unwrap(),
2678                    if index == 1 {
2679                        expected_draft.clone()
2680                    } else {
2681                        serde_json::Value::Null
2682                    }
2683                );
2684                write_frame(
2685                    &mut stream,
2686                    &ResponseEnvelope {
2687                        protocol_version: request.protocol_version,
2688                        request_id: request.request_id,
2689                        result: if index != 2 {
2690                            Ok(DaemonReply::SubagentOptions(options.clone()))
2691                        } else {
2692                            Err("profile discovery cancelled".into())
2693                        },
2694                    },
2695                )
2696                .await
2697                .unwrap();
2698            }
2699        });
2700        let mut client = DaemonClient::connect(metadata).await.unwrap();
2701        let discovered = client
2702            .subagent_options("codex".into(), None, None)
2703            .await
2704            .unwrap();
2705        assert_eq!(serde_json::to_value(discovered).unwrap(), expected);
2706        let discovered = client
2707            .subagent_options("codex".into(), Some("gpt-6-luna".into()), Some(draft))
2708            .await
2709            .unwrap();
2710        assert_eq!(serde_json::to_value(discovered).unwrap(), expected);
2711        let error = client
2712            .subagent_options("codex".into(), Some("gpt-6-luna".into()), None)
2713            .await
2714            .unwrap_err();
2715        assert_eq!(error.to_string(), "profile discovery cancelled");
2716        server.await.unwrap();
2717    }
2718    #[tokio::test]
2719    async fn upgrade_refusal_retries_the_identical_command_but_lost_acknowledgements_do_not() {
2720        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2721        let metadata = DaemonMetadata {
2722            protocol_version: PROTOCOL_VERSION,
2723            pid: std::process::id(),
2724            address: listener.local_addr().unwrap(),
2725            token: "test-token".into(),
2726            started_at: "test".into(),
2727            build_version: env!("CARGO_PKG_VERSION").into(),
2728        };
2729        let action = DaemonAction::SubmitSessionCommand {
2730            inherited_draft: None,
2731            session_id: "test-session".into(),
2732            command_id: "steer-command".into(),
2733            command: RelayCommand::Steer {
2734                active_prompt_id: "active-command".into(),
2735                queued_prompt_id: "queued-command".into(),
2736            },
2737        };
2738        let expected = serde_json::to_value(&action).unwrap();
2739        let server = tokio::spawn(async move {
2740            for reply in [
2741                Some(DaemonReply::UpgradePending),
2742                Some(DaemonReply::Done),
2743                None,
2744            ] {
2745                let (mut stream, _) = listener.accept().await.unwrap();
2746                let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2747                assert_eq!(serde_json::to_value(&request.action).unwrap(), expected);
2748                if let Some(reply) = reply {
2749                    write_frame(
2750                        &mut stream,
2751                        &ResponseEnvelope {
2752                            protocol_version: request.protocol_version,
2753                            request_id: request.request_id,
2754                            result: Ok(reply),
2755                        },
2756                    )
2757                    .await
2758                    .unwrap();
2759                }
2760            }
2761        });
2762        let mut client = DaemonClient::connect(metadata.clone()).await.unwrap();
2763        let reply = client
2764            .request_with_reconnect(action.clone(), || DaemonClient::connect(metadata.clone()))
2765            .await
2766            .unwrap();
2767        assert!(matches!(reply, DaemonReply::Done));
2768        let mut client = DaemonClient::connect(metadata).await.unwrap();
2769        assert!(
2770            client
2771                .request_with_reconnect(action, || async {
2772                    panic!("an ambiguous acknowledgement must not replay a mutation");
2773                })
2774                .await
2775                .is_err()
2776        );
2777        server.await.unwrap();
2778    }
2779
2780    // Hard-won: 25fee3db784d: protocol-mismatch advice omitted which installed mj the user ran
2781    #[test]
2782    fn unsupported_protocol_message_names_both_binaries_and_versions() {
2783        let message = unsupported_daemon_protocol_message(
2784            PROTOCOL_VERSION + 5,
2785            &describe_daemon_and_client_builds(
2786                4242,
2787                BuildDescription {
2788                    executable: Some(Path::new("/home/dev/mj/target/release/mj")),
2789                    version: "2.14.0",
2790                },
2791                BuildDescription {
2792                    executable: Some(Path::new("/home/dev/.cargo/bin/mj")),
2793                    version: "2.9.0",
2794                },
2795            ),
2796        );
2797        assert_eq!(
2798            message,
2799            format!(
2800                "the daemon uses a newer protocol ({}) than this client ({PROTOCOL_VERSION}). \
2801                 Daemon 4242 runs /home/dev/mj/target/release/mj (version 2.14.0), \
2802                 while this client runs /home/dev/.cargo/bin/mj (version 2.9.0). \
2803                 Put the daemon's directory first on PATH, or reinstall this client from that build.",
2804                PROTOCOL_VERSION + 5
2805            )
2806        );
2807    }
2808
2809    #[test]
2810    fn unsupported_protocol_message_still_names_the_client_when_the_daemon_file_is_unknown() {
2811        let message = unsupported_daemon_protocol_message(
2812            PROTOCOL_VERSION + 1,
2813            &describe_daemon_and_client_builds(
2814                4242,
2815                BuildDescription {
2816                    executable: None,
2817                    version: "2.14.0",
2818                },
2819                BuildDescription {
2820                    executable: Some(Path::new("/home/dev/.cargo/bin/mj")),
2821                    version: "2.9.0",
2822                },
2823            ),
2824        );
2825        assert!(
2826            message.contains("Daemon 4242 runs an unknown file (version 2.14.0)"),
2827            "{message}"
2828        );
2829        assert!(
2830            message.contains("this client runs /home/dev/.cargo/bin/mj (version 2.9.0)"),
2831            "{message}"
2832        );
2833    }
2834}