rig_harness_protocol/lib.rs
1//! The protocol between the `rig` launcher (the `rig` crate's binary) and the
2//! rig-harness agent it builds and runs: the `RIG_HOME` layout, session ids,
3//! the environment the launcher passes to the agent, and the exit code with
4//! which the agent asks for a restart. The launcher and `rig-harness` both use
5//! this crate, so the two sides cannot drift apart. It uses only std, and
6//! it changes with rig-harness.
7
8use std::ffi::OsString;
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::str::FromStr;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15/// The exit code with which the agent asks its launcher to restart it on
16/// the staged build.
17pub const RELOAD_EXIT_CODE: u8 = 75;
18
19/// The version of rig: of the workspace, the launcher and the harness they
20/// build together. It stamps generated projects, so a new launcher
21/// regenerates and rebuilds the agent; a plugin reads it to say which rig
22/// wrote something.
23pub const VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// The one Bevy version of the agent and of every plugin: a Bevy plugin
26/// only works with the exact Bevy version of its app.
27pub const BEVY_VERSION: &str = "0.20.0";
28
29/// The environment variables of the protocol.
30pub mod env {
31 /// The root of every rig directory, `~/.rig` when unset. The launcher
32 /// passes it on made absolute; see [`Home::from_env`](super::Home::from_env).
33 pub const HOME: &str = "RIG_HOME";
34 /// The [`SessionId`](super::SessionId) the agent runs. `rig build`,
35 /// run by the agent's `/reload`, stages for that session's launcher.
36 pub const SESSION: &str = "RIG_SESSION";
37 /// The launcher executable, which `/reload` runs as `rig build`. Unlike
38 /// [`AGENT_ONLY`], the agent's commands keep it, so the model runs
39 /// `"$RIG_LAUNCHER" plugin check --build`; an agent counts as started by
40 /// a launcher only with [`SESSION`] set too.
41 pub const LAUNCHER: &str = "RIG_LAUNCHER";
42 /// A line the agent shows at startup, such as a rollback.
43 pub const NOTICE: &str = "RIG_NOTICE";
44 /// Why the build before this start failed, so an older build runs:
45 /// the reason and the first compiler errors. The whole output is in
46 /// [`Home::build_log`](super::Home::build_log). The agent puts it in
47 /// its conversation, for the model to read.
48 pub const BUILD_FAILURE: &str = "RIG_BUILD_FAILURE";
49 /// The launcher's [`RunDir`](super::RunDir), where the agent says it
50 /// started.
51 pub const RUN: &str = "RIG_RUN";
52 /// Why the launcher started the agent: a
53 /// [`StartReason`](super::StartReason), as text.
54 pub const START: &str = "RIG_START";
55 /// What the launcher tells the agent it runs, and only that agent: a
56 /// command the agent runs, such as a nested agent while working on
57 /// rig-harness itself, must not act as this agent. Without [`SESSION`]
58 /// a nested agent ignores the [`LAUNCHER`] it inherits.
59 pub const AGENT_ONLY: &[&str] = &[SESSION, NOTICE, BUILD_FAILURE, RUN, START];
60 /// Set by the launcher to a file's path: the agent builds its app,
61 /// writes what its plugins add there ([`AgentDescription`](super::AgentDescription))
62 /// and exits without running a frame.
63 pub const DESCRIBE: &str = "RIG_DESCRIBE";
64 /// The start of a secret's variable, such as `RIG_SECRET_CRYPT`
65 /// ([`secret_variable`](super::secret_variable)): the launcher sets it
66 /// on the agent's process alone, and every process rig starts drops it
67 /// ([`secret_variables`](super::secret_variables)).
68 pub const SECRET_PREFIX: &str = "RIG_SECRET_";
69 /// A rig checkout the agent is built from instead of crates.io.
70 pub const SOURCE: &str = "RIG_SOURCE";
71}
72
73/// The launcher that started this agent, if one did: it sets both
74/// [`env::LAUNCHER`] and [`env::SESSION`]. A nested agent, run by this
75/// agent's shell, inherits only the launcher's path, which the model uses.
76pub fn launcher() -> Option<OsString> {
77 std::env::var_os(env::SESSION).filter(|session| !session.is_empty())?;
78 std::env::var_os(env::LAUNCHER).filter(|launcher| !launcher.is_empty())
79}
80
81/// The variable that carries the secret `name`: [`env::SECRET_PREFIX`]
82/// and the name in upper case, `-` as `_` (`crypt` is `RIG_SECRET_CRYPT`).
83pub fn secret_variable(name: &str) -> String {
84 format!(
85 "{}{}",
86 env::SECRET_PREFIX,
87 name.to_ascii_uppercase().replace('-', "_")
88 )
89}
90
91/// Every secret variable ([`env::SECRET_PREFIX`]) in this process's
92/// environment: what a process this one starts must not inherit.
93pub fn secret_variables() -> Vec<OsString> {
94 std::env::vars_os()
95 .map(|(name, _)| name)
96 .filter(|name| {
97 name.to_str()
98 .is_some_and(|name| name.starts_with(env::SECRET_PREFIX))
99 })
100 .collect()
101}
102
103/// At most `count` lines of a build's output from the first one that
104/// starts with `error`, as cargo writes a compiler error; none when no
105/// line does.
106pub fn first_errors<'a>(lines: impl IntoIterator<Item = &'a str>, count: usize) -> Vec<&'a str> {
107 lines
108 .into_iter()
109 .skip_while(|line| !line.starts_with("error"))
110 .take(count)
111 .collect()
112}
113
114/// A session's id: digits and dashes, so it is safe as a path segment. The
115/// launcher makes one per new session; the agent makes its own when it
116/// runs without the launcher.
117#[derive(Clone, Debug, PartialEq, Eq, Hash)]
118pub struct SessionId(String);
119
120impl SessionId {
121 /// A new id: the Unix time in seconds and this process's id, then,
122 /// after the process's first, how many it made before, so no two of
123 /// one process are the same.
124 pub fn generate() -> Self {
125 static MADE: AtomicU64 = AtomicU64::new(0);
126 let seconds = SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map(|elapsed| elapsed.as_secs())
129 .unwrap_or_default();
130 let id = format!("{seconds}-{}", std::process::id());
131 match MADE.fetch_add(1, Ordering::Relaxed) {
132 0 => Self(id),
133 made => Self(format!("{id}-{made}")),
134 }
135 }
136
137 /// The id in [`env::SESSION`], or `None` when it is unset or empty.
138 pub fn from_env() -> Result<Option<Self>, String> {
139 let id = std::env::var_os(env::SESSION).filter(|id| !id.is_empty());
140 id.map(|id| id.to_string_lossy().parse()).transpose()
141 }
142
143 /// The id as text.
144 pub fn as_str(&self) -> &str {
145 &self.0
146 }
147}
148
149impl FromStr for SessionId {
150 type Err = String;
151
152 fn from_str(id: &str) -> Result<Self, Self::Err> {
153 let valid = id.starts_with(|c: char| c.is_ascii_digit())
154 && id.chars().all(|c| c.is_ascii_digit() || c == '-');
155 if valid {
156 Ok(Self(id.to_owned()))
157 } else {
158 Err(format!(
159 "`{id}` is not a session id: it must be digits and dashes"
160 ))
161 }
162 }
163}
164
165impl fmt::Display for SessionId {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.write_str(&self.0)
168 }
169}
170
171/// Why the agent process started, which the launcher passes in
172/// [`env::START`]. A switch to another session (`/new`, `/resume`) starts
173/// it as `New` or `Resumed`; a crash is no reason, since the launcher
174/// never restarts a crashed agent.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176#[non_exhaustive]
177pub enum StartReason {
178 /// The session's first start: a new session, or `/new`.
179 New,
180 /// A session that ran before: `rig --resume`, or `/resume` switching
181 /// to it.
182 Resumed,
183 /// The same session again after `/reload` or a plugin's reload exit.
184 Reloaded,
185 /// The same session again on the previous build, after the launcher
186 /// rolled back a new build that stopped during startup.
187 Restarted,
188}
189
190impl StartReason {
191 /// Its text in [`env::START`].
192 pub fn as_str(self) -> &'static str {
193 match self {
194 Self::New => "new",
195 Self::Resumed => "resumed",
196 Self::Reloaded => "reloaded",
197 Self::Restarted => "restarted",
198 }
199 }
200
201 /// The reason in [`env::START`], or `None` when it is unset or not one.
202 pub fn from_env() -> Option<Self> {
203 std::env::var(env::START).ok()?.parse().ok()
204 }
205}
206
207impl FromStr for StartReason {
208 type Err = String;
209
210 fn from_str(text: &str) -> Result<Self, Self::Err> {
211 [Self::New, Self::Resumed, Self::Reloaded, Self::Restarted]
212 .into_iter()
213 .find(|reason| reason.as_str() == text)
214 .ok_or_else(|| format!("`{text}` is not a start reason"))
215 }
216}
217
218/// The `RIG_HOME` layout. Every file the launcher and the agent write lives
219/// under this one root.
220#[derive(Clone, Debug)]
221pub struct Home {
222 root: PathBuf,
223}
224
225impl Home {
226 /// The root from [`env::HOME`], else `~/.rig`, made absolute so the
227 /// launcher, the agent and their children agree on it from any
228 /// directory.
229 pub fn from_env() -> Self {
230 let root = std::env::var_os(env::HOME)
231 .filter(|root| !root.is_empty())
232 .map(PathBuf::from)
233 .or_else(|| std::env::home_dir().map(|home| home.join(".rig")))
234 .unwrap_or_else(|| PathBuf::from(".rig"));
235 Self::at(root)
236 }
237
238 /// The layout under `root`, made absolute: a home of its own, such as
239 /// a test's.
240 pub fn at(root: impl Into<PathBuf>) -> Self {
241 let root = root.into();
242 let root = std::path::absolute(&root).unwrap_or(root);
243 Self { root }
244 }
245
246 /// The root itself.
247 pub fn root(&self) -> &Path {
248 &self.root
249 }
250
251 /// `plugins.toml`, the plugin list.
252 pub fn plugins_toml(&self) -> PathBuf {
253 self.root.join("plugins.toml")
254 }
255
256 /// `plugins/`, where `rig plugin new` makes plugin crates, outside any
257 /// workspace: `plugins/<name>` is listed in `plugins.toml` as
258 /// `path = "plugins/<name>"`.
259 pub fn plugins(&self) -> PathBuf {
260 self.root.join("plugins")
261 }
262
263 /// `settings.toml`, the user's settings for plugins: one table per
264 /// plugin, which the agent hands to the plugin that reads it.
265 pub fn settings(&self) -> PathBuf {
266 self.root.join("settings.toml")
267 }
268
269 /// `defaults.json`, the model and reasoning setting a new session
270 /// starts with: the last ones chosen.
271 pub fn defaults(&self) -> PathBuf {
272 self.root.join("defaults.json")
273 }
274
275 /// `auth/`, where `/login` keeps each subscription credential, as
276 /// `<provider>.json`.
277 pub fn auth(&self) -> PathBuf {
278 self.root.join("auth")
279 }
280
281 /// The generated agent project.
282 pub fn project(&self) -> PathBuf {
283 self.root.join("project")
284 }
285
286 /// The whole output of the last build, the launcher's and cargo's,
287 /// rewritten by every build: the one before each start and `rig build`,
288 /// which `/reload` runs. Its last line says how the build ended.
289 pub fn build_log(&self) -> PathBuf {
290 self.root.join("build.log")
291 }
292
293 /// Cargo's target directory for the agent project.
294 pub fn target(&self) -> PathBuf {
295 self.root.join("target")
296 }
297
298 /// The directory of the builds and the build lock.
299 pub fn bin(&self) -> PathBuf {
300 self.root.join("bin")
301 }
302
303 /// The last build that started up.
304 pub fn good(&self) -> PathBuf {
305 self.bin().join("good")
306 }
307
308 /// The build `rig build` staged for whichever launcher starts next.
309 pub fn staged(&self) -> PathBuf {
310 self.bin().join("staged")
311 }
312
313 /// The [`AgentDescription`] report of the build whose stamp is `stamp` (its
314 /// modification time in nanoseconds and its length, as `"{mtime} {len}"`),
315 /// one per build, which every copy of the build shares.
316 pub fn described(&self, stamp: &str) -> PathBuf {
317 self.bin()
318 .join("described")
319 .join(stamp.trim().replace(' ', "-"))
320 }
321
322 /// The stamp of the last build that stopped before it was ready.
323 pub fn rejected(&self) -> PathBuf {
324 self.bin().join("rejected")
325 }
326
327 /// The lock every launcher on this root takes to generate, build and
328 /// stage the agent.
329 pub fn build_lock(&self) -> PathBuf {
330 self.bin().join("lock")
331 }
332
333 /// The build staged for the launcher of `session` alone.
334 pub fn staged_for(&self, session: &SessionId) -> PathBuf {
335 self.bin().join(format!("staged-{session}"))
336 }
337
338 /// The build the launcher of `session` runs until it is ready.
339 pub fn trial_for(&self, session: &SessionId) -> PathBuf {
340 self.bin().join(format!("trial-{session}"))
341 }
342
343 /// The session whose launcher owns a file of [`Home::bin`] named
344 /// `name`: a `staged-<session>` or `trial-<session>`.
345 pub fn owner_of(name: &str) -> Option<SessionId> {
346 name.strip_prefix("trial-")
347 .or_else(|| name.strip_prefix("staged-"))?
348 .parse()
349 .ok()
350 }
351
352 /// `data/`, where plugins keep files across sessions and projects.
353 pub fn data(&self) -> PathBuf {
354 self.root.join("data")
355 }
356
357 /// `projects/`, where plugins keep files per project, one directory
358 /// for each project the agent worked in.
359 pub fn projects(&self) -> PathBuf {
360 self.root.join("projects")
361 }
362
363 /// The directory holding every running launcher's [`RunDir`].
364 pub fn runs(&self) -> PathBuf {
365 self.root.join("run")
366 }
367
368 /// The run directory of the launcher with the process id `launcher`.
369 pub fn run(&self, launcher: u32) -> RunDir {
370 RunDir(self.runs().join(launcher.to_string()))
371 }
372
373 /// The directory holding every session's directory.
374 pub fn sessions(&self) -> PathBuf {
375 self.root.join("sessions")
376 }
377
378 /// The directory of `session`.
379 pub fn session(&self, session: &SessionId) -> SessionDir {
380 SessionDir(self.sessions().join(session.as_str()))
381 }
382
383 /// A new session, its directory created: a [`SessionId::generate`]d
384 /// id, or, when a session of this home has that one, the first free
385 /// one after it, such as `1700000000-42-1`.
386 ///
387 /// # Errors
388 ///
389 /// When the directory cannot be created.
390 pub fn new_session(&self) -> std::io::Result<(SessionId, SessionDir)> {
391 std::fs::create_dir_all(self.sessions())?;
392 let base = SessionId::generate();
393 let mut id = base.clone();
394 let mut taken = 0u64;
395 loop {
396 let dir = self.session(&id);
397 match std::fs::create_dir(dir.path()) {
398 Ok(()) => return Ok((id, dir)),
399 Err(failure) if failure.kind() == std::io::ErrorKind::AlreadyExists => {
400 taken += 1;
401 id = SessionId(format!("{base}-{taken}"));
402 }
403 Err(failure) => return Err(failure),
404 }
405 }
406 }
407
408 /// Every saved session of this home (not one being deleted), in no
409 /// order, with its directory: what every listing and [`Self::find`]
410 /// read.
411 pub fn saved_sessions(&self) -> Vec<(SessionId, SessionDir)> {
412 let entries = std::fs::read_dir(self.sessions()).into_iter().flatten();
413 let ids = entries.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok());
414 ids.map(|id| {
415 let dir = self.session(&id);
416 (id, dir)
417 })
418 .filter(|(_, dir)| dir.is_saved())
419 .collect()
420 }
421
422 /// The saved session `name_or_id` names: the one with that id when it
423 /// parses as one and is saved, else the only saved session with that
424 /// [`SessionDir::name`].
425 ///
426 /// # Errors
427 ///
428 /// When no saved session has that id or name, or several have that
429 /// name: the error lists their ids.
430 pub fn find(&self, name_or_id: &str) -> Result<SessionId, String> {
431 let wanted = name_or_id.trim();
432 if let Ok(id) = wanted.parse::<SessionId>()
433 && self.session(&id).is_saved()
434 {
435 return Ok(id);
436 }
437 let mut named: Vec<SessionId> = self
438 .saved_sessions()
439 .into_iter()
440 .filter(|(_, dir)| dir.name().as_deref() == Some(wanted))
441 .map(|(id, _)| id)
442 .collect();
443 named.sort_by(|a, b| a.as_str().cmp(b.as_str()));
444 match named.as_slice() {
445 [id] => Ok(id.clone()),
446 [] => Err(format!("`{wanted}` names no saved session")),
447 several => Err(format!(
448 "`{wanted}` names {} saved sessions: {}; resume one by its id",
449 several.len(),
450 several
451 .iter()
452 .map(SessionId::as_str)
453 .collect::<Vec<_>>()
454 .join(", ")
455 )),
456 }
457 }
458}
459
460/// A launcher's own directory, outside every session, which lives while
461/// the launcher runs: the file that tells it the agent started, and the
462/// log of a sessionless run that failed, which outlives the launcher.
463#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct RunDir(PathBuf);
465
466impl RunDir {
467 /// The directory the launcher named in [`env::RUN`], or `None` when
468 /// it is unset or empty.
469 pub fn from_env() -> Option<Self> {
470 let path = std::env::var_os(env::RUN).filter(|path| !path.is_empty())?;
471 Some(Self(PathBuf::from(path)))
472 }
473
474 /// The directory itself.
475 pub fn path(&self) -> &Path {
476 &self.0
477 }
478
479 /// Written by the agent once its build started, or exited on purpose
480 /// before that; the launcher then keeps the build.
481 pub fn ready(&self) -> PathBuf {
482 self.0.join("ready")
483 }
484
485 /// The text log of a sessionless run that failed, moved here from its
486 /// session's directory, which is removed.
487 pub fn log(&self) -> PathBuf {
488 self.0.join("agent.log")
489 }
490
491 /// Why the agent refused its run, as it said it on stderr: the
492 /// launcher says it again once it took the terminal back from a
493 /// full-screen view that a restart kept.
494 pub fn refusal(&self) -> PathBuf {
495 self.0.join("refusal")
496 }
497
498 /// Held by the launcher while it lives.
499 pub fn lock(&self) -> PathBuf {
500 self.0.join("lock")
501 }
502}
503
504/// A session's directory. The agent keeps the session there through its
505/// store, which a plugin may replace (by default in the folders `logs`,
506/// `blobs` and `files`), so only the plain files beside the store are
507/// named here: the markers of a session an agent started in
508/// ([`Self::started`]), logged to ([`Self::is_saved`]) and being deleted
509/// ([`Self::is_deleting`]), the text log, the lock of the launcher running
510/// it, its working directory, its name and the session to switch to.
511#[derive(Clone, Debug)]
512pub struct SessionDir(PathBuf);
513
514impl SessionDir {
515 /// The session kept in the directory `path`, such as one a command
516 /// names, rather than one of a [`Home`]'s.
517 pub fn at(path: impl Into<PathBuf>) -> Self {
518 Self(path.into())
519 }
520
521 /// The directory itself.
522 pub fn path(&self) -> &Path {
523 &self.0
524 }
525
526 /// The session's id: the directory's name, when it is one.
527 pub fn id(&self) -> Option<SessionId> {
528 self.0.file_name()?.to_str()?.parse().ok()
529 }
530
531 /// Whether an agent of the session wrote its log, so it can be
532 /// resumed: the session has the marker [`Self::mark_saved`] writes and
533 /// is not being deleted.
534 pub fn is_saved(&self) -> bool {
535 self.saved().exists() && !self.is_deleting()
536 }
537
538 /// Whether the session is being deleted: it has the marker
539 /// [`Self::mark_deleting`] writes. Listings leave it out, and the
540 /// agent's next start finishes deleting it.
541 pub fn is_deleting(&self) -> bool {
542 self.deleting().exists()
543 }
544
545 /// Marks the session as being deleted, before anything of it is
546 /// removed, so a delete a crash cuts short is finished later and never
547 /// leaves a half-removed session to resume.
548 ///
549 /// # Errors
550 ///
551 /// When the marker cannot be written.
552 pub fn mark_deleting(&self) -> std::io::Result<()> {
553 std::fs::write(self.deleting(), b"")
554 }
555
556 /// The plain, empty marker of a session being deleted.
557 fn deleting(&self) -> PathBuf {
558 self.0.join("deleting")
559 }
560
561 /// Marks the session saved, as the agent's store does when it first
562 /// writes an agent's log there.
563 ///
564 /// # Errors
565 ///
566 /// When the marker cannot be written.
567 pub fn mark_saved(&self) -> std::io::Result<()> {
568 std::fs::write(self.saved(), b"")
569 }
570
571 /// The plain, empty marker of a saved session.
572 fn saved(&self) -> PathBuf {
573 self.0.join("saved")
574 }
575
576 /// The plain, empty marker of a session an agent started in: its
577 /// first app ran a whole frame.
578 pub fn started(&self) -> PathBuf {
579 self.0.join("started")
580 }
581
582 /// The agent's text log, unless a plugin keeps it elsewhere or not at
583 /// all.
584 pub fn log(&self) -> PathBuf {
585 self.0.join("agent.log")
586 }
587
588 /// Held by the launcher running the session while it lives.
589 pub fn launcher_lock(&self) -> PathBuf {
590 self.0.join("launcher.lock")
591 }
592
593 /// Takes [`Self::launcher_lock`], creating the directory, unless
594 /// another launcher holds it: the lock that marks the session as run,
595 /// held until the returned file is dropped or the process dies. A
596 /// launcher holds it while it runs the session, and a program that
597 /// changes a saved session holds it while it does, so no launcher
598 /// starts the session meanwhile. `None` when another holds it.
599 ///
600 /// # Errors
601 ///
602 /// When the directory or the lock file cannot be made, or locking
603 /// fails otherwise.
604 pub fn hold(&self) -> std::io::Result<Option<std::fs::File>> {
605 std::fs::create_dir_all(&self.0)?;
606 let file = lock_file(&self.launcher_lock(), true)?;
607 match file.try_lock() {
608 Ok(()) => Ok(Some(file)),
609 Err(std::fs::TryLockError::WouldBlock) => Ok(None),
610 Err(std::fs::TryLockError::Error(failure)) => Err(failure),
611 }
612 }
613
614 /// Whether a launcher runs the session: one holds
615 /// [`Self::launcher_lock`]. A command that rewrites or removes a saved
616 /// session leaves such a one alone.
617 pub fn runs_elsewhere(&self) -> bool {
618 !is_free(&self.launcher_lock())
619 }
620
621 /// The working directory the session runs in, as plain text: written
622 /// when the session starts, and where the launcher starts the agent
623 /// when it resumes the session from elsewhere.
624 pub fn directory(&self) -> PathBuf {
625 self.0.join("directory")
626 }
627
628 /// The name the user gave the session, kept as plain text in the
629 /// file `name` beside [`Self::directory`], so the launcher finds the
630 /// session by it ([`Home::find`]) without opening its store. Like the
631 /// directory, it is readable on disk when the store seals what it
632 /// keeps.
633 pub fn name(&self) -> Option<String> {
634 let text = std::fs::read_to_string(self.name_file()).ok()?;
635 let name = text.trim();
636 (!name.is_empty()).then(|| name.to_owned())
637 }
638
639 /// Names the session, or removes its name with `None` or a blank name.
640 /// The name is written beside the file and renamed over it, so a
641 /// reader sees the old name or the new one, never a part.
642 ///
643 /// # Errors
644 ///
645 /// When the file cannot be written or removed.
646 pub fn set_name(&self, name: Option<&str>) -> std::io::Result<()> {
647 let file = self.name_file();
648 match name.map(str::trim).filter(|name| !name.is_empty()) {
649 Some(name) => {
650 let part = self.0.join("name.part");
651 std::fs::write(&part, name)?;
652 std::fs::rename(part, file)
653 }
654 None => match std::fs::remove_file(file) {
655 Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => Ok(()),
656 removed => removed,
657 },
658 }
659 }
660
661 /// The plain-text file of [`Self::name`].
662 fn name_file(&self) -> PathBuf {
663 self.0.join("name")
664 }
665
666 /// Written by the agent before it exits with [`RELOAD_EXIT_CODE`] to
667 /// run another session: the [`SessionId`] to resume, or nothing for a
668 /// new session. The launcher reads and removes it.
669 pub fn switch(&self) -> PathBuf {
670 self.0.join("switch")
671 }
672
673 /// The session's working directory, when [`Self::directory`] names one
674 /// that still exists.
675 pub fn working_directory(&self) -> Option<PathBuf> {
676 let text = std::fs::read_to_string(self.directory()).ok()?;
677 let directory = PathBuf::from(text.trim_end_matches('\n'));
678 directory.is_dir().then_some(directory)
679 }
680}
681
682/// The agent's arguments, in the canonical form the launcher passes on
683/// ([`Invocation::to_args`]): the model, each plugin option as
684/// `--name=VALUE`, then the print prompt after `--print --`, or a plugin's
685/// command and its arguments. The launcher reads the user's command line
686/// against the agent's [`AgentDescription`] report first
687/// ([`AgentDescription::invocation`]), so `rig -p "…"` and the agent binary run
688/// alone agree on them.
689#[derive(Clone, Debug, PartialEq, Eq, Default)]
690pub struct Invocation {
691 /// `Some` for print mode: answer this one prompt, then exit, with the
692 /// answer on stdout and text piped in on stdin after the prompt (which
693 /// may be empty when stdin is piped). `None` runs the terminal view, or
694 /// no view at all without one.
695 pub print: Option<String>,
696 /// A catalog model (`vendor/model`) for the session's first agent.
697 pub model: Option<String>,
698 /// The options of the agent's plugins, in the order given: each
699 /// `--name` with its value, if any. The agent refuses one no plugin of
700 /// its build takes.
701 pub flags: Vec<(String, Option<String>)>,
702 /// A plugin's command, `rig <command> …`, which chooses the front that
703 /// runs.
704 pub command: Option<String>,
705 /// The words after the command, as given.
706 pub arguments: Vec<String>,
707}
708
709/// The arguments [`Invocation::parse`] takes, for usage texts.
710pub const INVOCATION_USAGE: &str =
711 " -p, --print [prompt…] Answer one prompt and exit: the answer goes to stdout.
712 Text piped in on stdin follows the prompt. With
713 --json, the run as JSON lines; --json alone reads
714 requests on stdin (`rig help-agents`).
715 -m, --model <model> The catalog model (vendor/model) to use.
716 --<option> [<value>] An option of one of the agent's plugins, also written
717 --<option>=<value>.
718";
719
720impl Invocation {
721 /// Reads the agent's arguments in their canonical form, without the
722 /// program name: a plugin option's value comes after `=`. Without
723 /// `--print`, the first other word is a command, and every word after
724 /// it is the command's.
725 pub fn parse<S: AsRef<str>>(args: &[S]) -> Result<Self, String> {
726 match read_args(args, |flag, _| plugin_flag(flag))? {
727 (invocation, None) => Ok(invocation),
728 (_, Some(_)) => Err("`--resume` is the launcher's".to_owned()),
729 }
730 }
731
732 /// The agent's own arguments, from the process's.
733 pub fn from_env() -> Result<Self, String> {
734 let args: Vec<String> = std::env::args().skip(1).collect();
735 Self::parse(&args)
736 }
737
738 /// Whether nobody sits at a terminal view, and the run ends by itself.
739 pub fn is_headless(&self) -> bool {
740 self.print.is_some()
741 }
742
743 /// The arguments that [`Self::parse`] reads back as `self`.
744 pub fn to_args(&self) -> Vec<OsString> {
745 let mut args: Vec<OsString> = Vec::new();
746 if let Some(model) = &self.model {
747 args.extend(["--model".into(), model.into()]);
748 }
749 for (name, value) in &self.flags {
750 args.push(match value {
751 Some(value) => format!("--{name}={value}").into(),
752 None => format!("--{name}").into(),
753 });
754 }
755 if let Some(prompt) = &self.print {
756 args.push("--print".into());
757 if !prompt.is_empty() {
758 args.extend(["--".into(), prompt.into()]);
759 }
760 } else if let Some(command) = &self.command {
761 args.push(command.into());
762 args.extend(self.arguments.iter().map(OsString::from));
763 }
764 args
765 }
766}
767
768/// Reads a command line left to right, as [`Invocation::parse`] and
769/// [`AgentDescription`] do, into the invocation and the session `-r` names; they
770/// differ in how a plugin option is read: `option` reads one, `--…`,
771/// given the words after it, from which it may take its value.
772fn read_args<'a, S: AsRef<str>>(
773 args: &'a [S],
774 mut option: impl FnMut(
775 &'a str,
776 &mut dyn Iterator<Item = &'a str>,
777 ) -> Result<(String, Option<String>), String>,
778) -> Result<(Invocation, Option<String>), String> {
779 let mut print = false;
780 let mut model = None;
781 let mut resume = None;
782 let mut flags = Vec::new();
783 let mut words: Vec<&str> = Vec::new();
784 let mut args = args.iter().map(AsRef::as_ref);
785 while let Some(arg) = args.next() {
786 match arg {
787 "-p" | "--print" => print = true,
788 "-m" | "--model" => {
789 let name = args.next().ok_or("--model needs a vendor/model")?;
790 model = Some(name.to_owned());
791 }
792 "--" => words.extend(args.by_ref()),
793 flag if flag.starts_with("--model=") => {
794 model = flag.strip_prefix("--model=").map(str::to_owned);
795 }
796 "-r" | "--resume" => {
797 let session = args.next().ok_or("--resume needs a session id or name")?;
798 resume = Some(session.to_owned());
799 }
800 flag if flag.starts_with("--resume=") => {
801 resume = flag.strip_prefix("--resume=").map(str::to_owned);
802 }
803 flag if flag.starts_with("--") => flags.push(option(flag, &mut args)?),
804 flag if flag.starts_with('-') && flag.len() > 1 => {
805 return Err(format!("unknown option `{flag}`"));
806 }
807 // A command takes every word after it as it is.
808 word if !print => {
809 words.push(word);
810 words.extend(args.by_ref());
811 }
812 word => words.push(word),
813 }
814 }
815 let mut words = words.into_iter().map(str::to_owned);
816 let (print, command) = if print {
817 (Some(words.by_ref().collect::<Vec<_>>().join(" ")), None)
818 } else {
819 (None, words.next())
820 };
821 let invocation = Invocation {
822 print,
823 model,
824 flags,
825 command,
826 arguments: words.collect(),
827 };
828 Ok((invocation, resume))
829}
830
831/// A plugin's option, `--name` or `--name=value`: the name is lowercase
832/// ASCII letters, digits and dashes, starting with a letter.
833fn plugin_flag(flag: &str) -> Result<(String, Option<String>), String> {
834 let option = flag.strip_prefix("--").unwrap_or(flag);
835 let (name, value) = match option.split_once('=') {
836 Some((name, value)) => (name, Some(value.to_owned())),
837 None => (option, None),
838 };
839 if !is_name(name) {
840 return Err(format!("unknown option `{flag}`"));
841 }
842 Ok((name.to_owned(), value))
843}
844
845/// Opens the lock file at `path` for writing, creating it when `create`.
846/// Every lock between launchers and agents is such a file, held with
847/// [`std::fs::File::try_lock`] or [`std::fs::File::lock`].
848///
849/// # Errors
850///
851/// When the file cannot be opened (or is missing and not `create`).
852pub fn lock_file(path: &Path, create: bool) -> std::io::Result<std::fs::File> {
853 std::fs::File::options()
854 .create(create)
855 .write(true)
856 .truncate(false)
857 .open(path)
858}
859
860/// Whether no live process holds the lock file at `path`: it is missing, or
861/// it can be locked now. A failure that hides the answer counts as held.
862pub fn is_free(path: &Path) -> bool {
863 match lock_file(path, false) {
864 Ok(lock) => lock.try_lock().is_ok(),
865 Err(failure) => failure.kind() == std::io::ErrorKind::NotFound,
866 }
867}
868
869/// Whether `name` can name a plugin's option or command: lowercase ASCII
870/// letters, digits and dashes, starting with a letter.
871pub fn is_name(name: &str) -> bool {
872 name.starts_with(|c: char| c.is_ascii_lowercase())
873 && name
874 .chars()
875 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
876}
877
878/// The launcher's own first words, which no plugin's command may take.
879pub const RESERVED_COMMANDS: &[&str] = &["build", "plugin", "help", "help-agents"];
880
881/// The plugins whose absence takes away the way back from inside the
882/// agent, by type path, with what goes: `rig plugin disable`, `rig plugin
883/// list` and `/plugins` warn on them. `rig plugin enable` from a shell
884/// always brings one back.
885pub const ESSENTIAL_PLUGINS: &[(&str, &str)] = &[
886 (
887 "rig_tui::TuiPlugin",
888 "the terminal view, leaving only -p and piped prompts",
889 ),
890 (
891 "rig_basics::reload::plugins::PluginsPlugin",
892 "/plugins itself",
893 ),
894 (
895 "rig_basics::reload::ReloadPlugin",
896 "/reload, so a change applies only when rig starts again",
897 ),
898];
899
900/// What goes when the plugin `type_path` is left out, if it is one of the
901/// [`ESSENTIAL_PLUGINS`].
902pub fn essential(type_path: &str) -> Option<&'static str> {
903 ESSENTIAL_PLUGINS
904 .iter()
905 .find(|(essential, _)| *essential == type_path)
906 .map(|(_, what)| *what)
907}
908
909/// The first line of a [`AgentDescription`] report.
910const DESCRIBED_HEADER: &str = "rig-describe 1";
911
912/// What a built agent's plugins add to the command line, as the agent
913/// writes it when the launcher sets [`env::DESCRIBE`]: their commands,
914/// their options and the secrets they ask for, and the plugins whose
915/// agent-loop systems clash, for `rig plugin check --build`. The launcher
916/// keeps one per build ([`Home::described`]) and reads the command line
917/// against it.
918///
919/// The text form is a header line, then one line each, its fields separated by
920/// tabs (shown here as `⇥`):
921///
922/// ```text
923/// rig-describe 1
924/// command ⇥ stats ⇥ - ⇥ headless ⇥ sessionless ⇥ Count the saved sessions
925/// flag ⇥ remote ⇥ HOST:PATH ⇥ Work in a directory on another machine over SSH
926/// secret ⇥ crypt ⇥ Passphrase for this machine's sessions
927/// conflict ⇥ `a::APlugin` and `b::BPlugin`: a::count and b::reset, on b::Tally
928/// ```
929#[derive(Clone, Debug, Default, PartialEq, Eq)]
930pub struct AgentDescription {
931 /// The plugins' commands.
932 pub commands: Vec<DescribedCommand>,
933 /// The plugins' options, the fronts' included.
934 pub flags: Vec<DescribedFlag>,
935 /// The secrets the plugins ask for.
936 pub secrets: Vec<DescribedSecret>,
937 /// Each pair of systems of one agent-loop stage that use the same data
938 /// with no order between them, one writing it, naming their plugins.
939 pub conflicts: Vec<String>,
940}
941
942/// A plugin's command, `rig <name> <usage>`.
943#[derive(Clone, Debug, PartialEq, Eq)]
944pub struct DescribedCommand {
945 /// Its name.
946 pub name: String,
947 /// What its arguments are, such as `<tool> [path]`; may be empty.
948 pub usage: String,
949 /// Whether it runs on an interactive terminal; otherwise it runs
950 /// headless, never restarted, its output passed through.
951 pub terminal: bool,
952 /// Whether its runs keep no session.
953 pub sessionless: bool,
954 /// What it does.
955 pub help: String,
956}
957
958/// A plugin's option, `--name` or `--name VALUE`.
959#[derive(Clone, Debug, PartialEq, Eq)]
960pub struct DescribedFlag {
961 /// Its name.
962 pub name: String,
963 /// What its value is, such as `ADDR`, when it takes one.
964 pub value: Option<String>,
965 /// What it does.
966 pub help: String,
967}
968
969/// A secret a plugin asks for, which reaches the agent as
970/// [`secret_variable`] of its name.
971#[derive(Clone, Debug, PartialEq, Eq)]
972pub struct DescribedSecret {
973 /// Its name.
974 pub name: String,
975 /// What to ask for.
976 pub help: String,
977}
978
979impl AgentDescription {
980 /// Reads a report. Without the header it is empty: a build from before
981 /// commands, which has none. A record of a kind it does not know is
982 /// skipped, and fields after those it knows are ignored, so a later
983 /// version can add both.
984 pub fn parse(text: &str) -> Self {
985 let mut lines = text.lines();
986 let mut described = Self::default();
987 if lines.next() != Some(DESCRIBED_HEADER) {
988 return described;
989 }
990 let given = |field: &str| (field != "-").then(|| field.to_owned());
991 for line in lines {
992 match line.split('\t').collect::<Vec<_>>().as_slice() {
993 ["command", name, usage, run, session, help, ..] => {
994 described.commands.push(DescribedCommand {
995 name: (*name).to_owned(),
996 usage: given(usage).unwrap_or_default(),
997 terminal: *run == "terminal",
998 sessionless: *session == "sessionless",
999 help: (*help).to_owned(),
1000 });
1001 }
1002 ["flag", name, value, help, ..] => described.flags.push(DescribedFlag {
1003 name: (*name).to_owned(),
1004 value: given(value),
1005 help: (*help).to_owned(),
1006 }),
1007 ["secret", name, help, ..] => described.secrets.push(DescribedSecret {
1008 name: (*name).to_owned(),
1009 help: (*help).to_owned(),
1010 }),
1011 ["conflict", conflict, ..] => described.conflicts.push((*conflict).to_owned()),
1012 // A record of a later version.
1013 _ => {}
1014 }
1015 }
1016 described
1017 }
1018
1019 /// The report as text, which [`Self::parse`] reads back. Tabs and line
1020 /// breaks in a field become spaces.
1021 pub fn write(&self) -> String {
1022 let clean = |field: &str| field.replace(['\t', '\n', '\r'], " ");
1023 let or_dash = |field: &str| match field {
1024 "" => "-".to_owned(),
1025 field => clean(field),
1026 };
1027 let mut lines = vec![DESCRIBED_HEADER.to_owned()];
1028 for command in &self.commands {
1029 lines.push(format!(
1030 "command\t{}\t{}\t{}\t{}\t{}",
1031 clean(&command.name),
1032 or_dash(&command.usage),
1033 if command.terminal {
1034 "terminal"
1035 } else {
1036 "headless"
1037 },
1038 if command.sessionless {
1039 "sessionless"
1040 } else {
1041 "session"
1042 },
1043 clean(&command.help),
1044 ));
1045 }
1046 for flag in &self.flags {
1047 lines.push(format!(
1048 "flag\t{}\t{}\t{}",
1049 clean(&flag.name),
1050 or_dash(flag.value.as_deref().unwrap_or_default()),
1051 clean(&flag.help),
1052 ));
1053 }
1054 for secret in &self.secrets {
1055 lines.push(format!(
1056 "secret\t{}\t{}",
1057 clean(&secret.name),
1058 clean(&secret.help)
1059 ));
1060 }
1061 for conflict in &self.conflicts {
1062 lines.push(format!("conflict\t{}", clean(conflict)));
1063 }
1064 lines.push(String::new());
1065 lines.join("\n")
1066 }
1067
1068 /// The command `name`, if a plugin adds it.
1069 pub fn command(&self, name: &str) -> Option<&DescribedCommand> {
1070 self.commands.iter().find(|command| command.name == name)
1071 }
1072
1073 /// Reads the user's command line, without the launcher's own first
1074 /// word, against this agent, into the canonical [`Invocation`]. `-p`,
1075 /// `-m` and `-r` ([`Self::resume`]) are the launcher's; a plugin option
1076 /// takes a value as `--name=VALUE`, or as the next word when it takes
1077 /// one (`--name VALUE`); without `-p`, the first other word is a
1078 /// command, and every later word is its argument. An option or command
1079 /// no plugin adds, and a launcher's word as a command, are refused.
1080 pub fn invocation<S: AsRef<str>>(&self, args: &[S]) -> Result<Invocation, String> {
1081 let invocation = read_args(args, |flag, rest| {
1082 let (name, value) = plugin_flag(flag).map_err(|_| self.unknown_option(flag))?;
1083 let Some(known) = self.flags.iter().find(|known| known.name == name) else {
1084 return Err(self.unknown_option(flag));
1085 };
1086 match (&known.value, value) {
1087 (Some(_), Some(value)) => Ok((name, Some(value))),
1088 (Some(what), None) => match rest.next() {
1089 Some(value) => Ok((name, Some(value.to_owned()))),
1090 None => Err(format!("--{name} needs a value: --{name} {what}")),
1091 },
1092 (None, Some(_)) => Err(format!("--{name} takes no value")),
1093 (None, None) => Ok((name, None)),
1094 }
1095 })?
1096 .0;
1097 if let Some(command) = &invocation.command {
1098 if RESERVED_COMMANDS.contains(&command.as_str()) {
1099 return Err(format!("`{command}` is the launcher's; put it first"));
1100 }
1101 if self.command(command).is_none() {
1102 return Err(format!(
1103 "unknown command `{command}`; commands: {}",
1104 self.command_names().join(", ")
1105 ));
1106 }
1107 }
1108 Ok(invocation)
1109 }
1110
1111 /// The session `-r <id|name>` (or `--resume`) names on the command
1112 /// line `args`, read as [`Self::invocation`] reads it: wherever an
1113 /// option goes, so not after `--` or among a command's words. This
1114 /// build's options that take a value take the next word as theirs; an
1115 /// option it does not know takes none. `None` without `-r`, or when
1116 /// the command line cannot be read.
1117 pub fn resume<S: AsRef<str>>(&self, args: &[S]) -> Option<String> {
1118 let read = read_args(args, |flag, rest| {
1119 let (name, value) = plugin_flag(flag)?;
1120 let spaced = value.is_none()
1121 && self
1122 .flags
1123 .iter()
1124 .any(|known| known.name == name && known.value.is_some());
1125 Ok((
1126 name,
1127 if spaced {
1128 rest.next().map(str::to_owned)
1129 } else {
1130 value
1131 },
1132 ))
1133 });
1134 read.ok().and_then(|(_, resume)| resume)
1135 }
1136
1137 /// What `rig help` lists of the build: the plugins' commands, their
1138 /// options, and the secrets they ask for, each with the variable that
1139 /// gives it (never its value).
1140 pub fn usage(&self) -> String {
1141 let mut text = String::from("\nPlugin commands (of the last build):\n");
1142 if self.commands.is_empty() {
1143 text.push_str(" (none)\n");
1144 }
1145 for command in &self.commands {
1146 let usage = format!("rig {} {}", command.name, command.usage);
1147 text.push_str(&help_entry(usage.trim_end(), &command.help));
1148 }
1149 text.push_str("\nPlugin options (of the last build):\n");
1150 if self.flags.is_empty() {
1151 text.push_str(" (none)\n");
1152 }
1153 for flag in &self.flags {
1154 let option = match &flag.value {
1155 Some(value) => format!("--{} {value}", flag.name),
1156 None => format!("--{}", flag.name),
1157 };
1158 text.push_str(&help_entry(&option, &flag.help));
1159 }
1160 if !self.secrets.is_empty() {
1161 text.push_str("\nPlugin secrets (of the last build), asked for when not set:\n");
1162 }
1163 for secret in &self.secrets {
1164 let variable = secret_variable(&secret.name);
1165 text.push_str(&help_entry(&variable, &secret.help));
1166 }
1167 text
1168 }
1169
1170 /// The launcher's commands, then the plugins'.
1171 pub fn command_names(&self) -> Vec<&str> {
1172 RESERVED_COMMANDS
1173 .iter()
1174 .copied()
1175 .chain(self.commands.iter().map(|command| command.name.as_str()))
1176 .collect()
1177 }
1178
1179 /// Why `flag` is refused, with the options there are.
1180 fn unknown_option(&self, flag: &str) -> String {
1181 let options: Vec<String> = ["-p", "-m"]
1182 .into_iter()
1183 .map(str::to_owned)
1184 .chain(self.flags.iter().map(|flag| match &flag.value {
1185 Some(value) => format!("--{} {value}", flag.name),
1186 None => format!("--{}", flag.name),
1187 }))
1188 .collect();
1189 format!("unknown option {flag}; options: {}", options.join(", "))
1190 }
1191}
1192
1193/// A line of `rig help`: `name`, then `help` from the 26th column, as the
1194/// launcher's own lines are, or on the next line there when `name` leaves
1195/// no gap before it.
1196fn help_entry(name: &str, help: &str) -> String {
1197 const COLUMN: usize = 25;
1198 let name = format!(" {name}");
1199 if name.chars().count() + 2 <= COLUMN {
1200 format!("{name:<COLUMN$}{help}\n")
1201 } else {
1202 format!("{name}\n{:COLUMN$}{help}\n", "")
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests;