scv-protocol 0.4.10

Versioned JSON protocol types for SCV clients and servers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
//! The agent workbench: what SCV knows about delegated agents, background
//! jobs, and their sessions across the whole instance, as a dashboard reads
//! it ([`WorkbenchSnapshot`]), the activity a delegated run records for
//! its live view ([`RunEvent`], [`RunLogPage`]), and the screen of a
//! conversation the owner took over in its agent's own terminal
//! ([`AttachPage`]).
//!
//! Every type here is additive to protocol version 3: a daemon sends them
//! only in answer to the `workbench*`, `run_log`, `job_result`,
//! `worktree*`, and `attach*` `daemon.control` actions, which only newer
//! clients send.

use serde::{Deserialize, Serialize};

use crate::review::JobOutcome;

/// What a delegated run, a background job, or a session is doing, as far as
/// SCV can tell. A state a client does not know parses as `unknown`, which
/// also means SCV itself cannot tell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentState {
    /// Launching: starting the process, its handshake, or its session.
    Starting,
    /// A turn runs and the agent reports activity.
    Working,
    /// The agent waits for a person: an approval request it relayed, or the
    /// session's own approval request, is unanswered.
    AwaitingInput,
    /// A live agent (an ACP server or nested SCV) that waits between turns
    /// for its next prompt; or a session with no turn running.
    Idle,
    /// A prompt that waits for its conversation's running turn.
    Queued,
    /// The turn finished and the agent completed its task.
    Completed,
    /// The run or job failed.
    Failed,
    /// The agent's model refused the request.
    Declined,
    /// The run reached its time limit.
    Timeout,
    /// Stopped on request.
    Cancelled,
    /// Its SCV process or session stopped before it finished: a crash, a
    /// restart, or the session's client going away.
    Interrupted,
    /// SCV cannot tell: a turn has reported nothing for a long while, or the
    /// process that owns it cannot be observed. Also any state this client
    /// does not know.
    #[serde(other)]
    Unknown,
}

impl AgentState {
    /// The state as SCV writes it, such as `awaiting_input`.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Starting => "starting",
            Self::Working => "working",
            Self::AwaitingInput => "awaiting_input",
            Self::Idle => "idle",
            Self::Queued => "queued",
            Self::Completed => "completed",
            Self::Failed => "failed",
            Self::Declined => "declined",
            Self::Timeout => "timeout",
            Self::Cancelled => "cancelled",
            Self::Interrupted => "interrupted",
            Self::Unknown => "unknown",
        }
    }

    /// Whether nothing more happens in this state by itself: the run or job
    /// has ended one way or another.
    pub fn is_final(self) -> bool {
        matches!(
            self,
            Self::Completed
                | Self::Failed
                | Self::Declined
                | Self::Timeout
                | Self::Cancelled
                | Self::Interrupted
        )
    }

    /// Whether a person is needed to go on.
    pub fn needs_attention(self) -> bool {
        matches!(self, Self::AwaitingInput)
    }
}

impl std::fmt::Display for AgentState {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// Who started a background job.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobOrigin {
    /// The session's model, through an `agent` call.
    #[default]
    Model,
    /// The owner, from the workbench (a dashboard or `scv jobs`).
    Owner,
    /// An origin this client does not know.
    #[serde(other)]
    Unknown,
}

/// A git worktree SCV created for one job, so its agent works apart from
/// the repository's own checkout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeRef {
    /// The worktree's directory, under `$SCV_HOME/worktrees`.
    pub path: String,
    /// The branch SCV created for it, such as `scv/codex-3f9a2c`.
    pub branch: String,
    /// The repository it was created from.
    pub repo: String,
}

/// A worktree SCV created, as `scv agents worktrees` lists it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeInfo {
    /// Where it is, its branch, and the repository it belongs to.
    #[serde(flatten)]
    pub worktree: WorktreeRef,
    /// Whether it has uncommitted changes; absent when git could not tell.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dirty: Option<bool>,
    /// The jobs recorded as working in it, as `<session>/<job>`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub jobs: Vec<String>,
}

/// One SCV session the workbench knows: a live daemon session, the
/// workbench's own host for jobs the owner starts, or one that ended.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbenchSession {
    /// The session ID.
    pub id: String,
    /// Who it serves, such as `TUI`, `WeChat`, `scv exec`, or `workbench`.
    pub label: String,
    /// Its workspace directory.
    pub workspace: String,
    /// Whether it still runs in a process of this instance.
    pub live: bool,
    /// What it is doing, when it is live.
    pub state: AgentState,
    /// SCV's words for that state, such as which approval it waits for.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// One background job, from the instance's job ledger and, while it runs,
