sprawling 0.0.11

A local multi-agent harness for persistent agent teams: long-running project work, agent communication, and experiments in coordination and social simulation.
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
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The first screen a person sees when the binary carries no command,
//! where a city goes when nobody named one, and handing a URL to the
//! desktop's default handler.
//!
//! Launching this binary from a file manager gives it no arguments and a
//! console that closes the moment it exits. Telling that apart from a
//! person typing `sprawling` in a shell needs `GetConsoleProcessList`,
//! which needs `unsafe`, which the workspace forbids. So nothing here
//! guesses how it was started: the three ways in are each named, and they
//! meet at `up` (`crates/sprawling/spec/Firstrun.lean` ยง8-8).

use std::io::{BufRead, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;

use accounting::home::Home;

/// How long the WebUI waits for its own city. Everything a city does
/// before it binds - verifying the chain, folding the views, starting the
/// worker - happens first, and on a long history that is seconds. A city
/// slower than this is one to watch starting rather than open behind.
const PROBE_ATTEMPTS: u32 = 60;
const PROBE_INTERVAL: Duration = Duration::from_millis(200);

/// What the person answered on the first screen.
///
/// A city is created only through `Start`, so the genesis write - the one
/// irreversible act in this system - always has somebody behind it.
pub enum FirstScreen {
    Start(PathBuf),
    /// A folder the person already works in. The city forms around it
    /// and every folder inside becomes a building; no file already there
    /// is moved or rewritten, and a `.gitignore` only gains the lines
    /// that keep the city's own records out of git.
    Use(PathBuf),
    Quit,
}

/// Whether the binary's own directory takes a city.
///
/// The answer of [`writability`], and the only thing `default_city`
/// asks about that directory: a boolean parameter at this call site
/// read as `true` at one end and "writable" at the other, which is two
/// spellings of one fact.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BesideBinary {
    /// The directory accepted a file, so the city goes there.
    Writable,
    /// The directory refused one - unpacked into `Program Files`, on a
    /// read-only mount, or on a full disk.
    ReadOnly,
}

/// Where a city goes when the person named none.
///
/// Beside the binary, so the whole thing stays one folder that can be
/// moved, copied or deleted as a unit. When that directory cannot be
/// written the city goes under the home directory instead, at the path
/// `Home::default_city` states. The caller shows the result before
/// creating anything, so the fallback is visible rather than silent.
///
/// With no home to fall back to, this still answers beside the binary:
/// the screen shows that path and starting there fails with the reason,
/// which beats inventing a location nobody asked for.
#[must_use]
pub fn default_city(exe_dir: &Path, home: Option<&Home>, beside: BesideBinary) -> PathBuf {
    match (beside, home) {
        (BesideBinary::Writable, _) | (BesideBinary::ReadOnly, None) => {
            exe_dir.join(accounting::home::CITY_DIR)
        }
        (BesideBinary::ReadOnly, Some(home)) => home.default_city(),
    }
}

/// Whether a directory accepts a file today. Probes rather than reads
/// permissions: a read-only mount, an ACL and a full disk all end the
/// same way, and only writing finds out.
#[must_use]
pub fn writability(dir: &Path) -> BesideBinary {
    let probe = dir.join(WRITE_PROBE);
    match std::fs::write(&probe, b"") {
        Ok(()) => {
            // The probe is empty and its name says what it is, so a
            // removal this platform refuses leaves nothing a person has
            // to understand, and the directory is writable either way.
            match std::fs::remove_file(&probe) {
                Ok(()) | Err(_) => BesideBinary::Writable,
            }
        }
        Err(_) => BesideBinary::ReadOnly,
    }
}

/// The file `writability` writes and removes. Named after what it is,
/// because a machine that refuses the removal leaves it behind.
const WRITE_PROBE: &str = ".sprawling-write-probe";

