Skip to main content

scv_protocol/
workbench.rs

1//! The agent workbench: what SCV knows about delegated agents, background
2//! jobs, and their sessions across the whole instance, as a dashboard reads
3//! it ([`WorkbenchSnapshot`]), the activity a delegated run records for
4//! its live view ([`RunEvent`], [`RunLogPage`]), and the screen of a
5//! conversation the owner took over in its agent's own terminal
6//! ([`AttachPage`]).
7//!
8//! Every type here is additive to protocol version 3: a daemon sends them
9//! only in answer to the `workbench*`, `run_log`, `job_result`,
10//! `worktree*`, and `attach*` `daemon.control` actions, which only newer
11//! clients send.
12
13use serde::{Deserialize, Serialize};
14
15use crate::review::JobOutcome;
16
17/// What a delegated run, a background job, or a session is doing, as far as
18/// SCV can tell. A state a client does not know parses as `unknown`, which
19/// also means SCV itself cannot tell.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AgentState {
23    /// Launching: starting the process, its handshake, or its session.
24    Starting,
25    /// A turn runs and the agent reports activity.
26    Working,
27    /// The agent waits for a person: an approval request it relayed, or the
28    /// session's own approval request, is unanswered.
29    AwaitingInput,
30    /// A live agent (an ACP server or nested SCV) that waits between turns
31    /// for its next prompt; or a session with no turn running.
32    Idle,
33    /// A prompt that waits for its conversation's running turn.
34    Queued,
35    /// The turn finished and the agent completed its task.
36    Completed,
37    /// The run or job failed.
38    Failed,
39    /// The agent's model refused the request.
40    Declined,
41    /// The run reached its time limit.
42    Timeout,
43    /// Stopped on request.
44    Cancelled,
45    /// Its SCV process or session stopped before it finished: a crash, a
46    /// restart, or the session's client going away.
47    Interrupted,
48    /// SCV cannot tell: a turn has reported nothing for a long while, or the
49    /// process that owns it cannot be observed. Also any state this client
50    /// does not know.
51    #[serde(other)]
52    Unknown,
53}
54
55impl AgentState {
56    /// The state as SCV writes it, such as `awaiting_input`.
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Starting => "starting",
60            Self::Working => "working",
61            Self::AwaitingInput => "awaiting_input",
62            Self::Idle => "idle",
63            Self::Queued => "queued",
64            Self::Completed => "completed",
65            Self::Failed => "failed",
66            Self::Declined => "declined",
67            Self::Timeout => "timeout",
68            Self::Cancelled => "cancelled",
69            Self::Interrupted => "interrupted",
70            Self::Unknown => "unknown",
71        }
72    }
73
74    /// Whether nothing more happens in this state by itself: the run or job
75    /// has ended one way or another.
76    pub fn is_final(self) -> bool {
77        matches!(
78            self,
79            Self::Completed
80                | Self::Failed
81                | Self::Declined
82                | Self::Timeout
83                | Self::Cancelled
84                | Self::Interrupted
85        )
86    }
87
88    /// Whether a person is needed to go on.
89    pub fn needs_attention(self) -> bool {
90        matches!(self, Self::AwaitingInput)
91    }
92}
93
94impl std::fmt::Display for AgentState {
95    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        formatter.write_str(self.as_str())
97    }
98}
99
100/// Who started a background job.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum JobOrigin {
104    /// The session's model, through an `agent` call.
105    #[default]
106    Model,
107    /// The owner, from the workbench (a dashboard or `scv jobs`).
108    Owner,
109    /// An origin this client does not know.
110    #[serde(other)]
111    Unknown,
112}
113
114/// A git worktree SCV created for one job, so its agent works apart from
115/// the repository's own checkout.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct WorktreeRef {
118    /// The worktree's directory, under `$SCV_HOME/worktrees`.
119    pub path: String,
120    /// The branch SCV created for it, such as `scv/codex-3f9a2c`.
121    pub branch: String,
122    /// The repository it was created from.
123    pub repo: String,
124}
125
126/// A worktree SCV created, as `scv agents worktrees` lists it.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct WorktreeInfo {
129    /// Where it is, its branch, and the repository it belongs to.
130    #[serde(flatten)]
131    pub worktree: WorktreeRef,
132    /// Whether it has uncommitted changes; absent when git could not tell.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub dirty: Option<bool>,
135    /// The jobs recorded as working in it, as `<session>/<job>`.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub jobs: Vec<String>,
138}
139
140/// One SCV session the workbench knows: a live daemon session, the
141/// workbench's own host for jobs the owner starts, or one that ended.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct WorkbenchSession {
144    /// The session ID.
145    pub id: String,
146    /// Who it serves, such as `TUI`, `WeChat`, `scv exec`, or `workbench`.
147    pub label: String,
148    /// Its workspace directory.
149    pub workspace: String,
150    /// Whether it still runs in a process of this instance.
151    pub live: bool,
152    /// What it is doing, when it is live.
153    pub state: AgentState,
154    /// SCV's words for that state, such as which approval it waits for.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub detail: Option<String>,
157}
158
159/// One background job, from the instance's job ledger and, while it runs,
160/// its live state.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct WorkbenchJob {
163    /// The session that owns it.
164    pub session: String,
165    /// Its handle in that session, such as `job-3`.
166    pub job: String,
167    /// The agent that runs it, such as `codex`.
168    pub agent: String,
169    /// The first line of its prompt, shortened.
170    pub task: String,
171    /// The session's workspace.
172    pub workspace: String,
173    /// Where its agent runs: a directory in the workspace, or its worktree.
174    pub cwd: String,
175    /// What it is doing.
176    pub state: AgentState,
177    /// SCV's words for that state.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub detail: Option<String>,
180    /// Who started it.
181    pub started_by: JobOrigin,
182    /// The conversation it runs a turn of, such as `codex-2`, once known.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub conversation: Option<String>,
185    /// The delegated run serving it now, or that served it last.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub run: Option<String>,
188    /// The worktree SCV created for it.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub worktree: Option<WorktreeRef>,
191    /// A reviewed job (`review`), whose journal holds its rounds.
192    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
193    pub reviewed: bool,
194    /// The owner can resume it from the workbench.
195    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
196    pub resumable: bool,
197    /// The job it was resumed as, `<session>/<job>`, once it was.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub resumed_as: Option<String>,
200    /// When it was started.
201    pub created_unix_seconds: u64,
202    /// When its record last changed.
203    pub updated_unix_seconds: u64,
204}
205
206/// One delegated run (an agent process) and what it is doing.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct WorkbenchRun {
209    /// Its delegation handle, such as `codex-3f9a2c`; its run log's name.
210    pub handle: String,
211    /// The agent, such as `codex`.
212    pub agent: String,
213    /// The session that started it.
214    pub session: String,
215    /// How SCV reaches it: `cli` (one process per turn), `acp`, or `scv`.
216    pub transport: String,
217    /// The conversation it serves, such as `codex-2`.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub conversation: Option<String>,
220    /// The turn of that conversation it serves, or served last.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub turn: Option<u32>,
223    /// The background job it serves, when it serves one.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub job: Option<String>,
226    /// Every background job its turns served, oldest first: a live
227    /// conversation's run serves several.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub jobs: Vec<String>,
230    /// Its working directory.
231    pub cwd: String,
232    /// What it is doing.
233    pub state: AgentState,
234    /// SCV's words for that state, such as the approval it waits for or
235    /// how long it has been quiet.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub detail: Option<String>,
238    /// When it started.
239    pub started_unix_seconds: u64,
240    /// When it last reported anything.
241    pub last_activity_unix_seconds: u64,
242    /// Its process still runs.
243    pub live: bool,
244    /// A running turn of it can take a further prompt (ACP steering).
245    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
246    pub steerable: bool,
247}
248
249/// An approval request a background job relayed while the owner watches the
250/// workbench, waiting for the owner's answer.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct WorkbenchApproval {
253    /// Answer with `workbench_approve` naming this ID.
254    pub id: String,
255    /// The session whose job asks.
256    pub session: String,
257    /// The job that asks.
258    pub job: String,
259    /// The tool the request is for, as the nested agent named it.
260    pub name: String,
261    /// Its risk, such as `process`.
262    pub risk: String,
263    /// The working directory it concerns.
264    pub cwd: String,
265    /// What it will do.
266    pub summary: String,
267    /// When it was asked.
268    pub since_unix_seconds: u64,
269    /// When it gets the answer the session gives unattended (a denial).
270    pub expires_unix_seconds: u64,
271}
272
273/// A session whose queued prompts SCV kept when its process stopped (a
274/// restart or crash), which a new session can restore.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct RestorableSession {
277    /// The session that stopped.
278    pub id: String,
279    /// Who it served.
280    pub label: String,
281    /// Its workspace.
282    pub workspace: String,
283    /// Prompts that never started.
284    pub queued: u32,
285    /// The start of the prompt whose turn ran when it stopped, which is
286    /// never sent again by itself.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub interrupted: Option<String>,
289    /// When it was last saved.
290    pub saved_unix_seconds: u64,
291}
292
293/// Everything the workbench shows, at one moment.
294#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
295pub struct WorkbenchSnapshot {
296    /// When it was taken.
297    pub generated_unix_seconds: u64,
298    /// Sessions with jobs, runs, or live state.
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub sessions: Vec<WorkbenchSession>,
301    /// Background jobs, newest first.
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub jobs: Vec<WorkbenchJob>,
304    /// Delegated runs, newest first.
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub runs: Vec<WorkbenchRun>,
307    /// Approval requests waiting for the owner.
308    #[serde(default, skip_serializing_if = "Vec::is_empty")]
309    pub approvals: Vec<WorkbenchApproval>,
310    /// Sessions whose queued prompts can be restored.
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub restorable: Vec<RestorableSession>,
313}
314
315/// What one line of a run's activity log records.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum RunEventKind {
319    /// The run began: its agent, transport, and working directory.
320    RunStarted,
321    /// A turn began with a prompt.
322    TurnStarted,
323    /// A line of the agent's own message.
324    Text,
325    /// A tool call or command the agent started, as a short redacted line.
326    Tool,
327    /// A tool call that failed.
328    ToolFailed,
329    /// The agent's current plan step.
330    Plan,
331    /// The agent asked for approval.
332    ApprovalRequested,
333    /// That request was answered.
334    ApprovalResolved,
335    /// A prompt was added to the running turn.
336    Steered,
337    /// A turn ended.
338    TurnEnded,
339    /// The run's process ended.
340    RunEnded,
341    /// SCV's own note, such as why the run stopped.
342    Note,
343    /// The log reached its size limit; only turns, approvals, and the end
344    /// are recorded after this.
345    Truncated,
346    /// A kind this client does not know.
347    #[serde(other)]
348    Unknown,
349}
350
351/// One entry of a run's activity log. Text is display-only: agent messages
352/// and short tool lines with credential-like values redacted, never tool
353/// output.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct RunEvent {
356    /// Its position in the log, from 1.
357    pub seq: u64,
358    /// When it happened, in Unix milliseconds.
359    pub ts_ms: u64,
360    /// What it records.
361    pub kind: RunEventKind,
362    /// Its text: the message line, tool line, prompt, summary, or note.
363    #[serde(default, skip_serializing_if = "String::is_empty")]
364    pub text: String,
365    /// The conversation turn it belongs to.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub turn: Option<u32>,
368    /// How a turn or run ended, or an approval was answered (`approved`,
369    /// `denied`).
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub status: Option<String>,
372    /// The background job a turn serves.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub job: Option<String>,
375    /// An approval request's risk.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub risk: Option<String>,
378    /// Reserved for who sent a turn's prompt or answered an approval:
379    /// `model`, `owner`, or `policy`. Logs omit it until a writer records
380    /// that actor.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub by: Option<String>,
383}
384
385/// The most bytes of a job's reply, and of its error, a [`JobResult`]
386/// carries; a longer one is cut at a character boundary and flagged
387/// `truncated`. The ledger itself keeps up to 8 KiB of each.
388pub const MAX_JOB_RESULT_BYTES: usize = 4 * 1024;
389
390/// A background job's result, for a `job_result` request: where it stands
391/// and, once it has ended, its agent's reply or error, bounded by
392/// [`MAX_JOB_RESULT_BYTES`]. The reply and error are untrusted
393/// delegated-agent text.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct JobResult {
396    /// The session that owns it.
397    pub session: String,
398    /// Its handle in that session, such as `job-3`.
399    pub job: String,
400    /// The agent that runs it, such as `codex`.
401    pub agent: String,
402    /// What it is doing, as the workbench lists it.
403    pub state: AgentState,
404    /// SCV's words for that state.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub detail: Option<String>,
407    /// The agent's reply, once the job has ended with one.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub reply: Option<String>,
410    /// Why it failed, once it has failed.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub error: Option<String>,
413    /// The reply or the error was cut to [`MAX_JOB_RESULT_BYTES`].
414    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
415    pub truncated: bool,
416    /// A reviewed job's outcome, while the session that ran it still holds
417    /// the job.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub outcome: Option<JobOutcome>,
420    /// When it ended.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub finished_unix_seconds: Option<u64>,
423}
424
425/// Part of a run's activity log, after a position.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct RunLogPage {
428    /// The run, by delegation handle.
429    pub handle: String,
430    /// Its events after the requested position, oldest first.
431    #[serde(default, skip_serializing_if = "Vec::is_empty")]
432    pub events: Vec<RunEvent>,
433    /// Pass as `after` to read on: the last position returned, or the
434    /// requested one when nothing was new.
435    pub next: u64,
436    /// The run has ended, so nothing more will be logged.
437    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438    pub ended: bool,
439    /// More events wait beyond this page.
440    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
441    pub more: bool,
442}
443
444/// How `workbench_send` hands the owner's prompt to a conversation that is
445/// running a turn.
446#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum SendMode {
449    /// Steer the running turn when its agent can take the prompt, else
450    /// queue it.
451    #[default]
452    Auto,
453    /// Queue it behind the running turn.
454    Queue,
455    /// Steer the running turn, or fail when it cannot be steered.
456    Steer,
457}
458
459/// A color of a cell of a taken-over terminal: one of the terminal's 256
460/// indexed colors, or a direct RGB color.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
462#[serde(untagged)]
463pub enum AttachColor {
464    /// An indexed color: 0-15 are the terminal's own, 16-255 the xterm cube.
465    Index(u8),
466    /// A direct color, as `[red, green, blue]`.
467    Rgb([u8; 3]),
468}
469
470/// A run of cells of one row of a taken-over terminal, all in one style.
471/// Its text is display-only: printable characters, never an escape
472/// sequence, with values that look like credentials masked.
473#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
474pub struct AttachSpan {
475    /// The characters, one per cell (two for a wide one).
476    pub text: String,
477    /// Foreground color; absent is the terminal's default.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub fg: Option<AttachColor>,
480    /// Background color; absent is the terminal's default.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub bg: Option<AttachColor>,
483    /// Bold.
484    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
485    pub bold: bool,
486    /// Italic.
487    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
488    pub italic: bool,
489    /// Underlined.
490    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
491    pub underline: bool,
492    /// Foreground and background swapped.
493    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
494    pub inverse: bool,
495}
496
497/// One row of a taken-over terminal's screen.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct AttachRow {
500    /// The row, from 0 at the top.
501    pub row: u16,
502    /// Its cells, left to right; cells past the last span are blank.
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    pub spans: Vec<AttachSpan>,
505}
506
507/// The screen of a conversation the owner took over in its agent's own
508/// terminal, as `attach` and `attach_io` return it: the rows that changed
509/// since the client's `after`, never the terminal's raw output.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct AttachPage {
512    /// This client's hold on the terminal: `attach_io` and `attach_detach`
513    /// name it. Another `attach` of the same conversation replaces it.
514    pub id: String,
515    /// The session that holds the conversation.
516    pub session: String,
517    /// The conversation taken over, such as `codex-2`.
518    pub conversation: String,
519    /// The agent, such as `codex`.
520    pub agent: String,
521    /// The takeover's delegated run, whose activity log records it.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub run: Option<String>,
524    /// Pass as `after` to read only what changes next.
525    pub generation: u64,
526    /// The screen's width, in cells.
527    pub cols: u16,
528    /// The screen's height, in rows.
529    pub rows: u16,
530    /// Every row whose content changed since `after`; all of them when
531    /// `full`.
532    #[serde(default, skip_serializing_if = "Vec::is_empty")]
533    pub lines: Vec<AttachRow>,
534    /// `lines` holds the whole screen: the client starts over from blank.
535    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
536    pub full: bool,
537    /// The cursor, as `[row, col]`; absent while the agent hides it.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub cursor: Option<[u16; 2]>,
540    /// The agent asked for application cursor keys (`ESC O A` for Up).
541    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
542    pub application_cursor: bool,
543    /// The agent asked for bracketed paste.
544    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
545    pub bracketed_paste: bool,
546    /// The agent's terminal closed: its process exited or was stopped, so
547    /// the conversation goes back to SCV's turns.
548    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
549    pub ended: bool,
550    /// How it ended, such as `exit 0` or `stopped by the owner`.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub exit: Option<String>,
553    /// SCV's sentence about input it could not deliver, such as an agent
554    /// that stopped reading its terminal.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub note: Option<String>,
557}
558
559/// What `session.start` restored from a session that stopped.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct RestoredQueue {
562    /// The session whose queue was restored.
563    pub from: String,
564    /// Queued prompts restored; the new session's queue starts paused.
565    pub queued: u32,
566    /// The start of the prompt that was running when the old session
567    /// stopped. It is not sent again: its outcome is unknown.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub interrupted: Option<String>,
570    /// Queued prompts that could not be restored, because an attachment
571    /// was gone or the prompt was too long. Older servers omit it, and it
572    /// is left off the wire when none were.
573    #[serde(default, skip_serializing_if = "zero")]
574    pub dropped: u32,
575}
576
577#[allow(
578    clippy::trivially_copy_pass_by_ref,
579    reason = "serde skip_serializing_if passes the field by reference"
580)]
581fn zero(value: &u32) -> bool {
582    *value == 0
583}
584
585#[cfg(test)]
586mod tests;