/// its live state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbenchJob {
    /// The session that owns it.
    pub session: String,
    /// Its handle in that session, such as `job-3`.
    pub job: String,
    /// The agent that runs it, such as `codex`.
    pub agent: String,
    /// The first line of its prompt, shortened.
    pub task: String,
    /// The session's workspace.
    pub workspace: String,
    /// Where its agent runs: a directory in the workspace, or its worktree.
    pub cwd: String,
    /// What it is doing.
    pub state: AgentState,
    /// SCV's words for that state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// Who started it.
    pub started_by: JobOrigin,
    /// The conversation it runs a turn of, such as `codex-2`, once known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation: Option<String>,
    /// The delegated run serving it now, or that served it last.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run: Option<String>,
    /// The worktree SCV created for it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worktree: Option<WorktreeRef>,
    /// A reviewed job (`review`), whose journal holds its rounds.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub reviewed: bool,
    /// The owner can resume it from the workbench.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub resumable: bool,
    /// The job it was resumed as, `<session>/<job>`, once it was.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resumed_as: Option<String>,
    /// Whether the owner can take `conversation` over in its agent's own
    /// terminal now, and why not. Absent without a conversation the owner
    /// can reach, and from daemons that predate it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attach: Option<AttachOffer>,
    /// When it was started.
    pub created_unix_seconds: u64,
    /// When its record last changed.
    pub updated_unix_seconds: u64,
}

/// One delegated run (an agent process) and what it is doing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbenchRun {
    /// Its delegation handle, such as `codex-3f9a2c`; its run log's name.
    pub handle: String,
    /// The agent, such as `codex`.
    pub agent: String,
    /// The session that started it.
    pub session: String,
    /// How SCV reaches it: `cli` (one process per turn), `acp`, or `scv`.
    pub transport: String,
    /// The conversation it serves, such as `codex-2`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation: Option<String>,
    /// The turn of that conversation it serves, or served last.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub turn: Option<u32>,
    /// The background job it serves, when it serves one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job: Option<String>,
    /// Every background job its turns served, oldest first: a live
    /// conversation's run serves several.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub jobs: Vec<String>,
    /// Its working directory.
    pub cwd: String,
    /// What it is doing.
    pub state: AgentState,
    /// SCV's words for that state, such as the approval it waits for or
    /// how long it has been quiet.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// When it started.
    pub started_unix_seconds: u64,
    /// When it last reported anything.
    pub last_activity_unix_seconds: u64,
    /// Its process still runs.
    pub live: bool,
    /// A running turn of it can take a further prompt (ACP steering).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub steerable: bool,
    /// Whether the owner can take `conversation` over in its agent's own
    /// terminal now, and why not; absent as on [`WorkbenchJob::attach`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attach: Option<AttachOffer>,
}

/// Where a conversation stands for the owner's takeover of it in its
/// agent's own terminal (`attach`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttachState {
    /// It is idle: `attach` takes it over.
    Ready,
    /// It is taken over: `attach` attaches to its terminal, taking the
    /// hold from any other client.
    TakenOver,
    /// A turn runs, or turns wait: `attach` is refused. With `interrupt`,
    /// when [`AttachOffer::interrupt`] says so, it stops the running turn
    /// first.
    Busy,
    /// Its agent's terminal is never offered (only an agent whose terminal
    /// asks no approval is).
    Ineligible,
    /// It has no session of the agent's own to open yet: its first turn has
    /// not ended.
    NoSession,
    /// A reviewed job holds it.
    Reviewed,
    /// As many takeovers as may run at once already run.
    Full,
    /// A state this client does not know.
    #[serde(other)]
    Unknown,
}

impl AttachState {
    /// The state as SCV writes it, such as `taken_over`.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Ready => "ready",
            Self::TakenOver => "taken_over",
            Self::Busy => "busy",
            Self::Ineligible => "ineligible",
            Self::NoSession => "no_session",
            Self::Reviewed => "reviewed",
            Self::Full => "full",
            Self::Unknown => "unknown",
        }
    }
}

/// Whether the owner can take a conversation over in its agent's own
/// terminal now, as `attach` would answer it at this moment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachOffer {
    /// Where it stands.
    pub state: AttachState,
    /// SCV's words for why `attach` is refused, as it would refuse it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// `attach` with `interrupt` would stop the job running its turn and
    /// then take it over.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub interrupt: bool,
    /// The background job running its turn, such as `job-3`, which
    /// `interrupt` stops.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job: Option<String>,
}

impl AttachOffer {
    /// Whether `attach` (without `interrupt`) goes ahead.
    pub fn allowed(&self) -> bool {
        matches!(self.state, AttachState::Ready | AttachState::TakenOver)
    }
}