/// Draws the first two lines of the CLI when no city is there yet, and
/// reads the one answer they ask for (`crates/sprawling/spec/Firstrun.lean` ยง8-8).
///
/// The path is on the screen before the question, so nobody consents to a
/// location they were not shown. End of input is `Quit`: a piped or
/// unattended stdin has nobody to ask, and creating a city would be
/// acting on silence.
///
/// # Errors
/// Propagates what the terminal reports: a screen nobody can be shown,
/// or an answer that cannot be read, is not an answer to guess at.
pub fn ask<R: BufRead, W: Write>(
    city: &Path,
    input: &mut R,
    out: &mut W,
) -> std::io::Result<FirstScreen> {
    writeln!(out, "sprawling  no city yet")?;
    writeln!(
        out,
        "start one at {}?   Enter yes   type a folder to use it   q quit",
        city.display()
    )?;
    writeln!(
        out,
        "  a folder you type becomes a city around your work, and your files stay as they are"
    )?;
    write!(out, "> ")?;
    out.flush()?;

    let mut answer = String::new();
    let read = input.read_line(&mut answer)?;
    if read == 0 {
        return Ok(FirstScreen::Quit);
    }
    let answer = answer.trim();
    if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
        return Ok(FirstScreen::Start(city.to_path_buf()));
    }
    if answer.eq_ignore_ascii_case("q") || answer.eq_ignore_ascii_case("quit") {
        return Ok(FirstScreen::Quit);
    }
    // Anything else is a path. Whether it exists is the caller's to
    // check and to report: a screen that guessed would either refuse a
    // real folder over a typo in its own reading, or create one where
    // somebody meant to point at work they already had.
    Ok(FirstScreen::Use(PathBuf::from(strip_quotes(answer))))
}

/// A path pasted from a file manager arrives wrapped in quotes on every
/// platform that has spaces in its directory names, which is all of
/// them.
fn strip_quotes(answer: &str) -> &str {
    answer
        .strip_prefix('"')
        .and_then(|rest| rest.strip_suffix('"'))
        .or_else(|| {
            answer
                .strip_prefix('\'')
                .and_then(|rest| rest.strip_suffix('\''))
        })
        .unwrap_or(answer)
}

/// The address something on this machine can actually connect to. A city
/// bound to every interface is still opened from the browser in front of
/// it, and `0.0.0.0` is not an address any client can dial.
fn reachable(bind: SocketAddr) -> SocketAddr {
    if bind.ip().is_unspecified() {
        let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
        return SocketAddr::new(loopback, bind.port());
    }
    bind
}

/// Opens the WebUI once the city is really listening, on a thread of its
/// own so serving is not held up by a browser starting.
///
/// Waits for the port to accept rather than guessing when the bind will
/// happen, and gives up in silence when it never does - the URL was
/// printed before this started, so nothing is lost but the convenience.
pub fn open_when_ready(bind: SocketAddr, url: String, door: wire::LocalDoor) {
    let addr = reachable(bind);
    std::thread::spawn(move || {
        for _ in 0..PROBE_ATTEMPTS {
            if std::net::TcpStream::connect_timeout(&addr, PROBE_INTERVAL).is_ok() {
                // Never fatal: a machine with no handler for URLs still
                // has a working city, and its address is on the screen.
                if let Err(unopened) = open_paired(&url, &door) {
                    eprintln!("  {}: {}", unopened.action(), unopened.recovery());
                }
                return;
            }
            std::thread::sleep(PROBE_INTERVAL);
        }
    });
}

/// Opens `url` in this desktop's browser already paired: an open code
/// from `door` goes into a page only this account can read, and the
/// browser is handed that file's path, never the code
/// (`crates/sprawling/spec/Firstrun.lean` ยง8-8). The file is removed once
/// the page redeems the code, or once the code expires.
///
/// Under WSL a `file:` page cannot reach the Windows browser, so the bare
/// address opens and the pairing code on the terminal pairs it.
///
/// # Errors
/// No open code could be minted, the redirect file could not be written,
/// or the desktop's handler could not be started.
pub(crate) fn open_paired(url: &str, door: &wire::LocalDoor) -> Result<(), kernel::AxError> {
    let unhandled = |source: std::io::Error| {
        kernel::AxError::failure(
            kernel::AxCode::ToolUnavailable,
            "open the WebUI in a browser",
            source.to_string(),
        )
        .with_recovery("open the address the city printed, and type the pairing code it shows")
    };
    if std::env::var_os("WSL_DISTRO_NAME").is_some() {
        return open_in_browser(url, unhandled);
    }
    let wire::OpenCode { code, redeemed } = door.issue_open_code()?;
    let page = crate::serving::key_file::runtime_dir()?.join(format!(
        "{REDIRECT_PREFIX}{}{REDIRECT_SUFFIX}",
        std::process::id()
    ));
    let target = format!("{}/#open={code}", url.trim_end_matches('/'));
    let redirect = format!(
        "<!doctype html><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url={target}\">\
         <script>location.replace({target:?})</script>\n"
    );
    write_private(&page, &redirect).map_err(unhandled)?;
    let opened = open_in_browser(&page.to_string_lossy(), unhandled);
    std::thread::spawn(move || {
        // Every answer ends the wait the same way - redeemed, expired, or
        // the door gone - so which one it was is owed to nobody.
        let _waited: Result<(), std::sync::mpsc::RecvTimeoutError> =
            redeemed.recv_timeout(Duration::from_millis(wire::OPEN_CODE_LIFETIME_MS));
        if let Err(left) = std::fs::remove_file(&page) {
            eprintln!(
                "  remove the browser's redirect page {}: {left}",
                page.display()
            );
        }
    });
    opened
}

