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}