/// An approval request a background job relayed while the owner watches the
/// workbench, waiting for the owner's answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbenchApproval {
    /// Answer with `workbench_approve` naming this ID.
    pub id: String,
    /// The session whose job asks.
    pub session: String,
    /// The job that asks.
    pub job: String,
    /// The tool the request is for, as the nested agent named it.
    pub name: String,
    /// Its risk, such as `process`.
    pub risk: String,
    /// The working directory it concerns.
    pub cwd: String,
    /// What it will do.
    pub summary: String,
    /// When it was asked.
    pub since_unix_seconds: u64,
    /// When it gets the answer the session gives unattended (a denial).
    pub expires_unix_seconds: u64,
}

/// A session whose queued prompts SCV kept when its process stopped (a
/// restart or crash), which a new session can restore.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestorableSession {
    /// The session that stopped.
    pub id: String,
    /// Who it served.
    pub label: String,
    /// Its workspace.
    pub workspace: String,
    /// Prompts that never started.
    pub queued: u32,
    /// The start of the prompt whose turn ran when it stopped, which is
    /// never sent again by itself.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub interrupted: Option<String>,
    /// When it was last saved.
    pub saved_unix_seconds: u64,
}

/// Everything the workbench shows, at one moment.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkbenchSnapshot {
    /// When it was taken.
    pub generated_unix_seconds: u64,
    /// Sessions with jobs, runs, or live state.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sessions: Vec<WorkbenchSession>,
    /// Background jobs, newest first.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub jobs: Vec<WorkbenchJob>,
    /// Delegated runs, newest first.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub runs: Vec<WorkbenchRun>,
    /// Approval requests waiting for the owner.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub approvals: Vec<WorkbenchApproval>,
    /// Sessions whose queued prompts can be restored.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub restorable: Vec<RestorableSession>,
}

/// What one line of a run's activity log records.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunEventKind {
    /// The run began: its agent, transport, and working directory.
    RunStarted,
    /// A turn began with a prompt.
    TurnStarted,
    /// A line of the agent's own message.
    Text,
    /// A tool call or command the agent started, as a short redacted line.
    Tool,
    /// A tool call that failed.
    ToolFailed,
    /// The agent's current plan step.
    Plan,
    /// The agent asked for approval.
    ApprovalRequested,
    /// That request was answered.
    ApprovalResolved,
    /// A prompt was added to the running turn.
    Steered,
    /// A turn ended.
    TurnEnded,
    /// The run's process ended.
    RunEnded,
    /// SCV's own note, such as why the run stopped.
    Note,
    /// The log reached its size limit; only turns, approvals, and the end
    /// are recorded after this.
    Truncated,
    /// A kind this client does not know.
    #[serde(other)]
    Unknown,
}

/// One entry of a run's activity log. Text is display-only: agent messages
/// and short tool lines with credential-like values redacted, never tool
/// output.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunEvent {
    /// Its position in the log, from 1.
    pub seq: u64,
    /// When it happened, in Unix milliseconds.
    pub ts_ms: u64,
    /// What it records.
    pub kind: RunEventKind,
    /// Its text: the message line, tool line, prompt, summary, or note.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub text: String,
    /// The conversation turn it belongs to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub turn: Option<u32>,
    /// How a turn or run ended, or an approval was answered (`approved`,
    /// `denied`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// The background job a turn serves.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job: Option<String>,
    /// An approval request's risk.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub risk: Option<String>,
    /// Reserved for who sent a turn's prompt or answered an approval:
    /// `model`, `owner`, or `policy`. Logs omit it until a writer records
    /// that actor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub by: Option<String>,
}

/// The most bytes of a job's reply, and of its error, a [`JobResult`]
/// carries; a longer one is cut at a character boundary and flagged
/// `truncated`. The ledger itself keeps up to 8 KiB of each.
pub const MAX_JOB_RESULT_BYTES: usize = 4 * 1024;

/// A background job's result, for a `job_result` request: where it stands
/// and, once it has ended, its agent's reply or error, bounded by
/// [`MAX_JOB_RESULT_BYTES`]. The reply and error are untrusted
/// delegated-agent text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobResult {
    /// The session that owns it.
    pub session: String,
    /// Its handle in that session, such as `job-3`.
    pub job: String,
    /// The agent that runs it, such as `codex`.
    pub agent: String,
    /// What it is doing, as the workbench lists it.
    pub state: AgentState,
    /// SCV's words for that state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// The agent's reply, once the job has ended with one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reply: Option<String>,
    /// Why it failed, once it has failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// The reply or the error was cut to [`MAX_JOB_RESULT_BYTES`].
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub truncated: bool,
    /// A reviewed job's outcome, while the session that ran it still holds
    /// the job.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome: Option<JobOutcome>,
    /// When it ended.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub finished_unix_seconds: Option<u64>,
}