/// A new file only this account reads, in the runtime directory made
/// private to it.
fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.mode(0o600);
    }
    options.open(path)?.write_all(text.as_bytes())
}

/// What the redirect pages `open_paired` writes are named: the prefix,
/// this process's id, the suffix. The sweep at start reads the same two.
const REDIRECT_PREFIX: &str = "open-";
const REDIRECT_SUFFIX: &str = ".html";

/// Removes the redirect pages a city ended by force left behind: every
/// `open-*.html` in the runtime directory last written more than an open
/// code's lifetime before `now`, so its code has expired. A younger page
/// stays, because another city of this account may have just written it
/// for a browser that has not read it yet.
///
/// # Errors
/// The runtime directory cannot be found or listed, or an expired page
/// cannot be removed.
pub fn sweep_expired_redirects(now: kernel::TimeMs) -> Result<(), kernel::AxError> {
    let dir = crate::serving::key_file::runtime_dir()?;
    let unswept = |source: std::io::Error| {
        kernel::AxError::failure(
            kernel::AxCode::StorageFatal,
            "remove the expired browser redirect pages",
            format!("{}: {source}", dir.display()),
        )
        .with_recovery("delete the open-*.html files in that directory; their codes have expired")
    };
    let age = Duration::from_millis(now.value())
        .saturating_sub(Duration::from_millis(wire::OPEN_CODE_LIFETIME_MS));
    // Past the end of the platform's time, no page can be older.
    let Some(cutoff) = std::time::UNIX_EPOCH.checked_add(age) else {
        return Ok(());
    };
    for entry in std::fs::read_dir(&dir).map_err(unswept)? {
        let entry = entry.map_err(unswept)?;
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if !(name.starts_with(REDIRECT_PREFIX) && name.ends_with(REDIRECT_SUFFIX)) {
            continue;
        }
        let written = entry
            .metadata()
            .and_then(|m| m.modified())
            .map_err(unswept)?;
        if written < cutoff {
            std::fs::remove_file(entry.path()).map_err(unswept)?;
        }
    }
    Ok(())
}

/// Hands a URL or a file path to whatever this desktop opens them with,
/// started as every other child of the city is (`child::command`): none
/// of the city's secrets in its environment, which the browser it starts
/// inherits and outlives the city with, and no standard stream of the
/// city's, so nothing it prints lands on the CLI or the quiet host.
///
/// # Errors
/// The handler could not be found or started, in `unhandled`'s words.
/// Callers treat this as a notice rather than a fault: the URL is
/// printed before this runs, so a machine with no handler still has a
/// working city.
fn open_in_browser(
    target: &str,
    unhandled: impl Fn(std::io::Error) -> kernel::AxError,
) -> Result<(), kernel::AxError> {
    handler(target)?
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|_| ())
        .map_err(unhandled)
}

/// `cmd.exe` from `System32`, never one the search order finds first in
/// this binary's folder (`crates/sprawling/spec/Firstrun.lean` ยง8-8).
#[cfg(target_os = "windows")]
fn handler(target: &str) -> Result<std::process::Command, kernel::AxError> {
    let mut command = child::command(crate::privacy::windows::system32("cmd.exe")?);
    // The empty argument is `start`'s title slot: without it a quoted URL
    // becomes the window title and nothing opens.
    command.args(["/C", "start", "", target]);
    Ok(command)
}

#[cfg(target_os = "macos")]
fn handler(target: &str) -> Result<std::process::Command, kernel::AxError> {
    let mut command = child::command("open");
    command.arg(target);
    Ok(command)
}

