Skip to main content

nomoreide_remote_protocol/
device_bound.rs

1//! Everything the platform may send to a daemon — the frozen v1 command union.
2//!
3//! This union **is** the remote attack surface. A phone cannot ask the machine
4//! for anything that is not a variant below, and no variant carries a command,
5//! argument, working directory, environment, port, SSH host, process id or kill
6//! strategy. Adding one is a protocol change, not a feature.
7//!
8//! What is deliberately absent, and must stay absent in v1: raw terminal input,
9//! arbitrary shell, filesystem browsing or writes, git mutations, database
10//! queries or unlock, service and config registration, environment and
11//! credential reads, provider and deployment mutations, daemon shutdown,
12//! port-holder killing, and generic HTTP forwarding. The exclusion list is not
13//! commentary — [`DeviceBound::KINDS`] is exhaustive and the parser refuses
14//! everything else by name.
15//!
16//! The union has since grown a **read-only inspection surface** — Actions runs,
17//! pull requests, agent usage, the error inbox, the timeline. It grew without a
18//! version bump because each is gated by a capability the daemon advertises,
19//! which is what capabilities are for; and it widened the attack surface by
20//! nothing, because not one of those frames can change the state of anything.
21//! The exclusion list above is unchanged, and the same rule applied to each
22//! addition: a phone names *what to look at* on the machine it already paired,
23//! never *where* — there is no repository, path or command in any of them.
24
25use super::errors::{ErrorCode, ProtocolError};
26use serde::{Deserialize, Serialize};
27
28/// One frame travelling platform → daemon.
29///
30/// Adjacently tagged, so `type` and `payload` in the envelope are the enum's
31/// own discriminant and content rather than a second hand-written mapping that
32/// could drift from it.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "type", content = "payload")]
35pub enum DeviceBound {
36    /// The relay's answer to the daemon's hello: the negotiated version, and
37    /// how much of the protocol this session may use.
38    #[serde(rename = "session.welcome")]
39    SessionWelcome(SessionWelcome),
40    /// The device has been revoked. Advisory only — the socket closing is the
41    /// revocation, and a daemon that ignores this must still be unable to act.
42    #[serde(rename = "session.revoke")]
43    SessionRevoke(SessionRevoke),
44
45    /// Sanitized machine snapshot: name, platform, daemon version, presence.
46    #[serde(rename = "device.snapshot.request")]
47    DeviceSnapshot(Empty),
48    /// Registered service names, descriptions, kinds, ports and runtime states.
49    #[serde(rename = "service.list.request")]
50    ServiceList(Empty),
51    /// `start`, `stop` or `restart` on one exactly-named registered service.
52    #[serde(rename = "service.action.request")]
53    ServiceAction(ServiceActionRequest),
54    /// Bounded, redacted recent logs for one exactly-named registered service.
55    #[serde(rename = "service.logs.request")]
56    ServiceLogs(ServiceLogsRequest),
57    /// Registered bundle names and states. Read-only: there is no bundle
58    /// mutation in the v1 allowlist.
59    #[serde(rename = "bundle.list.request")]
60    BundleList(Empty),
61
62    /// Which agent providers are installed, and what they can do.
63    #[serde(rename = "agent.providers.request")]
64    AgentProviders(Empty),
65    /// Start or resume one agent turn in the daemon's selected workspace.
66    #[serde(rename = "agent.turn.start")]
67    AgentTurnStart(AgentTurnStart),
68    /// Cancel an active turn.
69    #[serde(rename = "agent.turn.cancel")]
70    AgentTurnCancel(AgentTurnCancel),
71    /// Allow or deny one pending mutating tool request.
72    #[serde(rename = "agent.approval.resolve")]
73    AgentApprovalResolve(AgentApprovalResolve),
74
75    /// Start a new agent terminal on the machine. **v2.**
76    #[serde(rename = "terminal.spawn.request")]
77    TerminalSpawn(TerminalSpawnRequest),
78    /// Start a plain shell on the machine. **v2.**
79    ///
80    /// Its own frame rather than a flag on the spawn above, so an older daemon
81    /// that never heard of it cannot be handed one by accident — and so the
82    /// capability that gates it gates exactly one thing.
83    #[serde(rename = "terminal.shell.request")]
84    TerminalShell(Empty),
85    /// Which agent terminals are running and could be mirrored. **v2.**
86    #[serde(rename = "terminal.sessions.request")]
87    TerminalSessions(Empty),
88    /// Begin mirroring one agent terminal. **v2.**
89    #[serde(rename = "terminal.attach.request")]
90    TerminalAttach(TerminalAttachRequest),
91    /// Keystrokes for a mirrored terminal. **v2.**
92    #[serde(rename = "terminal.input")]
93    TerminalInput(TerminalInput),
94    /// The viewport changed size. **v2.**
95    #[serde(rename = "terminal.resize")]
96    TerminalResize(TerminalResize),
97    /// Stop mirroring. The PTY keeps running; only the mirror ends. **v2.**
98    #[serde(rename = "terminal.detach")]
99    TerminalDetach(TerminalDetach),
100    /// End a session: the PTY closes and the agent in it stops.
101    ///
102    /// **The one thing on this surface that destroys work.** Detaching leaves
103    /// the agent running because a phone walking away should not stop it; this
104    /// is the opposite, and it is a separate frame with a capability of its own
105    /// so a machine can offer every other terminal command without offering
106    /// this one. It names a session the machine reported, like an attach —
107    /// never a pid, and never a signal to send.
108    #[serde(rename = "terminal.kill.request")]
109    TerminalKill(TerminalKillRequest),
110
111    /// The repositories this machine has registered, so a phone can say which
112    /// one it is asking about.
113    #[serde(rename = "repositories.request")]
114    Repositories(Empty),
115
116    /// Recent GitHub Actions runs for the selected repository.
117    #[serde(rename = "github.runs.request")]
118    GithubRuns(GithubRunsRequest),
119    /// The jobs inside one run — which step went red.
120    #[serde(rename = "github.run.jobs.request")]
121    GithubRunJobs(GithubRunJobsRequest),
122    /// Pull requests on the selected repository.
123    #[serde(rename = "github.prs.request")]
124    GithubPulls(GithubPullsRequest),
125    /// One pull request by number.
126    #[serde(rename = "github.pr.request")]
127    GithubPull(GithubPullRequestRef),
128    #[serde(rename = "linear.request")]
129    Linear(super::linear::LinearRequest),
130
131    /// What Claude and Codex have spent, and how full their rate-limit windows
132    /// are.
133    #[serde(rename = "agent.usage.request")]
134    AgentUsage(Empty),
135
136    /// The deduped error inbox.
137    #[serde(rename = "errors.request")]
138    Errors(ErrorsRequest),
139    /// What the runtime did, across every service.
140    #[serde(rename = "timeline.request")]
141    Timeline(TimelineRequest),
142}
143
144/// A payload with no fields.
145///
146/// A unit variant would serialise as a missing `payload`, and "absent" is a
147/// second shape for a reader to handle. An empty object is one shape, and
148/// `deny_unknown_fields` still refuses anything smuggled into it.
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct Empty {}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub struct SessionWelcome {
156    /// The version both ends will speak for the rest of this session.
157    pub version: u32,
158    pub mode: super::version::SessionMode,
159    pub device_id: String,
160    /// The platform's own build, for the daemon's logs. Never parsed.
161    pub server_version: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct SessionRevoke {
167    /// Prose for the daemon's log and the local `nomoreide remote status`, so a
168    /// user is told "revoked from your account" rather than "connection lost".
169    pub reason: String,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum ServiceAction {
175    Start,
176    Stop,
177    Restart,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
182pub struct ServiceActionRequest {
183    /// An exact registered service name. Never a pattern, never a path.
184    pub service: String,
185    pub action: ServiceAction,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct ServiceLogsRequest {
191    pub service: String,
192    /// Clamped to [`super::limits::MAX_LOG_LINES`] by the daemon. Absent means
193    /// the maximum — an omitted bound is the safest bound, not an unbounded
194    /// one.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub limit: Option<u32>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201pub struct AgentTurnStart {
202    /// Resume this run, or start a new one when absent. The daemon mints run
203    /// ids; a caller-supplied one is only ever a reference to a run it was
204    /// already told about.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub run_id: Option<String>,
207    /// Which installed provider to use. Absent means the daemon's selection.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub provider: Option<String>,
210    /// Bounded by [`super::limits::MAX_AGENT_PROMPT_BYTES`].
211    pub prompt: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase", deny_unknown_fields)]
216pub struct AgentTurnCancel {
217    pub run_id: String,
218}
219
220/// A remote verdict on one tool call.
221///
222/// Its own type rather than a reuse of the local approval broker's, because
223/// this one is frozen: the broker is free to grow a "always allow" the day the
224/// local UI wants one, and that must never become reachable from a phone.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum ApprovalVerdict {
228    Allow,
229    Deny,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase", deny_unknown_fields)]
234pub struct AgentApprovalResolve {
235    pub run_id: String,
236    /// The approval's id, not the frame's. One turn can have several pending.
237    pub approval_id: String,
238    pub verdict: ApprovalVerdict,
239}
240
241/// Start an agent, in a terminal, on the machine.
242///
243/// **There is still deliberately no working directory here.** A caller-supplied
244/// path would be the filesystem reach that remote control does not have, and no
245/// field for it is the way to not have it.
246///
247/// [`Self::repository`] is not that field, and the difference is the whole rule
248/// this union is built on: it names one of the machine's *own registered*
249/// repositories, by an id the machine itself reported, and the daemon is what
250/// turns that id into a path. A phone still cannot say where — only which of
251/// the things already on the machine.
252///
253/// Nor is there an argv: `provider` picks between the agent CLIs this machine
254/// knows, and everything else about the invocation is the daemon's.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase", deny_unknown_fields)]
257pub struct TerminalSpawnRequest {
258    /// `claude` or `codex`. Absent means the machine's own selection.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub provider: Option<String>,
261    /// The first thing to say to it. Bounded by
262    /// [`super::limits::MAX_AGENT_PROMPT_BYTES`], like any other prompt from a
263    /// phone.
264    pub prompt: String,
265    /// Which registered repository to start the agent in — **an id this machine
266    /// already reported**, never a path the caller invented.
267    ///
268    /// The same constraint `GithubRunsRequest::repository` and
269    /// `TerminalAttachRequest::session_id` carry, for the same reason: naming
270    /// one of the machine's own things is not the arbitrary filesystem reach
271    /// that a path would make of this surface. The dispatcher refuses anything
272    /// the registry does not hold.
273    ///
274    /// Absent means the repository the machine has selected, which is what this
275    /// asked for before the field existed. It never *changes* that selection —
276    /// the dashboard on the user's desk is looking at it too.
277    ///
278    /// A daemon that predates this field refuses the whole frame rather than
279    /// ignoring the key, because this struct denies unknown fields. That is why
280    /// it is gated by its own capability,
281    /// [`super::version::capabilities::TERMINAL_SPAWN_REPOSITORY`], rather than
282    /// riding on `terminal.spawn`: a phone that is not offered the name must
283    /// keep sending what it sent before.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub repository: Option<String>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290pub struct TerminalAttachRequest {
291    /// An exact session id the daemon already reported. The daemon refuses any
292    /// session that is not an *agent* session, so this is never a way to reach
293    /// a shell — see the dispatcher, which is where that is enforced.
294    pub session_id: String,
295    /// The viewport the phone will render into. Bounded by
296    /// [`super::limits::MAX_TERMINAL_DIMENSION`] before it reaches an `ioctl`.
297    pub cols: u16,
298    pub rows: u16,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase", deny_unknown_fields)]
303pub struct TerminalInput {
304    pub stream_id: String,
305    /// Bounded by [`super::limits::MAX_TERMINAL_INPUT_BYTES`].
306    pub data: super::terminal_bytes::TerminalBytes,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase", deny_unknown_fields)]
311pub struct TerminalResize {
312    pub stream_id: String,
313    pub cols: u16,
314    pub rows: u16,
315}
316
317/// End one agent terminal.
318///
319/// No signal, no pid, no force flag: the daemon closes the session the way the
320/// dashboard's own close button does, and how that is done is the machine's
321/// business. A phone names *which*, and nothing else — the same constraint
322/// [`TerminalAttachRequest::session_id`] carries.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase", deny_unknown_fields)]
325pub struct TerminalKillRequest {
326    /// A session id the machine reported. Anything else is refused.
327    pub session_id: String,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase", deny_unknown_fields)]
332pub struct TerminalDetach {
333    pub stream_id: String,
334}
335
336/// Which runs to list.
337///
338/// **There is no repository here, and that is the point.** The daemon answers
339/// for the repository it already has selected — the same one the dashboard is
340/// looking at. A caller-supplied `owner/repo` would turn remote control into a
341/// general-purpose GitHub client running under the user's token, which is a
342/// much larger thing than "show me my CI".
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct GithubRunsRequest {
346    /// Which repository to answer for — **an id this machine already
347    /// reported**, never an `owner/repo` the caller invented.
348    ///
349    /// This is the same constraint `TerminalAttachRequest::session_id` carries,
350    /// and for the same reason: naming one of the machine's own things is not
351    /// the general-purpose GitHub client that an arbitrary `owner/repo` would
352    /// make of this surface. The dispatcher refuses anything the registry does
353    /// not hold.
354    ///
355    /// Absent means the repository the machine has selected, which is what a
356    /// phone opening the screen cold wants, and what this asked for before the
357    /// field existed. It never *changes* that selection — the dashboard on the
358    /// user's desk is looking at it too.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub repository: Option<String>,
361    /// Only runs on this branch. Absent means every branch, which is what a
362    /// phone opening the screen cold wants.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub branch: Option<String>,
365    /// Clamped to [`super::limits::MAX_WORKFLOW_RUNS`]. Absent means the
366    /// maximum — an omitted bound is the safest bound, not an unbounded one.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub limit: Option<u32>,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase", deny_unknown_fields)]
373pub struct GithubRunJobsRequest {
374    /// Which repository to answer for — **an id this machine already
375    /// reported**, never an `owner/repo` the caller invented.
376    ///
377    /// This is the same constraint `TerminalAttachRequest::session_id` carries,
378    /// and for the same reason: naming one of the machine's own things is not
379    /// the general-purpose GitHub client that an arbitrary `owner/repo` would
380    /// make of this surface. The dispatcher refuses anything the registry does
381    /// not hold.
382    ///
383    /// Absent means the repository the machine has selected, which is what a
384    /// phone opening the screen cold wants, and what this asked for before the
385    /// field existed. It never *changes* that selection — the dashboard on the
386    /// user's desk is looking at it too.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub repository: Option<String>,
389    /// GitHub's run id, as a string, exactly as a listing reported it. The
390    /// daemon refuses anything that is not digits — it becomes part of a URL,
391    /// and a run id is the only caller-supplied value on this surface that
392    /// does.
393    pub run_id: String,
394}
395
396/// Which pull requests to list. Mirrors GitHub's own filter and nothing more.
397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum PullRequestFilter {
400    #[default]
401    Open,
402    Closed,
403    All,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase", deny_unknown_fields)]
408pub struct GithubPullsRequest {
409    /// Which repository to answer for — **an id this machine already
410    /// reported**, never an `owner/repo` the caller invented.
411    ///
412    /// This is the same constraint `TerminalAttachRequest::session_id` carries,
413    /// and for the same reason: naming one of the machine's own things is not
414    /// the general-purpose GitHub client that an arbitrary `owner/repo` would
415    /// make of this surface. The dispatcher refuses anything the registry does
416    /// not hold.
417    ///
418    /// Absent means the repository the machine has selected, which is what a
419    /// phone opening the screen cold wants, and what this asked for before the
420    /// field existed. It never *changes* that selection — the dashboard on the
421    /// user's desk is looking at it too.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub repository: Option<String>,
424    /// Absent means [`PullRequestFilter::Open`].
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub state: Option<PullRequestFilter>,
427    /// Clamped to [`super::limits::MAX_PULL_REQUESTS`].
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub limit: Option<u32>,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase", deny_unknown_fields)]
434pub struct GithubPullRequestRef {
435    /// Which repository to answer for — **an id this machine already
436    /// reported**, never an `owner/repo` the caller invented.
437    ///
438    /// This is the same constraint `TerminalAttachRequest::session_id` carries,
439    /// and for the same reason: naming one of the machine's own things is not
440    /// the general-purpose GitHub client that an arbitrary `owner/repo` would
441    /// make of this surface. The dispatcher refuses anything the registry does
442    /// not hold.
443    ///
444    /// Absent means the repository the machine has selected, which is what a
445    /// phone opening the screen cold wants, and what this asked for before the
446    /// field existed. It never *changes* that selection — the dashboard on the
447    /// user's desk is looking at it too.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub repository: Option<String>,
450    pub number: u64,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "camelCase", deny_unknown_fields)]
455pub struct ErrorsRequest {
456    /// Clamped to [`super::limits::MAX_INCIDENTS`].
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub limit: Option<u32>,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "camelCase", deny_unknown_fields)]
463pub struct TimelineRequest {
464    /// Clamped to [`super::limits::MAX_TIMELINE_ENTRIES`].
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub limit: Option<u32>,
467}
468
469impl DeviceBound {
470    /// Every accepted `type`, in the order the union declares them.
471    ///
472    /// This is the allowlist. A name absent from it is refused with
473    /// [`ErrorCode::UnknownCommand`], whatever its payload looks like.
474    pub const KINDS: &'static [&'static str] = &[
475        "session.welcome",
476        "session.revoke",
477        "device.snapshot.request",
478        "service.list.request",
479        "service.action.request",
480        "service.logs.request",
481        "bundle.list.request",
482        "agent.providers.request",
483        "agent.turn.start",
484        "agent.turn.cancel",
485        "agent.approval.resolve",
486        "terminal.spawn.request",
487        "terminal.shell.request",
488        "terminal.sessions.request",
489        "terminal.attach.request",
490        "terminal.input",
491        "terminal.resize",
492        "terminal.detach",
493        "terminal.kill.request",
494        "repositories.request",
495        "github.runs.request",
496        "github.run.jobs.request",
497        "github.prs.request",
498        "github.pr.request",
499        "linear.request",
500        "agent.usage.request",
501        "errors.request",
502        "timeline.request",
503    ];
504
505    pub fn kind(&self) -> &'static str {
506        match self {
507            Self::SessionWelcome(_) => "session.welcome",
508            Self::SessionRevoke(_) => "session.revoke",
509            Self::DeviceSnapshot(_) => "device.snapshot.request",
510            Self::ServiceList(_) => "service.list.request",
511            Self::ServiceAction(_) => "service.action.request",
512            Self::ServiceLogs(_) => "service.logs.request",
513            Self::BundleList(_) => "bundle.list.request",
514            Self::AgentProviders(_) => "agent.providers.request",
515            Self::AgentTurnStart(_) => "agent.turn.start",
516            Self::AgentTurnCancel(_) => "agent.turn.cancel",
517            Self::AgentApprovalResolve(_) => "agent.approval.resolve",
518            Self::TerminalSpawn(_) => "terminal.spawn.request",
519            Self::TerminalShell(_) => "terminal.shell.request",
520            Self::TerminalSessions(_) => "terminal.sessions.request",
521            Self::TerminalAttach(_) => "terminal.attach.request",
522            Self::TerminalInput(_) => "terminal.input",
523            Self::TerminalResize(_) => "terminal.resize",
524            Self::TerminalDetach(_) => "terminal.detach",
525            Self::TerminalKill(_) => "terminal.kill.request",
526            Self::Repositories(_) => "repositories.request",
527            Self::GithubRuns(_) => "github.runs.request",
528            Self::GithubRunJobs(_) => "github.run.jobs.request",
529            Self::GithubPulls(_) => "github.prs.request",
530            Self::GithubPull(_) => "github.pr.request",
531            Self::Linear(_) => "linear.request",
532            Self::AgentUsage(_) => "agent.usage.request",
533            Self::Errors(_) => "errors.request",
534            Self::Timeline(_) => "timeline.request",
535        }
536    }
537
538    /// Whether this frame can change the state of the user's machine.
539    ///
540    /// Drives two rules that must not be decided case by case at the call site:
541    /// a mutation is never automatically retried, and no mutation routes in
542    /// [`super::version::SessionMode::Degraded`].
543    pub fn mutating(&self) -> bool {
544        match self {
545            Self::Linear(request) => request.is_mutating(),
546            Self::ServiceAction(_)
547            | Self::AgentTurnStart(_)
548            | Self::AgentTurnCancel(_)
549            | Self::AgentApprovalResolve(_)
550            // Typing is the most mutating thing there is, and a retried
551            // keystroke is a second keystroke. Attaching, resizing and
552            // detaching only move the mirror, so they are safe to repeat.
553            | Self::TerminalInput(_)
554            // A retried spawn is a second agent, running a second time — and a
555            // retried shell is a second shell.
556            | Self::TerminalSpawn(_)
557            | Self::TerminalShell(_)
558            // Ending a session is destructive and a timeout says nothing about
559            // whether it happened. A retry that arrives after the id came round
560            // again would end a different session.
561            | Self::TerminalKill(_) => true,
562            Self::SessionWelcome(_)
563            | Self::SessionRevoke(_)
564            | Self::DeviceSnapshot(_)
565            | Self::ServiceList(_)
566            | Self::ServiceLogs(_)
567            | Self::BundleList(_)
568            | Self::AgentProviders(_)
569            | Self::TerminalSessions(_)
570            | Self::TerminalAttach(_)
571            | Self::TerminalResize(_)
572            | Self::TerminalDetach(_)
573            // The whole inspection surface. Nothing below changes anything on
574            // the machine or on GitHub, which is what makes a retry harmless
575            // and a degraded session still useful.
576            | Self::Repositories(_)
577            | Self::GithubRuns(_)
578            | Self::GithubRunJobs(_)
579            | Self::GithubPulls(_)
580            | Self::GithubPull(_)
581            | Self::AgentUsage(_)
582            | Self::Errors(_)
583            | Self::Timeline(_) => false,
584        }
585    }
586
587    /// The capability a daemon must advertise for this frame to be routable, or
588    /// `None` for the control frames, which are part of every session.
589    pub fn required_capability(&self) -> Option<&'static str> {
590        use super::version::capabilities as capability;
591        match self {
592            Self::SessionWelcome(_) | Self::SessionRevoke(_) => None,
593            Self::DeviceSnapshot(_) => Some(capability::DEVICE_SNAPSHOT),
594            Self::ServiceList(_) => Some(capability::SERVICE_LIST),
595            Self::ServiceAction(_) => Some(capability::SERVICE_ACTION),
596            Self::ServiceLogs(_) => Some(capability::SERVICE_LOGS),
597            Self::BundleList(_) => Some(capability::BUNDLE_LIST),
598            Self::AgentProviders(_) => Some(capability::AGENT_PROVIDERS),
599            Self::AgentTurnStart(_) | Self::AgentTurnCancel(_) => Some(capability::AGENT_TURNS),
600            Self::AgentApprovalResolve(_) => Some(capability::AGENT_APPROVALS),
601            Self::TerminalSpawn(_) => Some(capability::TERMINAL_SPAWN),
602            Self::TerminalShell(_) => Some(capability::TERMINAL_SHELL),
603            Self::TerminalSessions(_) => Some(capability::TERMINAL_SESSIONS),
604            Self::TerminalAttach(_)
605            | Self::TerminalInput(_)
606            | Self::TerminalResize(_)
607            | Self::TerminalDetach(_) => Some(capability::TERMINAL_ATTACH),
608            Self::TerminalKill(_) => Some(capability::TERMINAL_KILL),
609            Self::Linear(_) => Some(capability::LINEAR),
610            Self::Repositories(_) => Some(capability::REPOSITORIES),
611            Self::GithubRuns(_) | Self::GithubRunJobs(_) => Some(capability::GITHUB_ACTIONS),
612            Self::GithubPulls(_) | Self::GithubPull(_) => Some(capability::GITHUB_PULLS),
613            Self::AgentUsage(_) => Some(capability::AGENT_USAGE),
614            Self::Errors(_) => Some(capability::DEVICE_ERRORS),
615            Self::Timeline(_) => Some(capability::DEVICE_TIMELINE),
616        }
617    }
618
619    /// Parse one frame's `type` and `payload`.
620    ///
621    /// Two failures, kept apart on purpose: a name outside [`Self::KINDS`] is
622    /// [`ErrorCode::UnknownCommand`], and a known name whose body does not fit
623    /// is [`ErrorCode::MalformedFrame`]. A phone told "unknown command" should
624    /// stop sending it; a phone told "malformed" has a bug to fix.
625    pub fn parse(kind: &str, payload: serde_json::Value) -> Result<Self, ProtocolError> {
626        if !Self::KINDS.contains(&kind) {
627            return Err(
628                ProtocolError::new(ErrorCode::UnknownCommand, "Unknown remote command.")
629                    .with_detail(kind),
630            );
631        }
632        let tagged = serde_json::json!({ "type": kind, "payload": payload });
633        serde_json::from_value(tagged).map_err(|error| {
634            ProtocolError::new(ErrorCode::MalformedFrame, "Command payload is not valid.")
635                .with_detail(error.to_string())
636        })
637    }
638
639    /// The `payload` half of the envelope.
640    pub fn payload(&self) -> serde_json::Value {
641        let tagged = serde_json::to_value(self).expect("a command always serialises");
642        tagged
643            .get("payload")
644            .cloned()
645            .unwrap_or(serde_json::Value::Null)
646    }
647}