/// Part of a run's activity log, after a position.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLogPage {
    /// The run, by delegation handle.
    pub handle: String,
    /// Its events after the requested position, oldest first.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub events: Vec<RunEvent>,
    /// Pass as `after` to read on: the last position returned, or the
    /// requested one when nothing was new.
    pub next: u64,
    /// The run has ended, so nothing more will be logged.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub ended: bool,
    /// More events wait beyond this page.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub more: bool,
}

/// How `workbench_send` hands the owner's prompt to a conversation that is
/// running a turn.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SendMode {
    /// Steer the running turn when its agent can take the prompt, else
    /// queue it.
    #[default]
    Auto,
    /// Queue it behind the running turn.
    Queue,
    /// Steer the running turn, or fail when it cannot be steered.
    Steer,
}

/// A color of a cell of a taken-over terminal: one of the terminal's 256
/// indexed colors, or a direct RGB color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AttachColor {
    /// An indexed color: 0-15 are the terminal's own, 16-255 the xterm cube.
    Index(u8),
    /// A direct color, as `[red, green, blue]`.
    Rgb([u8; 3]),
}

/// A run of cells of one row of a taken-over terminal, all in one style.
/// Its text is display-only: printable characters, never an escape
/// sequence, with values that look like credentials masked.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachSpan {
    /// The characters, one per cell (two for a wide one).
    pub text: String,
    /// Foreground color; absent is the terminal's default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fg: Option<AttachColor>,
    /// Background color; absent is the terminal's default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bg: Option<AttachColor>,
    /// Bold.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub bold: bool,
    /// Italic.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub italic: bool,
    /// Underlined.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub underline: bool,
    /// Foreground and background swapped.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub inverse: bool,
}

/// One row of a taken-over terminal's screen.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachRow {
    /// The row, from 0 at the top.
    pub row: u16,
    /// Its cells, left to right; cells past the last span are blank.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub spans: Vec<AttachSpan>,
}

/// The screen of a conversation the owner took over in its agent's own
/// terminal, as `attach` and `attach_io` return it: the rows that changed
/// since the client's `after`, never the terminal's raw output.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttachPage {
    /// This client's hold on the terminal: `attach_io` and `attach_detach`
    /// name it. Another `attach` of the same conversation replaces it.
    pub id: String,
    /// The session that holds the conversation.
    pub session: String,
    /// The conversation taken over, such as `codex-2`.
    pub conversation: String,
    /// The agent, such as `codex`.
    pub agent: String,
    /// The takeover's delegated run, whose activity log records it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run: Option<String>,
    /// Pass as `after` to read only what changes next.
    pub generation: u64,
    /// The screen's width, in cells.
    pub cols: u16,
    /// The screen's height, in rows.
    pub rows: u16,
    /// Every row whose content changed since `after`; all of them when
    /// `full`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub lines: Vec<AttachRow>,
    /// `lines` holds the whole screen: the client starts over from blank.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub full: bool,
    /// The cursor, as `[row, col]`; absent while the agent hides it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<[u16; 2]>,
    /// The agent asked for application cursor keys (`ESC O A` for Up).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub application_cursor: bool,
    /// The agent asked for bracketed paste.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub bracketed_paste: bool,
    /// The agent's terminal closed: its process exited or was stopped, so
    /// the conversation goes back to SCV's turns.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub ended: bool,
    /// How it ended, such as `exit 0` or `stopped by the owner`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub exit: Option<String>,
    /// SCV's sentence about input it could not deliver, such as an agent
    /// that stopped reading its terminal.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

/// What `session.start` restored from a session that stopped.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestoredQueue {
    /// The session whose queue was restored.
    pub from: String,
    /// Queued prompts restored; the new session's queue starts paused.
    pub queued: u32,
    /// The start of the prompt that was running when the old session
    /// stopped. It is not sent again: its outcome is unknown.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub interrupted: Option<String>,
    /// Queued prompts that could not be restored, because an attachment
    /// was gone or the prompt was too long. Older servers omit it, and it
    /// is left off the wire when none were.
    #[serde(default, skip_serializing_if = "zero")]
    pub dropped: u32,
}

#[allow(
    clippy::trivially_copy_pass_by_ref,
    reason = "serde skip_serializing_if passes the field by reference"
)]
fn zero(value: &u32) -> bool {
    *value == 0
}

#[cfg(test)]
mod tests;