Skip to main content

fno_agents/
claude_drive.rs

1//! The proven drive primitive: inject a turn into an adopted `claude --bg`
2//! session and confirm delivery by reading the session transcript.
3//!
4//! G1 substrate (epic x-07c1, node x-26df). The Phase-0 spike found the drive
5//! half is the real, architecture-independent win: `control.sock op:'reply'
6//! {short,text,auth}` lands a turn, and delivery is confirmed by a new assistant
7//! turn in the session transcript JSONL -- NEVER by socket-write success. This is
8//! independent of the (retired) keepalive question.
9//!
10//! Wire contracts pinned to claude-code **2.1.195** (readiness brief):
11//!   - inject: `control.sock op:'reply' {short, text, auth}`. `[corroborated]`
12//!   - confirm: a new assistant turn referencing a marker in
13//!     `~/.claude/projects/<cwd-enc>/<session_uuid>.jsonl`. The filename IS the
14//!     full session uuid, so we locate it by globbing the uuid across project
15//!     dirs (mirrors `cli/src/fno/doctor.py` `_find_transcript_for`), sidestepping
16//!     the lossy cwd-encoding.
17//!   - auth = the daemon `control.key` (32-hex), NOT the per-worker `ptyAuth`.
18//!   - NEVER inject the detach sentinels (they would tear the session off the
19//!     daemon). `[corroborated]`
20//!
21//! Addressing keys on the FULL `session_uuid`; `short` is a wire-derived value
22//! (`sessionId.split('-')[0]`) used only at the `control.sock` boundary.
23
24use std::io::{self, BufRead};
25use std::path::{Path, PathBuf};
26
27use crate::claude_attach::{perform_attach, AttachError, AttachRequest, ControlTransport};
28
29/// Detach sentinels Claude's client uses to pull a session off the daemon.
30/// Injecting either would detach the session, so the drive primitive refuses any
31/// text containing one. `[corroborated]`
32pub const DETACH_SENTINELS: [&str; 2] = ["\x1b_cc-daemon-detach\x1b\\", "\x1b_cc-detach-msg;"];
33
34/// Env override for the Claude projects (transcript) base dir (tests). When
35/// unset, `$HOME/.claude/projects`.
36pub const PROJECTS_DIR_ENV: &str = "FNO_CLAUDE_PROJECTS_DIR";
37
38/// What can go wrong driving a turn.
39#[derive(Debug)]
40pub enum DriveError {
41    /// The text contains a detach sentinel; refused before any write.
42    UnsafeText,
43    /// The attach handshake failed.
44    Attach(AttachError),
45    /// An I/O error (socket write, transcript read).
46    Io(String),
47    /// Injected, but no confirming assistant turn appeared within the budget.
48    NotDelivered,
49}
50
51impl std::fmt::Display for DriveError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            DriveError::UnsafeText => write!(f, "refused: text contains a detach sentinel"),
55            DriveError::Attach(e) => write!(f, "attach failed: {e}"),
56            DriveError::Io(s) => write!(f, "io error: {s}"),
57            DriveError::NotDelivered => {
58                write!(f, "injected but no confirming assistant turn appeared")
59            }
60        }
61    }
62}
63
64impl std::error::Error for DriveError {}
65
66/// The Claude projects (transcript) base dir.
67pub fn claude_projects_dir() -> PathBuf {
68    if let Some(v) = std::env::var_os(PROJECTS_DIR_ENV) {
69        return PathBuf::from(v);
70    }
71    let home = std::env::var_os("HOME")
72        .map(PathBuf::from)
73        .unwrap_or_else(|| PathBuf::from("."));
74    home.join(".claude").join("projects")
75}
76
77/// Loose `8-4-4-4-12` hex session-uuid check (the transcript filename shape).
78fn is_session_uuid(s: &str) -> bool {
79    let parts: Vec<&str> = s.split('-').collect();
80    parts.len() == 5
81        && [8, 4, 4, 4, 12]
82            .iter()
83            .zip(&parts)
84            .all(|(&n, p)| p.len() == n && p.bytes().all(|b| b.is_ascii_hexdigit()))
85}
86
87/// Locate a session's transcript JSONL by its full uuid, across all project dirs.
88/// Mirrors `doctor.py`: the filename is the uuid, so we never need the lossy
89/// cwd-encoding. Returns `None` for a malformed uuid or when no transcript exists.
90pub fn find_transcript(session_uuid: &str) -> Option<PathBuf> {
91    if !is_session_uuid(session_uuid) {
92        return None;
93    }
94    let base = claude_projects_dir();
95    let entries = std::fs::read_dir(&base).ok()?;
96    for entry in entries.flatten() {
97        let candidate = entry.path().join(format!("{session_uuid}.jsonl"));
98        if candidate.exists() {
99            return Some(candidate);
100        }
101    }
102    None
103}
104
105/// True if `text` contains any detach sentinel.
106pub fn contains_detach_sentinel(text: &str) -> bool {
107    DETACH_SENTINELS.iter().any(|s| text.contains(s))
108}
109
110/// The sender (and optional recipient) of an `<fno_mail>` agent-to-agent
111/// envelope. Rendered as a PAIRED, lowercase tag with `key="value"` attributes,
112/// matching the universal convention (snake_case tag name, double-quoted attrs,
113/// open/close pair, no data in the tag name) so an adopted session treats it as
114/// structure natively.
115///
116/// Field rule: a field belongs in the TAG only if the recipient needs it AT
117/// MESSAGE TIME and cannot cheaply look it up by `from`; everything else lives in
118/// the registry, keyed by `from` (so cwd / log_path / pid / lineage are NOT tag
119/// fields). `from` is the SHORT 8-hex sessionId of the sender -- the identity,
120/// since sessionIds ARE names (no display name; the registry row + claim key on
121/// the FULL session_uuid underneath, the tag uses the short purely for
122/// legibility). Legible context, NOT unforgeable trust: the escaped/unforgeable
123/// form is deferred until a parser actually makes a trust decision on `from`.
124pub struct FnoMail<'a> {
125    /// REQUIRED. The sender's short 8-hex sessionId (identity).
126    pub from: &'a str,
127    /// Sender harness: `claude-code` / `codex` / `gemini` (how to reply).
128    pub harness: &'a str,
129    /// Sender model id (context).
130    pub model: &'a str,
131    /// OPTIONAL. The backlog node the sender is working on (e.g. `x-33b2`) --
132    /// the highest-value coordination context; omitted for node-less sessions.
133    pub node: Option<&'a str>,
134    /// OPTIONAL. A peer's short sessionId, only when the turn is directed at a
135    /// specific peer (omitted on broadcast/bus delivery).
136    pub to: Option<&'a str>,
137    /// OPTIONAL. This message's OWN bus msg-id (`msg-XXXXXX`), minted once by the
138    /// sender before wrapping and reused in the durable write. Additive,
139    /// last-but-one (immediately before `reply_to`); lets a recipient reply to a
140    /// live-injected message that wrote no durable thread. Omitted when absent so
141    /// a plain send stays byte-identical.
142    pub id: Option<&'a str>,
143    /// OPTIONAL. The bus msg-id this envelope answers (name-lane reply
144    /// correlation). Additive, last in attribute order; runtime-stamped from
145    /// `fno mail reply --to <msg-id>`, never agent-authored.
146    pub reply_to: Option<&'a str>,
147}
148
149/// Render the `<fno_mail ...>` open tag with double-quoted attributes:
150/// `<fno_mail from="..." harness="..." model="..."[ node="..."][ to="..."][ id="..."][ reply_to="..."]>`.
151pub fn fno_mail_open(m: &FnoMail) -> String {
152    let mut s = format!(
153        "<fno_mail from=\"{}\" harness=\"{}\" model=\"{}\"",
154        m.from, m.harness, m.model
155    );
156    if let Some(node) = m.node {
157        s.push_str(&format!(" node=\"{node}\""));
158    }
159    if let Some(to) = m.to {
160        s.push_str(&format!(" to=\"{to}\""));
161    }
162    if let Some(id) = m.id {
163        s.push_str(&format!(" id=\"{id}\""));
164    }
165    if let Some(reply_to) = m.reply_to {
166        s.push_str(&format!(" reply_to=\"{reply_to}\""));
167    }
168    s.push('>');
169    s
170}
171
172/// Wrap `text` in the paired `<fno_mail>` envelope:
173/// `<fno_mail ...>\n{text}\n</fno_mail>`.
174pub fn wrap_fno_mail(m: &FnoMail, text: &str) -> String {
175    format!("{}\n{}\n</fno_mail>", fno_mail_open(m), text)
176}
177
178/// Build the `op:'reply'` inject line. When `mail` is set, the turn text is
179/// wrapped in the paired `<fno_mail>` envelope so the recipient sees it as
180/// agent-to-agent structure, not a human typing. Refuses text carrying a detach
181/// sentinel. `auth` is omitted on the same-uid no-auth path.
182///
183/// This is the reusable LIVE-DELIVERY primitive: `fno mail send` calls it to
184/// inject into a live recipient first, falling back to the durable bus queue only
185/// when the recipient is not live-reachable (that unification is a follow-up; here
186/// we just build the clean callable inject path).
187pub fn build_reply_request(
188    short: &str,
189    text: &str,
190    auth: Option<&str>,
191    mail: Option<&FnoMail>,
192) -> Result<String, DriveError> {
193    if contains_detach_sentinel(text) {
194        return Err(DriveError::UnsafeText);
195    }
196    let body = match mail {
197        Some(m) => wrap_fno_mail(m, text),
198        None => text.to_string(),
199    };
200    let mut obj = serde_json::Map::new();
201    obj.insert("op".into(), "reply".into());
202    obj.insert("short".into(), short.into());
203    obj.insert("text".into(), body.into());
204    if let Some(a) = auth {
205        obj.insert("auth".into(), a.into());
206    }
207    let mut line = serde_json::Value::Object(obj).to_string();
208    line.push('\n');
209    Ok(line)
210}
211
212/// Inject a turn over `t` via `op:'reply'`, wrapped as `<fno_mail>` from `mail`.
213/// Writing succeeded does NOT mean the turn landed -- confirm with
214/// [`confirm_marker_after`] against the transcript.
215pub fn inject_reply<T: ControlTransport>(
216    t: &mut T,
217    short: &str,
218    text: &str,
219    auth: Option<&str>,
220    mail: Option<&FnoMail>,
221) -> Result<(), DriveError> {
222    let line = build_reply_request(short, text, auth, mail)?;
223    t.send_line(&line)
224        .map_err(|e| DriveError::Io(e.to_string()))
225}
226
227/// Current byte length of `path` (the baseline to read new transcript lines from),
228/// or 0 if absent/unreadable.
229pub fn transcript_len(path: &Path) -> u64 {
230    std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
231}
232
233/// Scan transcript lines appended after `since_byte` for an ASSISTANT turn
234/// containing `marker`. The injected user turn also carries the marker, so we
235/// only count assistant-role records -- that is what proves the model ACTED on
236/// the inject, not that the socket echoed our text. `[corroborated]` (spike's
237/// confirm rule)
238pub fn confirm_marker_after(path: &Path, marker: &str, since_byte: u64) -> io::Result<bool> {
239    let mut file = std::fs::File::open(path)?;
240    use std::io::Seek;
241    file.seek(io::SeekFrom::Start(since_byte))?;
242    let reader = io::BufReader::new(file);
243    for line in reader.lines() {
244        let line = line?;
245        if line.contains(marker) && line_is_assistant(&line) {
246            return Ok(true);
247        }
248    }
249    Ok(false)
250}
251
252/// True if a transcript JSONL line is an assistant-role record. Claude's
253/// transcript tags turns by a top-level `type` and/or a nested `message.role`;
254/// accept either so a schema tweak does not silently break confirmation.
255fn line_is_assistant(line: &str) -> bool {
256    let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
257        return false;
258    };
259    if v.get("type").and_then(serde_json::Value::as_str) == Some("assistant") {
260        return true;
261    }
262    v.get("message")
263        .and_then(|m| m.get("role"))
264        .and_then(serde_json::Value::as_str)
265        == Some("assistant")
266}
267
268/// One a2a turn to drive: the `text` (which must embed `marker` for the transcript
269/// confirm), and the `mail` identity that becomes the paired `<fno_mail>` envelope.
270pub struct DriveTurn<'a> {
271    pub text: &'a str,
272    pub marker: &'a str,
273    pub mail: Option<&'a FnoMail<'a>>,
274}
275
276/// Drive one turn end to end and confirm delivery: attach, baseline the
277/// transcript, inject `turn.text` tagged as a2a, then poll the transcript for a
278/// confirming assistant turn until `attempts` * `interval` elapses. Live glue
279/// (real socket + real transcript); the unit-tested pieces are
280/// [`build_reply_request`], [`confirm_marker_after`], [`find_transcript`].
281///
282/// ponytail: the poll loop sleeps for real; it is not unit-tested. Every decision
283/// it makes is a tested function.
284pub fn drive_and_confirm<T: ControlTransport>(
285    transport: &mut T,
286    attach: &AttachRequest,
287    session_uuid: &str,
288    turn: &DriveTurn,
289    attempts: u32,
290    interval: std::time::Duration,
291) -> Result<(), DriveError> {
292    perform_attach(transport, attach).map_err(DriveError::Attach)?;
293
294    let transcript = find_transcript(session_uuid)
295        .ok_or_else(|| DriveError::Io(format!("no transcript for session {session_uuid}")))?;
296    let baseline = transcript_len(&transcript);
297
298    inject_reply(
299        transport,
300        &attach.short,
301        turn.text,
302        attach.auth.as_deref(),
303        turn.mail,
304    )?;
305
306    for _ in 0..attempts.max(1) {
307        if confirm_marker_after(&transcript, turn.marker, baseline)
308            .map_err(|e| DriveError::Io(e.to_string()))?
309        {
310            return Ok(());
311        }
312        std::thread::sleep(interval);
313    }
314    Err(DriveError::NotDelivered)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::io::Write;
321
322    struct Fake {
323        sent: Vec<String>,
324    }
325    impl ControlTransport for Fake {
326        fn send_line(&mut self, line: &str) -> io::Result<()> {
327            self.sent.push(line.to_string());
328            Ok(())
329        }
330        fn recv_line(&mut self) -> io::Result<Option<String>> {
331            Ok(None)
332        }
333    }
334
335    fn tmpdir(tag: &str) -> PathBuf {
336        let p = std::env::temp_dir().join(format!(
337            "fno-drive-{}-{}-{}",
338            tag,
339            std::process::id(),
340            std::time::SystemTime::now()
341                .duration_since(std::time::UNIX_EPOCH)
342                .unwrap()
343                .as_nanos()
344        ));
345        std::fs::create_dir_all(&p).unwrap();
346        p
347    }
348
349    const UUID: &str = "a1b2c3d4-1111-2222-3333-444455556666";
350
351    #[test]
352    fn is_session_uuid_validates_shape() {
353        assert!(is_session_uuid(UUID));
354        assert!(!is_session_uuid("a1b2c3d4")); // short id, not a uuid
355        assert!(!is_session_uuid("not-a-uuid"));
356        assert!(!is_session_uuid("g1b2c3d4-1111-2222-3333-444455556666")); // non-hex
357    }
358
359    fn from_orchestrator() -> FnoMail<'static> {
360        FnoMail {
361            from: "7d1f8bdc",
362            harness: "claude-code",
363            model: "opus-4.8",
364            node: Some("x-26df"),
365            to: None,
366            id: None,
367            reply_to: None,
368        }
369    }
370
371    #[test]
372    fn fno_mail_open_is_lowercase_quoted_attrs() {
373        // Lowercase <fno_mail ...> with key="value" double-quoted attrs; from is
374        // the SHORT 8-hex sessionId; node included when present.
375        assert_eq!(
376            fno_mail_open(&from_orchestrator()),
377            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">"
378        );
379        // node omitted for a node-less sender; to included when directed.
380        let directed = FnoMail {
381            from: "7d1f8bdc",
382            harness: "claude-code",
383            model: "opus-4.8",
384            node: None,
385            to: Some("claude-ee99ff00"),
386            id: None,
387            reply_to: None,
388        };
389        assert_eq!(
390            fno_mail_open(&directed),
391            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-ee99ff00\">"
392        );
393    }
394
395    #[test]
396    fn fno_mail_open_renders_reply_to_last_when_present() {
397        // reply_to is additive and LAST in attribute order; a name-lane reply
398        // carries the answered msg-id inline. Parity-pinned by the Python
399        // `test_fno_mail_envelope.py` reply_to case.
400        let reply = FnoMail {
401            from: "7d1f8bdc",
402            harness: "claude-code",
403            model: "opus-4.8",
404            node: None,
405            to: Some("claude-e5f6a7b8"),
406            id: None,
407            reply_to: Some("msg-0091f3"),
408        };
409        assert_eq!(
410            fno_mail_open(&reply),
411            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-e5f6a7b8\" reply_to=\"msg-0091f3\">"
412        );
413    }
414
415    #[test]
416    fn fno_mail_open_renders_id_last_but_one_before_reply_to() {
417        // US1: `id` is the message's OWN msg-id, additive and positioned
418        // last-but-one (immediately before reply_to). Parity-pinned by the Python
419        // `test_fno_mail_envelope.py` id cases.
420        let with_id = FnoMail {
421            from: "7d1f8bdc",
422            harness: "claude-code",
423            model: "opus-4.8",
424            node: None,
425            to: Some("claude-e5f6a7b8"),
426            id: Some("msg-abc123"),
427            reply_to: Some("msg-0091f3"),
428        };
429        assert_eq!(
430            fno_mail_open(&with_id),
431            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-e5f6a7b8\" id=\"msg-abc123\" reply_to=\"msg-0091f3\">"
432        );
433        // A fresh send carries its own id but no reply_to.
434        let fresh = FnoMail {
435            from: "7d1f8bdc",
436            harness: "claude-code",
437            model: "opus-4.8",
438            node: None,
439            to: None,
440            id: Some("msg-abc123"),
441            reply_to: None,
442        };
443        assert_eq!(
444            fno_mail_open(&fresh),
445            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" id=\"msg-abc123\">"
446        );
447    }
448
449    #[test]
450    fn wrap_fno_mail_is_a_paired_envelope() {
451        let wrapped = wrap_fno_mail(&from_orchestrator(), "ship it");
452        assert_eq!(
453            wrapped,
454            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">\nship it\n</fno_mail>"
455        );
456    }
457
458    #[test]
459    fn build_reply_request_untagged_when_no_mail() {
460        let line = build_reply_request("a1b2c3d4", "hello world", Some("deadbeef"), None).unwrap();
461        assert!(line.ends_with('\n'));
462        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
463        assert_eq!(v["op"], "reply");
464        assert_eq!(v["short"], "a1b2c3d4");
465        assert_eq!(v["text"], "hello world");
466        assert_eq!(v["auth"], "deadbeef");
467    }
468
469    #[test]
470    fn build_reply_request_wraps_fno_mail() {
471        let from = from_orchestrator();
472        let line = build_reply_request("a1b2c3d4", "ship it MARKER42", None, Some(&from)).unwrap();
473        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
474        let text = v["text"].as_str().unwrap();
475        // The turn is the paired <fno_mail> envelope; the marker survives inside.
476        assert_eq!(
477            text,
478            "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">\nship it MARKER42\n</fno_mail>"
479        );
480    }
481
482    #[test]
483    fn build_reply_request_omits_auth() {
484        let line = build_reply_request("a1b2c3d4", "hi", None, None).unwrap();
485        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
486        assert!(v.get("auth").is_none());
487    }
488
489    #[test]
490    fn build_reply_request_refuses_detach_sentinel() {
491        let evil = format!("hi {}", DETACH_SENTINELS[0]);
492        assert!(matches!(
493            build_reply_request("a1b2c3d4", &evil, None, None),
494            Err(DriveError::UnsafeText)
495        ));
496        assert!(matches!(
497            build_reply_request("a1b2c3d4", DETACH_SENTINELS[1], None, None),
498            Err(DriveError::UnsafeText)
499        ));
500    }
501
502    #[test]
503    fn inject_reply_writes_one_line_and_guards_sentinels() {
504        let mut t = Fake { sent: Vec::new() };
505        inject_reply(&mut t, "a1b2c3d4", "ping MARKER42", None, None).unwrap();
506        assert_eq!(t.sent.len(), 1);
507        assert!(t.sent[0].contains("\"op\":\"reply\""));
508        assert!(t.sent[0].contains("MARKER42"));
509
510        let mut t2 = Fake { sent: Vec::new() };
511        assert!(inject_reply(&mut t2, "a1b2c3d4", DETACH_SENTINELS[0], None, None).is_err());
512        assert!(t2.sent.is_empty(), "must not write unsafe text");
513    }
514
515    #[test]
516    fn find_transcript_by_uuid_across_project_dirs() {
517        let base = tmpdir("find");
518        std::env::set_var(PROJECTS_DIR_ENV, &base);
519        let proj = base.join("-Users-x-code-proj");
520        std::fs::create_dir_all(&proj).unwrap();
521        let t = proj.join(format!("{UUID}.jsonl"));
522        std::fs::write(&t, b"{}\n").unwrap();
523
524        assert_eq!(find_transcript(UUID), Some(t));
525        assert_eq!(
526            find_transcript("ffffffff-0000-0000-0000-000000000000"),
527            None
528        );
529        assert_eq!(find_transcript("bad-uuid"), None);
530        std::env::remove_var(PROJECTS_DIR_ENV);
531        std::fs::remove_dir_all(&base).ok();
532    }
533
534    #[test]
535    fn confirm_marker_after_counts_only_assistant_turns() {
536        let dir = tmpdir("confirm");
537        let path = dir.join("t.jsonl");
538        // Baseline content (pre-inject); the marker must NOT be sought here.
539        let mut f = std::fs::File::create(&path).unwrap();
540        writeln!(
541            f,
542            r#"{{"type":"user","message":{{"role":"user","content":"older"}}}}"#
543        )
544        .unwrap();
545        let baseline = transcript_len(&path);
546
547        // After inject: our own user echo carries the marker (must NOT match),
548        // then the assistant turn carrying it (must match).
549        let mut f = std::fs::OpenOptions::new()
550            .append(true)
551            .open(&path)
552            .unwrap();
553        writeln!(
554            f,
555            r#"{{"type":"user","message":{{"role":"user","content":"drive MARKER42"}}}}"#
556        )
557        .unwrap();
558        // Our own user echo carries the marker but is NOT an assistant turn -> not
559        // yet delivered.
560        assert!(!confirm_marker_after(&path, "MARKER42", baseline).unwrap());
561
562        writeln!(
563            f,
564            r#"{{"type":"assistant","message":{{"role":"assistant","content":"done MARKER42"}}}}"#
565        )
566        .unwrap();
567        assert!(confirm_marker_after(&path, "MARKER42", baseline).unwrap());
568
569        // An offset past everything sees nothing new.
570        let end = transcript_len(&path);
571        assert!(!confirm_marker_after(&path, "MARKER42", end).unwrap());
572        std::fs::remove_dir_all(&dir).ok();
573    }
574
575    #[test]
576    fn confirm_marker_absent_assistant_is_false() {
577        let dir = tmpdir("absent");
578        let path = dir.join("t.jsonl");
579        let mut f = std::fs::File::create(&path).unwrap();
580        writeln!(
581            f,
582            r#"{{"type":"assistant","message":{{"role":"assistant","content":"no token here"}}}}"#
583        )
584        .unwrap();
585        assert!(!confirm_marker_after(&path, "MARKER42", 0).unwrap());
586        std::fs::remove_dir_all(&dir).ok();
587    }
588}