#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn handler(target: &str) -> Result<std::process::Command, kernel::AxError> {
    let mut command = child::command("xdg-open");
    command.arg(target);
    Ok(command)
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::wildcard_enum_match_arm,
    clippy::let_underscore_must_use,
    clippy::let_underscore_untyped,
    reason = "test code"
)]
mod tests {
    use super::{BesideBinary, FirstScreen, Home, ask, default_city};
    use std::path::{Path, PathBuf};

    fn screen(answer: &str) -> (FirstScreen, String) {
        let city = PathBuf::from("/tmp/here/city");
        let mut input = answer.as_bytes();
        let mut out: Vec<u8> = Vec::new();
        let outcome = ask(&city, &mut input, &mut out).unwrap();
        (outcome, String::from_utf8(out).unwrap())
    }

    /// Stand-ins for a binary's own directory and for the fallback root;
    /// neither is a real path on any machine.
    const UNPACKED: &str = "/unpacked";
    const ELSEWHERE: &str = "/elsewhere";

    /// A home that is nowhere real: these tests compare paths and
    /// never touch a disk.
    fn elsewhere() -> Home {
        Home::at(Path::new(ELSEWHERE))
    }

    #[test]
    fn city_goes_beside_the_binary_when_that_directory_is_writable() {
        let beside = default_city(
            Path::new(UNPACKED),
            Some(&elsewhere()),
            BesideBinary::Writable,
        );
        assert_eq!(beside, PathBuf::from("/unpacked/city"));
    }

    /// The fallback is the home's own answer rather than a second
    /// spelling of it, so a city under the home directory has one
    /// definition (`accounting::home::Home::default_city`).
    #[test]
    fn city_falls_back_when_the_binary_directory_is_read_only() {
        let fallback = default_city(
            Path::new(UNPACKED),
            Some(&elsewhere()),
            BesideBinary::ReadOnly,
        );
        assert_eq!(fallback, elsewhere().default_city());
    }

    /// With nowhere to fall back to, the answer stays beside the binary
    /// rather than becoming a location nobody asked for.
    #[test]
    fn with_no_fallback_the_answer_is_still_beside_the_binary() {
        let beside = default_city(Path::new(UNPACKED), None, BesideBinary::ReadOnly);
        assert_eq!(beside, PathBuf::from("/unpacked/city"));
    }

    #[test]
    fn the_path_is_on_the_screen_before_the_question_is_answered() {
        let (_, drawn) = screen("\n");
        assert!(drawn.contains("/tmp/here/city"), "drawn: {drawn}");
    }

    #[test]
    fn enter_starts_the_city_at_the_path_the_screen_showed() {
        let (outcome, _) = screen("\n");
        match outcome {
            FirstScreen::Start(path) => assert_eq!(path, PathBuf::from("/tmp/here/city")),
            _ => panic!("Enter starts the city"),
        }
    }

    /// The case a person with a workspace is in: they paste the folder
    /// they already work in, and the city forms around it.
    #[test]
    fn a_pasted_path_is_a_folder_to_use_rather_than_a_place_to_start() {
        let (outcome, drawn) = screen("/tmp/already/work\n");
        match outcome {
            FirstScreen::Use(path) => assert_eq!(path, PathBuf::from("/tmp/already/work")),
            _ => panic!("a path is a folder to use"),
        }
        assert!(
            drawn.contains("your files stay as they are"),
            "the screen has to say what will happen to their work: {drawn}"
        );
    }

    /// A file manager wraps a pasted path in quotes the moment it has a
    /// space in it, which is most of them.
    #[test]
    fn a_pasted_path_keeps_its_spaces_and_loses_its_quotes() {
        let (outcome, _) = screen("\"/tmp/already/my work\"\n");
        match outcome {
            FirstScreen::Use(path) => assert_eq!(path, PathBuf::from("/tmp/already/my work")),
            _ => panic!("a quoted path is still a path"),
        }
    }

    #[test]
    fn q_quits_without_creating_anything() {
        let (outcome, _) = screen("q\n");
        assert!(matches!(outcome, FirstScreen::Quit));
    }

    #[test]
    fn end_of_input_quits_rather_than_acting_on_silence() {
        let (outcome, _) = screen("");
        assert!(matches!(outcome, FirstScreen::Quit));
    }
}