OXYGENHUB

The 0.1 tools: brief, rules, agents, status, say, read, inbox, wait with the idle guard, ack, search, redact; every call on the record (#8) 037c45d8093c

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 45aeea9f6e11
When
· 2026-11-02 22:52 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
037c45d8093ce0497ee2668e514ab337385ed86b558416eb2b48ae1d79257019
Depends on
09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 5eb57578f4ef
Depended on by
b044d90930d9 98a0fb9f0948 77df591d5a2f b576d05cf862 39f5c913f617 eb675dfa5e18
In bookmarks
main
In tags
v0.1.0
Edits
48 atoms, 6 tree operations, 18 files

Browse the tree at this change

added crates/spectra-core/migrations/0005_rules_tools.sql +21 −0
1 +
-- The rules version each agent last read (spec §12.3), and the tool calls
2 +
-- each agent made (spec §9.2), for the agent pages and the idle guard.
3 +
CREATE TABLE rules_read (
4 +
    agent    TEXT PRIMARY KEY,
5 +
    version  TEXT NOT NULL,
6 +
    at_ms    INTEGER NOT NULL
7 +
);
8 +
9 +
CREATE TABLE tool_calls (
10 +
    seq       INTEGER PRIMARY KEY,
11 +
    agent     TEXT NOT NULL,
12 +
    tool      TEXT NOT NULL,
13 +
    args      TEXT NOT NULL,     -- BLAKE3 of the arguments, kept under attachments/
14 +
    result    TEXT NOT NULL,     -- BLAKE3 of the result
15 +
    ms        INTEGER NOT NULL,
16 +
    is_error  INTEGER NOT NULL,
17 +
    read      INTEGER NOT NULL,
18 +
    session   TEXT,
19 +
    at_ms     INTEGER NOT NULL
20 +
);
21 +
CREATE INDEX tool_calls_agent ON tool_calls(agent, seq);
added crates/spectra-core/src/attachments.rs +26 −0
1 +
//! Bytes kept beside the ledger, named by their BLAKE3 (spec §7.2): tool
2 +
//! call arguments and results in full, so the audit is never truncated;
3 +
//! retention drops old bodies and keeps the hashes (spec §22.1).
4 +
5 +
use anyhow::Result;
6 +
use std::path::Path;
7 +
8 +
/// Store `bytes` under `<data>/attachments/` and return their hash (hex).
9 +
pub fn put(data: &Path, bytes: &[u8]) -> Result<String> {
10 +
    let hash = blake3::hash(bytes).to_hex().to_string();
11 +
    let dir = data.join("attachments").join(&hash[..2]);
12 +
    let path = dir.join(&hash);
13 +
    if !path.exists() {
14 +
        std::fs::create_dir_all(&dir)?;
15 +
        oxy_core::durable::write_atomic(&path, bytes)?;
16 +
    }
17 +
    Ok(hash)
18 +
}
19 +
20 +
/// The bytes stored under `hash`, if they are still kept.
21 +
pub fn get(data: &Path, hash: &str) -> Option<Vec<u8>> {
22 +
    if hash.len() < 3 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
23 +
        return None;
24 +
    }
25 +
    std::fs::read(data.join("attachments").join(&hash[..2]).join(hash)).ok()
26 +
}
added crates/spectra-core/src/rules.rs +105 −0
1 +
//! Beacon (spec §12): the house rules an agent receives, compiled from the
2 +
//! layers that exist, each under a heading naming it, with no other change;
3 +
//! the version is the BLAKE3 of that text. 0.1 has the site layer; kind,
4 +
//! project and agent layers, and committing them to the ledger, are 0.3.
5 +
6 +
use crate::event::Event;
7 +
use anyhow::{bail, Result};
8 +
use std::path::Path;
9 +
10 +
/// A rule file over this is refused (spec §12.2).
11 +
pub const MAX_RULES: usize = 64 * 1024;
12 +
13 +
/// Compiled rules and their version.
14 +
#[derive(Clone, Debug, PartialEq, Eq)]
15 +
pub struct Rules {
16 +
    pub text: String,
17 +
    /// The first 12 hex characters of BLAKE3(text).
18 +
    pub version: String,
19 +
}
20 +
21 +
/// The rules for an agent, from `<data>/rules/`.
22 +
pub fn compile(data: &Path) -> Result<Rules> {
23 +
    let mut text = String::new();
24 +
    let site = data.join("rules").join("site.md");
25 +
    if let Ok(body) = std::fs::read_to_string(&site) {
26 +
        if body.len() > MAX_RULES {
27 +
            bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display());
28 +
        }
29 +
        text.push_str("# House rules: site (rules/site.md)\n\n");
30 +
        text.push_str(body.trim_end());
31 +
        text.push('\n');
32 +
    }
33 +
    let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string();
34 +
    Ok(Rules { text, version })
35 +
}
36 +
37 +
/// The version `agent` last read, if any.
38 +
pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> {
39 +
    sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await
40 +
}
41 +
42 +
/// The line every result begins with while an agent works on stale rules.
43 +
pub fn changed_line(read: &str, now: &str) -> String {
44 +
    format!("House rules changed ({read} → {now}): call rules before continuing.")
45 +
}
46 +
47 +
/// Apply `rules.read` and `tool.called` to their tables.
48 +
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
49 +
    let p = e.json();
50 +
    let agent = e.actor.strip_prefix("agent:");
51 +
    match e.kind.as_str() {
52 +
        "rules.read" => {
53 +
            if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) {
54 +
                sqlx::query("INSERT INTO rules_read (agent, version, at_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET version = excluded.version, at_ms = excluded.at_ms")
55 +
                    .bind(a)
56 +
                    .bind(v)
57 +
                    .bind(e.at_ms as i64)
58 +
                    .execute(&mut *conn)
59 +
                    .await?;
60 +
            }
61 +
        }
62 +
        "tool.called" => {
63 +
            let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from);
64 +
            if let (Some(a), Some(tool)) = (agent, s("tool")) {
65 +
                sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
66 +
                    .bind(e.seq as i64)
67 +
                    .bind(a)
68 +
                    .bind(tool)
69 +
                    .bind(s("args").unwrap_or_default())
70 +
                    .bind(s("result").unwrap_or_default())
71 +
                    .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0))
72 +
                    .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false))
73 +
                    .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false))
74 +
                    .bind(&e.session)
75 +
                    .bind(e.at_ms as i64)
76 +
                    .execute(&mut *conn)
77 +
                    .await?;
78 +
            }
79 +
        }
80 +
        _ => {}
81 +
    }
82 +
    Ok(())
83 +
}
84 +
85 +
#[cfg(test)]
86 +
mod tests {
87 +
    use super::*;
88 +
89 +
    #[test]
90 +
    fn the_version_follows_the_text_exactly() {
91 +
        let dir = tempfile::tempdir().unwrap();
92 +
        let empty = compile(dir.path()).unwrap();
93 +
        assert_eq!(empty.text, "");
94 +
        std::fs::create_dir_all(dir.path().join("rules")).unwrap();
95 +
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap();
96 +
        let a = compile(dir.path()).unwrap();
97 +
        assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site"));
98 +
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap();
99 +
        let b = compile(dir.path()).unwrap();
100 +
        assert_ne!(a.version, b.version);
101 +
        assert_eq!(a.version.len(), 12);
102 +
        std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap();
103 +
        assert!(compile(dir.path()).is_err());
104 +
    }
105 +
}
added tests/common/mod.rs +93 −0
1 +
//! Helpers for end-to-end tests: a hermetic environment, the owner's
2 +
//! delegation as `oxy identity delegate add` makes it, agents added through
3 +
//! the CLI, and the daemon started and stopped as a real process.
4 +
#![allow(dead_code)]
5 +
6 +
use oxy_core::durable::{write_atomic, write_atomic_private};
7 +
use oxy_core::identity::{Delegate, SecretKey};
8 +
use spectra_core::keys::KeyPair;
9 +
use spectra_core::principals::DELEGATION_SCOPE;
10 +
use std::path::{Path, PathBuf};
11 +
use std::process::{Child, Command, Output};
12 +
13 +
pub fn hermetic(dir: &Path) -> Command {
14 +
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
15 +
    c.env("SPECTRA_DATA", dir.join("data"))
16 +
        .env("OXY_HOME", dir.join("owner"))
17 +
        .env("HOME", dir)
18 +
        .env("XDG_DATA_HOME", dir.join("xdg"))
19 +
        .env("NO_COLOR", "1");
20 +
    c
21 +
}
22 +
23 +
pub fn run(dir: &Path, args: &[&str]) -> Output {
24 +
    hermetic(dir).args(args).output().unwrap()
25 +
}
26 +
27 +
pub fn text(o: &Output) -> String {
28 +
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
29 +
}
30 +
31 +
/// `oxy identity delegate add` in the owner's home.
32 +
pub fn delegate(owner_home: &Path, agent: &KeyPair) {
33 +
    let cur = KeyPair::load_home(owner_home).unwrap();
34 +
    let keys = owner_home.join("keys");
35 +
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
36 +
    let mut d = cur.identity.delegates.clone();
37 +
    d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None });
38 +
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
39 +
    let k = next.hash();
40 +
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
41 +
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
42 +
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
43 +
}
44 +
45 +
/// Make an agent's home, delegate to it, and add it.
46 +
pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair {
47 +
    let home = d.join("agents").join(name);
48 +
    let pair = KeyPair::create_home(&home, name, "").unwrap();
49 +
    delegate(&d.join("owner"), &pair);
50 +
    let mut args =
51 +
        vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"];
52 +
    args.extend_from_slice(extra);
53 +
    let o = run(d, &args);
54 +
    assert!(o.status.success(), "{}", text(&o));
55 +
    pair
56 +
}
57 +
58 +
pub struct Serving {
59 +
    pub child: Child,
60 +
    pub socket: PathBuf,
61 +
}
62 +
63 +
impl Serving {
64 +
    pub fn start(d: &Path) -> Serving {
65 +
        let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap();
66 +
        let socket = d.join("data/spectra.sock");
67 +
        for _ in 0..200 {
68 +
            if std::os::unix::net::UnixStream::connect(&socket).is_ok() {
69 +
                return Serving { child, socket };
70 +
            }
71 +
            std::thread::sleep(std::time::Duration::from_millis(25));
72 +
        }
73 +
        child.kill().ok();
74 +
        child.wait().ok();
75 +
        panic!("spectra serve did not start");
76 +
    }
77 +
    pub fn stop(mut self) {
78 +
        unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
79 +
        let status = self.child.wait().unwrap();
80 +
        assert!(status.success(), "a clean stop on SIGTERM");
81 +
        assert!(!self.socket.exists(), "the socket is removed");
82 +
    }
83 +
}
84 +
85 +
pub fn setup() -> tempfile::TempDir {
86 +
    let dir = tempfile::tempdir().unwrap();
87 +
    let d = dir.path();
88 +
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap();
89 +
    assert!(run(d, &["init"]).status.success());
90 +
    std::fs::create_dir_all(d.join("oxygen")).unwrap();
91 +
    assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success());
92 +
    dir
93 +
}
added tests/tools.rs +136 −0
1 +
//! The 0.1 tools end to end (spec §15.2, §14.1, §12.3, §9.2): two agents
2 +
//! talk through the real daemon, `wait` wakes at once and refuses an idle
3 +
//! loop, the rules line appears until the rules are read, every call is
4 +
//! recorded with its arguments in full, and the ledger verifies.
5 +
6 +
mod common;
7 +
8 +
use common::{add_agent, run, setup, Serving};
9 +
use serde_json::{json, Value};
10 +
use spectra_core::keys::KeyPair;
11 +
use spectra_mcp::client::{rpc_result, Client};
12 +
use std::time::{Duration, Instant};
13 +
14 +
async fn call(c: &Client, name: &str, args: Value) -> Value {
15 +
    let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args,
16 +
        "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
17 +
    let r = c.mcp("2026-07-28", &req).await.unwrap();
18 +
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
19 +
    rpc_result(&r).unwrap()["result"].clone()
20 +
}
21 +
22 +
fn text(v: &Value) -> String {
23 +
    v["content"][0]["text"].as_str().unwrap_or("").to_string()
24 +
}
25 +
26 +
#[tokio::test]
27 +
async fn two_agents_talk_and_every_call_is_on_the_record() {
28 +
    let dir = setup();
29 +
    let d = dir.path();
30 +
    std::fs::create_dir_all(d.join("data/rules")).unwrap();
31 +
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git.\n").unwrap();
32 +
    let claude: KeyPair = add_agent(d, "claude-oxygen", &[]);
33 +
    let kimi: KeyPair = add_agent(d, "kimi-oxygen", &[]);
34 +
    let srv = Serving::start(d);
35 +
    let (c, k) = (Client::new(&srv.socket, Some(claude)), Client::new(&srv.socket, Some(kimi)));
36 +
37 +
    let b = call(&c, "brief", json!({})).await;
38 +
    let t = text(&b);
39 +
    assert!(t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("Commit with oxy, never git."), "{t}");
40 +
    assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}");
41 +
    let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string();
42 +
    call(&k, "brief", json!({})).await;
43 +
44 +
    // kimi waits; claude mentions it; kimi wakes at once.
45 +
    let waiter = tokio::spawn({
46 +
        let k = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("agents/kimi-oxygen")).unwrap()));
47 +
        async move {
48 +
            let started = Instant::now();
49 +
            let r = call(&k, "wait", json!({ "timeout_s": 20 })).await;
50 +
            (r, started.elapsed())
51 +
        }
52 +
    });
53 +
    tokio::time::sleep(Duration::from_millis(300)).await;
54 +
    let s = call(&c, "say", json!({ "body": "@kimi-oxygen the wire protocol grew a field", "channel": "general" })).await;
55 +
    let id = s["structuredContent"]["id"].as_str().unwrap().to_string();
56 +
    let (woke, waited) = waiter.await.unwrap();
57 +
    assert!(
58 +
        text(&woke).contains("the wire protocol grew a field") && text(&woke).contains("[mention from claude-oxygen (Andy McCall's agent"),
59 +
        "{}",
60 +
        text(&woke)
61 +
    );
62 +
    assert!(waited < Duration::from_secs(5), "woke after {waited:?}, not at its 20 s timeout");
63 +
64 +
    let r = call(&k, "read", json!({ "thread": id })).await;
65 +
    assert!(text(&r).contains("[from claude-oxygen (Andy McCall's agent, claude-code)") && text(&r).contains("[end]"), "{}", text(&r));
66 +
    call(&k, "say", json!({ "body": "thanks, bumping the pin", "reply_to": id })).await;
67 +
    call(&k, "ack", json!({ "ids": [id], "reaction": "ack" })).await;
68 +
    assert!(text(&call(&k, "inbox", json!({})).await).contains("Nothing unread"));
69 +
    let hits = call(&c, "search", json!({ "query": "pin", "since": "1d" })).await;
70 +
    assert_eq!(hits["structuredContent"]["hits"].as_array().unwrap().len(), 1, "{hits}");
71 +
    call(&c, "status", json!({ "state": "working", "text": "reviewing the field" })).await;
72 +
    let a = call(&k, "agents", json!({})).await;
73 +
    assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a));
74 +
75 +
    // The rules change: the next result says so until `rules` is called.
76 +
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git. Add a changelog line.\n").unwrap();
77 +
    let r = call(&c, "inbox", json!({})).await;
78 +
    assert!(text(&r).starts_with(&format!("House rules changed ({version} → ")), "{}", text(&r));
79 +
    let rr = call(&c, "rules", json!({})).await;
80 +
    assert!(text(&rr).contains("Add a changelog line."));
81 +
    assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("House rules changed"));
82 +
83 +
    // Redaction and a refusal come back as results, never protocol errors.
84 +
    let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string();
85 +
    assert_eq!(call(&k, "redact", json!({ "id": mine, "reason": "not yours" })).await["isError"], true);
86 +
    assert_eq!(call(&c, "redact", json!({ "id": mine, "reason": "pasted by mistake" })).await["isError"], false);
87 +
    assert_eq!(call(&c, "say", json!({ "body": "x", "channel": "system" })).await["isError"], true);
88 +
    srv.stop();
89 +
90 +
    let v: Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
91 +
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
92 +
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
93 +
    let calls: Vec<(String, String)> =
94 +
        sqlx::query_as("SELECT tool, args FROM tool_calls WHERE agent = 'claude-oxygen' ORDER BY seq").fetch_all(&pool).await.unwrap();
95 +
    assert!(calls.iter().any(|(t, _)| t == "say"));
96 +
    let (_, args) = calls.iter().find(|(t, _)| t == "say").unwrap();
97 +
    let stored = spectra_core::attachments::get(&d.join("data"), args).expect("arguments kept in full");
98 +
    assert!(String::from_utf8_lossy(&stored).contains("the wire protocol grew a field"));
99 +
}
100 +
101 +
#[tokio::test]
102 +
async fn an_idle_wait_loop_is_refused() {
103 +
    let dir = setup();
104 +
    let d = dir.path();
105 +
    let a = add_agent(d, "idler", &[]);
106 +
    let srv = Serving::start(d);
107 +
    let c = Client::new(&srv.socket, Some(a));
108 +
    for _ in 0..3 {
109 +
        let r = call(&c, "wait", json!({ "timeout_s": 1 })).await;
110 +
        assert_eq!((r["isError"].as_bool(), text(&r).as_str()), (Some(false), "Nothing new."));
111 +
    }
112 +
    let started = Instant::now();
113 +
    let r = call(&c, "wait", json!({ "timeout_s": 50 })).await;
114 +
    assert_eq!(r["isError"], true);
115 +
    assert!(text(&r).contains("End your turn"), "{}", text(&r));
116 +
    assert!(started.elapsed() < Duration::from_secs(2), "refused at once");
117 +
    srv.stop();
118 +
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
119 +
    let waits: i64 =
120 +
        sqlx::query_scalar("SELECT COUNT(*) FROM tool_calls WHERE tool = 'wait' AND is_error = 0").fetch_one(&pool).await.unwrap();
121 +
    assert_eq!(waits, 0, "empty waits are not recorded");
122 +
}
123 +
124 +
#[tokio::test]
125 +
async fn the_tool_list_fits_its_token_budget() {
126 +
    let dir = setup();
127 +
    let d = dir.path();
128 +
    let a = add_agent(d, "counter", &[]);
129 +
    let srv = Serving::start(d);
130 +
    let c = Client::new(&srv.socket, Some(a));
131 +
    let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap();
132 +
    let tools = serde_json::to_string(&r.json().unwrap()["result"]["tools"]).unwrap();
133 +
    // About four bytes a token: 1,500 tokens (spec §15.2).
134 +
    assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4);
135 +
    srv.stop();
136 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: the 0.1 tools (#8).** `brief`, `rules`, `agents`, `status`, `say`, `read`, `inbox`, `wait`, `ack`, `search` and `redact`, in a `tools/list` under its 1,500-token budget. Every call is recorded as `tool.called` with its arguments and result kept in full, content addressed. While an agent works on stale house rules, every result begins by saying so until it calls `rules`. Messages come back delimited with their author. `wait` wakes as soon as something arrives, and after three empty waits in a row it refuses for ten minutes: an idle agent ends its turn instead of burning tokens. `spectra serve` now stops within five seconds of SIGTERM whatever streams are open.
5 6
- **Added: `spectra serve` and Tuner, MCP over the Unix socket and loopback (#7).** The daemon holds the ledger and serves `POST /mcp` on `spectra.sock` (reading the caller's uid) and on `127.0.0.1:7350`. Every request is signed with the caller's key and checked before anything else; an agent bound to another Unix user is refused and recorded as `guard.impersonation`, and `--learn-uid` binds an agent on its first connection. MCP 2026-07-28 (stateless, with its mirrored headers checked, `server/discover`, `subscriptions/listen`) and 2025-11-25, 2025-06-18 and 2025-03-26 (`initialize`); a foreign `Origin` is refused. While it serves, `spectra agent add`, `project add` and `identity refresh` hand their signed events to it. It writes a checkpoint when it stops.
6 7
- **Added: channels, threads, messages, the inbox and search (#6).** `spectra init` makes #general, #humans and #system; each project gets its channel. An agent posts in its project's channel, the shared channels and its direct messages; nobody but Spectra posts in #system. Replies join the root's thread; `@name`, `@here` and the owner's first name put a message in the right inbox, as do direct messages. Duplicates within a minute are dropped, bodies and payloads are capped at 64 KiB, and redaction hides a message everywhere while the ledger keeps it for audit. Full-text search (FTS5, stemmed) covers what the reader may see.
7 8
- **Added: projects, presence and sessions (#5).** `spectra project add PATH` records a checkout with its repository id and remote, and finds what it pins and what pins it from `.oxymodules`, whichever is registered first; `spectra project list` shows them with their agents. An agent needs a registered project. Presence (online, busy, idle, blocked, paused, offline) and sessions are tracked from what agents do and recorded when they change; `spectra agent list` shows them.
…
5 unchanged lines
modified Cargo.toml +1 −0
…
80 unchanged lines
81 81
[dev-dependencies]
82 82
tempfile.workspace = true
83 83
oxy-core.workspace = true
84 +
spectra-mcp.workspace = true
84 85
sqlx.workspace = true
85 86
serde_json.workspace = true
86 87
…
2 unchanged lines
modified crates/spectra-core/src/daemon.rs +3 −0
…
25 unchanged lines
26 26
    pub principals: RwLock<Principals>,
27 27
    pub authn: Authenticator,
28 28
    pub presence: Mutex<Presence>,
29 +
    /// Per agent: empty `wait`s in a row, and until when `wait` is refused.
30 +
    pub idle_waits: Mutex<std::collections::HashMap<String, (u32, u64)>>,
29 31
}
30 32
31 33
impl Daemon {
…
12 unchanged lines
44 46
            principals: RwLock::new(principals),
45 47
            authn: Authenticator::default(),
46 48
            presence: Mutex::new(Presence::default()),
49 +
            idle_waits: Mutex::new(Default::default()),
47 50
        });
48 51
        d.clone().watch_principals();
49 52
        d.clone().tick_presence();
…
208 unchanged lines
modified crates/spectra-core/src/ids.rs +19 −0
…
23 unchanged lines
24 24
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
25 25
}
26 26
27 +
/// A time as people and models read it: `2026-11-02 21:43Z`, UTC.
28 +
pub fn format_time(ms: u64) -> String {
29 +
    let secs = (ms / 1000) as i64;
30 +
    let days = secs.div_euclid(86_400);
31 +
    let rem = secs.rem_euclid(86_400);
32 +
    // Civil from days (Howard Hinnant), as oxy's `format::when`.
33 +
    let z = days + 719_468;
34 +
    let era = z.div_euclid(146_097);
35 +
    let doe = z - era * 146_097;
36 +
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
37 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
38 +
    let mp = (5 * doy + 2) / 153;
39 +
    let d = doy - (153 * mp + 2) / 5 + 1;
40 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
41 +
    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
42 +
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}Z", rem / 3600, (rem % 3600) / 60)
43 +
}
44 +
27 45
#[cfg(test)]
28 46
mod tests {
29 47
    use super::*;
…
6 unchanged lines
36 54
        assert!(a < b);
37 55
        assert_eq!(ulid_from(0, 0), "00000000000000000000000000");
38 56
        assert_ne!(ulid(), ulid());
57 +
        assert_eq!(format_time(1_793_655_780_000), "2026-11-02 21:43Z");
39 58
    }
40 59
}
modified crates/spectra-core/src/lib.rs +2 −0
…
4 unchanged lines
5 5
//! Identities, delegation, composite signatures, deterministic CBOR and
6 6
//! hashing come from `oxy-core`; nothing here reimplements them.
7 7
8 +
pub mod attachments;
8 9
pub mod authn;
9 10
pub mod conversation;
10 11
pub mod daemon;
…
7 unchanged lines
18 19
pub mod projections;
19 20
pub mod projects;
20 21
pub mod proof;
22 +
pub mod rules;
21 23
22 24
/// The ledger's format version: the frozen event envelope (spec §9.1). A
23 25
/// database written with a newer format is refused, never guessed at.
…
1 unchanged lines
modified crates/spectra-core/src/projections.rs +1 −0
…
31 unchanged lines
32 32
    crate::projects::project(conn, e).await?;
33 33
    crate::presence::project(conn, e).await?;
34 34
    crate::conversation::project(conn, e).await?;
35 +
    crate::rules::project(conn, e).await?;
35 36
    Ok(())
36 37
}
modified crates/spectra-mcp/src/client.rs +8 −2
…
49 unchanged lines
50 50
        tokio::spawn(async move {
51 51
            let _ = conn.await;
52 52
        });
53 -
        let mut req =
54 -
            hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("content-type", "application/json");
53 +
        let mut req = hyper::Request::builder()
54 +
            .method(method)
55 +
            .uri(path)
56 +
            .header("host", "spectra")
57 +
            .header("content-type", "application/json")
58 +
            .header("connection", "close");
59 +
        // One request a connection: the server closes it after answering, so
60 +
        // nothing lingers to hold up its shutdown.
55 61
        for (k, v) in headers {
56 62
            req = req.header(*k, v);
57 63
        }
…
55 unchanged lines
modified crates/spectra-mcp/src/http.rs +6 −3
…
136 unchanged lines
137 137
        Reply::Accepted => StatusCode::ACCEPTED.into_response(),
138 138
        Reply::Stream(id, tool, args) => {
139 139
            let fut = async move {
140 -
                let out = (tool.run)(&ctx, args).await;
140 +
                let out = crate::tools::invoke(&ctx, tool, args).await;
141 141
                let meta = crate::http::meta(&ctx).await;
142 142
                let msg = protocol::stream_result(&id, &version, &out, meta);
143 143
                Ok::<_, Infallible>(SseEvent::default().event("message").data(msg.to_string()))
…
17 unchanged lines
161 161
    r
162 162
}
163 163
164 -
/// `_meta` on every result (spec §15.2): the session and the unread count;
165 -
/// the rules version arrives with the rules (#8).
164 +
/// `_meta` on every result (spec §15.2): the rules version, the session and
165 +
/// the unread count.
166 166
pub async fn meta(ctx: &Ctx) -> Json {
167 167
    let mut m = serde_json::Map::new();
168 +
    if let Ok(r) = spectra_core::rules::compile(&ctx.daemon.data) {
169 +
        m.insert("spectra/rules_version".into(), json!(r.version));
170 +
    }
168 171
    if let Some(s) = &ctx.session {
169 172
        m.insert("spectra/session".into(), json!(s));
170 173
    }
…
31 unchanged lines
modified crates/spectra-mcp/src/protocol.rs +3 −1
…
84 unchanged lines
85 85
86 86
/// Answer one JSON-RPC message from an authenticated caller.
87 87
pub async fn handle(ctx: &Ctx, h: &Headers<'_>, req: Json, meta: Json) -> Reply {
88 +
    let _ = &meta;
88 89
    let id = req.get("id").cloned().unwrap_or(Json::Null);
89 90
    let Some(method) = req.get("method").and_then(Json::as_str).map(String::from) else {
90 91
        return Reply::Json(400, error(&id, INVALID_REQUEST, "a JSON-RPC request needs a method"));
…
42 unchanged lines
133 134
            if tool.long {
134 135
                return Reply::Stream(id, tool, args);
135 136
            }
136 -
            let out = (tool.run)(ctx, args).await;
137 +
            let out = tools::invoke(ctx, tool, args).await;
138 +
            let meta = crate::http::meta(ctx).await;
137 139
            Reply::Json(200, ok(&id, version, tool_result(&out), meta))
138 140
        }
139 141
        "subscriptions/listen" if version == STATELESS => Reply::Listen(id, spectra_core::ids::ulid()),
…
17 unchanged lines
modified crates/spectra-mcp/src/tools.rs +522 −32
1 1
//! The tools agents call (spec §15.2). Each is a plain function, so the
2 2
//! list is fixed and deterministic, which keeps clients' prompt caches warm.
3 3
//! A failure is a result with `isError`, never a protocol error, so the
4 -
//! model sees why.
4 +
//! model sees why. Every call goes through [`invoke`]: it is recorded as
5 +
//! `tool.called` with its arguments and result in full (content addressed),
6 +
//! and its text begins with the rules-change line while the caller works on
7 +
//! stale rules (spec §12.3).
8 +
//!
9 +
//! Text from other principals is always delimited with its author (spec
10 +
//! §20.3), so a model can tell whose words it is reading.
5 11
6 12
use futures::future::BoxFuture;
7 13
use serde_json::{json, Value as Json};
8 14
use spectra_core::authn::Caller;
15 +
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
9 16
use spectra_core::daemon::Daemon;
17 +
use spectra_core::event::NewEvent;
18 +
use spectra_core::ids::now_ms;
10 19
use spectra_core::principals::Principal;
11 20
use std::sync::Arc;
21 +
use std::time::Duration;
12 22
23 +
/// Empty `wait`s in a row before `wait` is refused, and for how long.
24 +
pub const IDLE_WAITS: u32 = 3;
25 +
pub const IDLE_COOLDOWN_MS: u64 = 10 * 60_000;
26 +
13 27
/// What a tool sees: the daemon and who called it.
14 28
pub struct Ctx {
15 29
    pub daemon: Arc<Daemon>,
…
1 unchanged lines
17 31
    pub session: Option<String>,
18 32
}
19 33
34 +
impl Ctx {
35 +
    fn author(&self) -> Author<'static> {
36 +
        Author { principal: self.caller.principal.clone(), signer: Signer::Request(self.caller.auth.clone()) }
37 +
    }
38 +
    fn actor(&self) -> String {
39 +
        self.caller.principal.actor()
40 +
    }
41 +
    fn event(&self, kind: &str, payload: Json) -> NewEvent {
42 +
        let mut e = NewEvent::system(kind, payload).by(self.actor(), self.caller.auth.clone());
43 +
        e.session = self.session.clone();
44 +
        e
45 +
    }
46 +
}
47 +
20 48
/// A tool's result: text for the model, the same as JSON, and whether it
21 49
/// failed.
22 50
#[derive(Clone, Debug, Default)]
…
1 unchanged lines
24 52
    pub text: String,
25 53
    pub structured: Option<Json>,
26 54
    pub is_error: bool,
55 +
    /// An empty `wait`: not recorded (spec §9.2).
56 +
    pub quiet: bool,
27 57
}
28 58
29 59
impl Output {
30 60
    pub fn ok(text: impl Into<String>, structured: Json) -> Output {
31 -
        Output { text: text.into(), structured: Some(structured), is_error: false }
61 +
        Output { text: text.into(), structured: Some(structured), ..Default::default() }
32 62
    }
33 63
    pub fn err(text: impl Into<String>) -> Output {
34 -
        Output { text: text.into(), structured: None, is_error: true }
64 +
        Output { text: text.into(), is_error: true, ..Default::default() }
35 65
    }
66 +
}
67 +
68 +
fn fail(e: anyhow::Error) -> Output {
69 +
    Output::err(format!("{e:#}"))
36 70
}
37 71
38 72
pub type Run = for<'a> fn(&'a Ctx, Json) -> BoxFuture<'a, Output>;
…
28 unchanged lines
67 101
68 102
pub fn find(name: &str) -> Option<&'static Tool> {
69 103
    TOOLS.iter().find(|t| t.name == name)
104 +
}
105 +
106 +
/// Run a tool for the caller: the idle guard, the rules line, and the record.
107 +
pub async fn invoke(ctx: &Ctx, tool: &Tool, args: Json) -> Output {
108 +
    let started = std::time::Instant::now();
109 +
    let mut out = (tool.run)(ctx, args.clone()).await;
110 +
    let Principal::Agent { name, .. } = &ctx.caller.principal else { return out };
111 +
    if tool.name != "rules" && tool.name != "brief" {
112 +
        // With no house rules there is nothing to read, so nothing to say.
113 +
        if let Some(rules) = spectra_core::rules::compile(&ctx.daemon.data).ok().filter(|r| !r.text.is_empty()) {
114 +
            let read = spectra_core::rules::last_read(ctx.daemon.ledger.pool(), name).await.ok().flatten();
115 +
            if read.as_deref() != Some(rules.version.as_str()) {
116 +
                let from = read.unwrap_or_else(|| "none".into());
117 +
                out.text = format!("{}\n\n{}", spectra_core::rules::changed_line(&from, &rules.version), out.text);
118 +
            }
119 +
        }
120 +
    }
121 +
    if out.quiet {
122 +
        return out;
123 +
    }
124 +
    let data = &ctx.daemon.data;
125 +
    let args_hash = spectra_core::attachments::put(data, args.to_string().as_bytes()).unwrap_or_default();
126 +
    let result_hash = spectra_core::attachments::put(data, out.text.as_bytes()).unwrap_or_default();
127 +
    let e = ctx.event(
128 +
        "tool.called",
129 +
        json!({ "tool": tool.name, "args": args_hash, "result": result_hash, "ms": started.elapsed().as_millis() as u64, "is_error": out.is_error, "read": tool.read_only }),
130 +
    );
131 +
    let _ = ctx.daemon.ledger.append_one(e).await;
132 +
    out
133 +
}
134 +
135 +
fn obj(props: Json, required: &[&str]) -> Json {
136 +
    json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
137 +
}
138 +
139 +
static TOOLS: &[Tool] = &[
140 +
    Tool {
141 +
        name: "brief",
142 +
        description: "Call first: who you are, your project, the house rules, your unread inbox, and who else is here.",
143 +
        schema: || obj(json!({ "new_session": { "type": "boolean" } }), &[]),
144 +
        read_only: true,
145 +
        destructive: false,
146 +
        long: false,
147 +
        run: brief,
148 +
    },
149 +
    Tool {
150 +
        name: "rules",
151 +
        description: "The house rules and their version, in full.",
152 +
        schema: || obj(json!({}), &[]),
153 +
        read_only: true,
154 +
        destructive: false,
155 +
        long: false,
156 +
        run: rules,
157 +
    },
158 +
    Tool {
159 +
        name: "agents",
160 +
        description: "Every agent with its owner, kind, project, presence and status.",
161 +
        schema: || obj(json!({ "project": { "type": "string" } }), &[]),
162 +
        read_only: true,
163 +
        destructive: false,
164 +
        long: false,
165 +
        run: agents,
166 +
    },
167 +
    Tool {
168 +
        name: "status",
169 +
        description: "Say what you are doing, so others can see.",
170 +
        schema: || obj(json!({ "state": { "enum": ["working", "idle", "blocked"] }, "text": { "type": "string" }, "task": { "type": "string" } }), &[]),
171 +
        read_only: false,
172 +
        destructive: false,
173 +
        long: false,
174 +
        run: status,
175 +
    },
176 +
    Tool {
177 +
        name: "say",
178 +
        description: "Post a message (default: your project's channel). @name mentions; reply_to answers in a thread.",
179 +
        schema: || {
180 +
            obj(
181 +
                json!({ "body": { "type": "string" }, "channel": { "type": "string" }, "reply_to": { "type": "string" },
182 +
                    "kind": { "enum": ["chat", "request", "answer", "handoff", "status", "decision"] }, "to": { "type": "string" } }),
183 +
                &["body"],
184 +
            )
185 +
        },
186 +
        read_only: false,
187 +
        destructive: false,
188 +
        long: false,
189 +
        run: say,
190 +
    },
191 +
    Tool {
192 +
        name: "read",
193 +
        description: "Messages of a channel (default: your project's) or a whole thread; marks nothing read.",
194 +
        schema: || obj(json!({ "channel": { "type": "string" }, "thread": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &[]),
195 +
        read_only: true,
196 +
        destructive: false,
197 +
        long: false,
198 +
        run: read,
199 +
    },
200 +
    Tool {
201 +
        name: "inbox",
202 +
        description: "Your unread mentions and direct messages.",
203 +
        schema: || obj(json!({}), &[]),
204 +
        read_only: true,
205 +
        destructive: false,
206 +
        long: false,
207 +
        run: inbox,
208 +
    },
209 +
    Tool {
210 +
        name: "wait",
211 +
        description: "Only while waiting for an answer inside a piece of work: blocks up to timeout_s for something new. If idle, end your turn instead.",
212 +
        schema: || obj(json!({ "timeout_s": { "type": "integer", "minimum": 1, "maximum": 50 } }), &["timeout_s"]),
213 +
        read_only: true,
214 +
        destructive: false,
215 +
        long: true,
216 +
        run: wait,
217 +
    },
218 +
    Tool {
219 +
        name: "ack",
220 +
        description: "Mark inbox items read, with an optional reaction (ack, done, eyes, no).",
221 +
        schema: || obj(json!({ "ids": { "type": "array", "items": { "type": "string" } }, "reaction": { "enum": ["ack", "done", "eyes", "no"] } }), &["ids"]),
222 +
        read_only: false,
223 +
        destructive: false,
224 +
        long: false,
225 +
        run: ack,
226 +
    },
227 +
    Tool {
228 +
        name: "search",
229 +
        description: "Search history: words, \"phrases\", prefix*, AND/OR/NOT; filter by channel, author, since (7d, 3h).",
230 +
        schema: || {
231 +
            obj(json!({ "query": { "type": "string" }, "channel": { "type": "string" }, "author": { "type": "string" }, "since": { "type": "string" }, "limit": { "type": "integer" } }), &["query"])
232 +
        },
233 +
        read_only: true,
234 +
        destructive: false,
235 +
        long: false,
236 +
        run: search,
237 +
    },
238 +
    Tool {
239 +
        name: "redact",
240 +
        description: "Redact your own message, with the reason.",
241 +
        schema: || obj(json!({ "id": { "type": "string" }, "reason": { "type": "string" } }), &["id", "reason"]),
242 +
        read_only: false,
243 +
        destructive: true,
244 +
        long: false,
245 +
        run: redact,
246 +
    },
247 +
];
248 +
249 +
fn s<'a>(args: &'a Json, k: &str) -> Option<&'a str> {
250 +
    args.get(k).and_then(Json::as_str).filter(|v| !v.is_empty())
251 +
}
252 +
253 +
/// How a principal is named to a model: `claude-oxygen (Andy McCall's agent, claude-code)`.
254 +
async fn label(d: &Daemon, actor: &str) -> String {
255 +
    if actor == "human" {
256 +
        let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1")
257 +
            .fetch_optional(d.ledger.pool())
258 +
            .await
259 +
            .ok()
260 +
            .flatten();
261 +
        return format!("{} (the human)", name.unwrap_or_else(|| "the owner".into()));
262 +
    }
263 +
    if let Some(a) = actor.strip_prefix("agent:") {
264 +
        let row: Option<(String, String)> = sqlx::query_as("SELECT owner_name, kind FROM agents WHERE name = ?")
265 +
            .bind(a)
266 +
            .fetch_optional(d.ledger.pool())
267 +
            .await
268 +
            .ok()
269 +
            .flatten();
270 +
        if let Some((owner, kind)) = row {
271 +
            return format!("{a} ({owner}'s agent, {kind})");
272 +
        }
273 +
        return a.to_string();
274 +
    }
275 +
    "Spectra".into()
276 +
}
277 +
278 +
/// A message as a model reads it: delimited with its author (spec §20.3).
279 +
async fn delimited(d: &Daemon, m: &conv::Message, channel: &str) -> String {
280 +
    let at = spectra_core::ids::format_time(m.at_ms as u64);
281 +
    let thread = m.reply_to.as_deref().map(|r| format!(", reply in thread {r}")).unwrap_or_default();
282 +
    let replies = if m.replies > 0 { format!(" ({} replies)", m.replies) } else { String::new() };
283 +
    format!("[from {} at {at} in {channel}, id {}{thread}{replies}]\n{}\n[end]", label(d, &m.author).await, m.id, m.body)
70 284
}
71 285
72 -
static TOOLS: &[Tool] = &[Tool {
73 -
    name: "agents",
74 -
    description: "Every agent with its owner, kind, project, presence and status.",
75 -
    schema: || json!({ "type": "object", "properties": { "project": { "type": "string", "description": "Only this project's agents" } }, "additionalProperties": false }),
76 -
    read_only: true,
77 -
    destructive: false,
78 -
    long: false,
79 -
    run: agents,
80 -
}];
286 +
fn brief(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
287 +
    let _ = args;
288 +
    Box::pin(async move {
289 +
        let d = &ctx.daemon;
290 +
        let rules = match spectra_core::rules::compile(&d.data) {
291 +
            Ok(r) => r,
292 +
            Err(e) => return fail(e),
293 +
        };
294 +
        let me = ctx.caller.principal.display();
295 +
        let project = match &ctx.caller.principal {
296 +
            Principal::Agent { project, .. } => Some(project.clone()),
297 +
            _ => None,
298 +
        };
299 +
        let mut relations = (Vec::<String>::new(), Vec::<String>::new());
300 +
        if let Some(p) = &project {
301 +
            relations.0 = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ?")
302 +
                .bind(p)
303 +
                .fetch_all(d.ledger.pool())
304 +
                .await
305 +
                .unwrap_or_default();
306 +
            relations.1 = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ?")
307 +
                .bind(p)
308 +
                .fetch_all(d.ledger.pool())
309 +
                .await
310 +
                .unwrap_or_default();
311 +
        }
312 +
        let items = conv::inbox(&d.ledger, &ctx.actor(), Some(&ctx.author())).await.unwrap_or_default();
313 +
        let others = agents(ctx, json!({})).await;
314 +
        let _ = d.ledger.append_one(ctx.event("rules.read", json!({ "version": rules.version }))).await;
315 +
        let mut text = format!("You are {me}.");
316 +
        if let Some(p) = &project {
317 +
            text.push_str(&format!(" Your project is {p}; your channel is #{p}."));
318 +
            if !relations.0.is_empty() {
319 +
                text.push_str(&format!(" It pins {}.", relations.0.join(", ")));
320 +
            }
321 +
            if !relations.1.is_empty() {
322 +
                text.push_str(&format!(" {} pin it.", relations.1.join(", ")));
323 +
            }
324 +
        }
325 +
        if let Some(sess) = &ctx.session {
326 +
            text.push_str(&format!(" Session {sess}."));
327 +
        }
328 +
        text.push_str(&format!("\n\nHouse rules (version {}):\n", rules.version));
329 +
        text.push_str(if rules.text.is_empty() { "(none yet)\n" } else { &rules.text });
330 +
        text.push_str(&format!("\nInbox: {} unread.\n", items.len()));
331 +
        for i in &items {
332 +
            text.push_str(&format!("- {} from {} in {} ({}): {}\n", i.kind, label(d, &i.from).await, i.channel, i.id, i.summary));
333 +
        }
334 +
        text.push_str(&format!("\nAgents:\n{}\n", others.text));
335 +
        text.push_str("\nWhen you have nothing to do, end your turn: Spectra starts you again when there is work.");
336 +
        Output::ok(
337 +
            text,
338 +
            json!({ "you": me, "project": project, "session": ctx.session, "rules": { "version": rules.version, "text": rules.text },
339 +
                "inbox": items, "agents": others.structured.and_then(|s| s.get("agents").cloned()), "pins": relations.0, "pinned_by": relations.1 }),
340 +
        )
341 +
    })
342 +
}
81 343
82 -
/// A principal as the model reads it.
83 -
pub fn who(p: &Principal) -> String {
84 -
    p.display()
344 +
fn rules(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
345 +
    Box::pin(async move {
346 +
        match spectra_core::rules::compile(&ctx.daemon.data) {
347 +
            Ok(r) => {
348 +
                let _ = ctx.daemon.ledger.append_one(ctx.event("rules.read", json!({ "version": r.version }))).await;
349 +
                let body = if r.text.is_empty() { "(no house rules yet)".to_string() } else { r.text.clone() };
350 +
                Output::ok(format!("House rules, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text }))
351 +
            }
352 +
            Err(e) => fail(e),
353 +
        }
354 +
    })
85 355
}
86 356
87 357
fn agents(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
88 358
    Box::pin(async move {
89 -
        let project = args.get("project").and_then(Json::as_str);
90 -
        let rows = sqlx_rows(&ctx.daemon, project).await;
91 -
        let rows = match rows {
359 +
        let project = s(&args, "project");
360 +
        let rows: Vec<AgentRow> = match sqlx::query_as(
361 +
            "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \
362 +
             WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name",
363 +
        )
364 +
        .bind(project)
365 +
        .bind(project)
366 +
        .fetch_all(ctx.daemon.ledger.pool())
367 +
        .await
368 +
        {
92 369
            Ok(r) => r,
93 -
            Err(e) => return Output::err(format!("could not list agents: {e}")),
370 +
            Err(e) => return fail(e.into()),
94 371
        };
372 +
        // Presence as the daemon sees it now; the table is its recorded history.
373 +
        let rows: Vec<_> = {
374 +
            let p = ctx.daemon.presence.lock().await;
375 +
            let now = now_ms();
376 +
            rows.into_iter()
377 +
                .map(|(name, owner, kind, project, _, status)| {
378 +
                    let live = p.state(&name, now).to_string();
379 +
                    (name, owner, kind, project, Some(live), status)
380 +
                })
381 +
                .collect()
382 +
        };
95 383
        let list: Vec<Json> = rows
96 384
            .iter()
97 385
            .map(|(name, owner, kind, project, presence, status)| {
…
12 unchanged lines
110 398
                .join("\n")
111 399
        };
112 400
        Output::ok(text, json!({ "agents": list }))
401 +
    })
402 +
}
403 +
404 +
/// An agent as the `agents` tool reads it: name, owner, kind, project,
405 +
/// presence and status.
406 +
type AgentRow = (String, String, String, String, Option<String>, Option<String>);
407 +
408 +
fn status(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
409 +
    Box::pin(async move {
410 +
        let Principal::Agent { name, .. } = &ctx.caller.principal else { return Output::err("status is for agents") };
411 +
        let state = s(&args, "state").unwrap_or("working");
412 +
        let text = s(&args, "text");
413 +
        if text.is_some_and(|t| t.len() > 500) {
414 +
            return Output::err("a status is at most 500 characters");
415 +
        }
416 +
        let e = ctx.event("agent.status", json!({ "state": state, "text": text, "task": s(&args, "task") })).subject(name.clone());
417 +
        if let Err(e) = ctx.daemon.ledger.append_one(e).await {
418 +
            return fail(e);
419 +
        }
420 +
        {
421 +
            let mut p = ctx.daemon.presence.lock().await;
422 +
            p.status(name, (state != "idle").then(now_ms));
423 +
            p.blocked(name, state == "blocked");
424 +
        }
425 +
        Output::ok(
426 +
            format!("Status set: {state}{}", text.map(|t| format!(" — {t}")).unwrap_or_default()),
427 +
            json!({ "state": state, "text": text }),
428 +
        )
429 +
    })
430 +
}
431 +
432 +
fn say(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
433 +
    Box::pin(async move {
434 +
        let Some(body) = s(&args, "body") else { return Output::err("say needs a body") };
435 +
        let mut channel = s(&args, "channel").map(String::from);
436 +
        if let Some(to) = s(&args, "to") {
437 +
            match conv::ensure_direct(&ctx.daemon.ledger, &ctx.caller.principal, to).await {
438 +
                Ok(c) => channel = Some(c.name),
439 +
                Err(e) => return fail(e),
440 +
            }
441 +
        }
442 +
        let post = Post {
443 +
            channel,
444 +
            body: body.into(),
445 +
            reply_to: s(&args, "reply_to").map(String::from),
446 +
            kind: s(&args, "kind").map(String::from),
447 +
            ..Default::default()
448 +
        };
449 +
        match conv::post(&ctx.daemon.ledger, &ctx.author(), post, now_ms()).await {
450 +
            Ok(Posted::Message { id, channel }) => {
451 +
                Output::ok(format!("Posted {id} in {channel}."), json!({ "id": id, "channel": channel }))
452 +
            }
453 +
            Ok(Posted::Duplicate { id }) => {
454 +
                Output::ok(format!("Not posted: you said exactly this in the last minute ({id})."), json!({ "duplicate_of": id }))
455 +
            }
456 +
            Err(e) => fail(e),
457 +
        }
458 +
    })
459 +
}
460 +
461 +
fn read(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
462 +
    Box::pin(async move {
463 +
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(50) as u32;
464 +
        match conv::read(&ctx.daemon.ledger, &ctx.caller.principal, s(&args, "channel"), s(&args, "thread"), s(&args, "since"), limit).await
465 +
        {
466 +
            Ok((ch, msgs)) => {
467 +
                let mut text = format!("{} message(s) in {}:\n\n", msgs.len(), ch.display());
468 +
                for m in &msgs {
469 +
                    text.push_str(&delimited(&ctx.daemon, m, &ch.display()).await);
470 +
                    text.push_str("\n\n");
471 +
                }
472 +
                Output::ok(text.trim_end().to_string(), json!({ "channel": ch.display(), "messages": msgs }))
473 +
            }
474 +
            Err(e) => fail(e),
475 +
        }
476 +
    })
477 +
}
478 +
479 +
fn inbox(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
480 +
    Box::pin(async move {
481 +
        match conv::inbox(&ctx.daemon.ledger, &ctx.actor(), Some(&ctx.author())).await {
482 +
            Ok(items) => inbox_output(ctx, items).await,
483 +
            Err(e) => fail(e),
484 +
        }
485 +
    })
486 +
}
487 +
488 +
async fn inbox_output(ctx: &Ctx, items: Vec<conv::Item>) -> Output {
489 +
    if items.is_empty() {
490 +
        return Output::ok("Nothing unread.", json!({ "items": [] }));
491 +
    }
492 +
    let mut text = format!("{} unread:\n\n", items.len());
493 +
    for i in &items {
494 +
        text.push_str(&format!(
495 +
            "[{} from {} in {}, id {}]\n{}\n[end]\nAct on it: {}\n\n",
496 +
            i.kind,
497 +
            label(&ctx.daemon, &i.from).await,
498 +
            i.channel,
499 +
            i.id,
500 +
            i.summary,
501 +
            i.action_hint
502 +
        ));
503 +
    }
504 +
    Output::ok(text.trim_end().to_string(), json!({ "items": items }))
505 +
}
506 +
507 +
fn wait(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
508 +
    Box::pin(async move {
509 +
        let actor = ctx.actor();
510 +
        let now = now_ms();
511 +
        {
512 +
            let guard = ctx.daemon.idle_waits.lock().await;
513 +
            if let Some((_, until)) = guard.get(&actor) {
514 +
                if *until > now {
515 +
                    return Output::err("Nothing is waiting for you. End your turn; Spectra starts you again when there is work.");
516 +
                }
517 +
            }
518 +
        }
519 +
        let timeout = args.get("timeout_s").and_then(Json::as_u64).unwrap_or(30).clamp(1, 50);
520 +
        let name = actor.strip_prefix("agent:").unwrap_or(&actor).to_string();
521 +
        // Subscribe before looking, so nothing is missed between the two.
522 +
        let mut rx = ctx.daemon.ledger.subscribe();
523 +
        let ledger = &ctx.daemon.ledger;
524 +
        ctx.daemon.presence.lock().await.wait_started(&name);
525 +
        let found = async {
526 +
            loop {
527 +
                if conv::unread(ledger, &actor).await.unwrap_or(0) > 0 {
528 +
                    return true;
529 +
                }
530 +
                match rx.recv().await {
531 +
                    Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
532 +
                    Err(_) => return false,
533 +
                }
534 +
            }
535 +
        };
536 +
        let got = tokio::time::timeout(Duration::from_secs(timeout), found).await.unwrap_or(false);
537 +
        ctx.daemon.presence.lock().await.wait_ended(&name);
538 +
        let mut guard = ctx.daemon.idle_waits.lock().await;
539 +
        if got {
540 +
            guard.remove(&actor);
541 +
            drop(guard);
542 +
            return match conv::inbox(ledger, &actor, Some(&ctx.author())).await {
543 +
                Ok(items) => inbox_output(ctx, items).await,
544 +
                Err(e) => fail(e),
545 +
            };
546 +
        }
547 +
        let entry = guard.entry(actor).or_insert((0, 0));
548 +
        entry.0 += 1;
549 +
        if entry.0 >= IDLE_WAITS {
550 +
            entry.1 = now_ms() + IDLE_COOLDOWN_MS;
551 +
            entry.0 = 0;
552 +
        }
553 +
        Output { text: "Nothing new.".into(), structured: Some(json!({ "items": [] })), is_error: false, quiet: true }
554 +
    })
555 +
}
556 +
557 +
fn ack(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
558 +
    Box::pin(async move {
559 +
        let ids: Vec<String> = args
560 +
            .get("ids")
561 +
            .and_then(Json::as_array)
562 +
            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
563 +
            .unwrap_or_default();
564 +
        if ids.is_empty() {
565 +
            return Output::err("ack needs ids");
566 +
        }
567 +
        match conv::ack(&ctx.daemon.ledger, &ctx.author(), &ids, s(&args, "reaction")).await {
568 +
            Ok(n) => Output::ok(format!("Marked {n} read."), json!({ "read": n })),
569 +
            Err(e) => fail(e),
570 +
        }
571 +
    })
572 +
}
573 +
574 +
fn search(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
575 +
    Box::pin(async move {
576 +
        let Some(q) = s(&args, "query") else { return Output::err("search needs a query") };
577 +
        let since = s(&args, "since").and_then(|v| parse_since(v, now_ms()));
578 +
        let limit = args.get("limit").and_then(Json::as_u64).unwrap_or(20) as u32;
579 +
        match conv::search(&ctx.daemon.ledger, &ctx.caller.principal, q, s(&args, "channel"), s(&args, "author"), since, limit).await {
580 +
            Ok(hits) => {
581 +
                let mut text = format!("{} hit(s):\n", hits.len());
582 +
                for h in &hits {
583 +
                    text.push_str(&format!(
584 +
                        "- [{} in {} at {}, id {}] {}\n",
585 +
                        label(&ctx.daemon, &h.author).await,
586 +
                        h.channel,
587 +
                        spectra_core::ids::format_time(h.at_ms as u64),
588 +
                        h.id,
589 +
                        h.snippet
590 +
                    ));
591 +
                }
592 +
                Output::ok(text.trim_end().to_string(), json!({ "hits": hits }))
593 +
            }
594 +
            Err(e) => fail(e),
595 +
        }
113 596
    })
114 597
}
115 598
116 -
#[allow(clippy::type_complexity)]
117 -
async fn sqlx_rows(
118 -
    d: &Daemon,
119 -
    project: Option<&str>,
120 -
) -> Result<Vec<(String, String, String, String, Option<String>, Option<String>)>, sqlx::Error> {
121 -
    sqlx::query_as(
122 -
        "SELECT a.name, a.owner_name, a.kind, a.project, p.state, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name \
123 -
         WHERE a.disabled_ms IS NULL AND (? IS NULL OR a.project = ?) ORDER BY a.name",
124 -
    )
125 -
    .bind(project)
126 -
    .bind(project)
127 -
    .fetch_all(d.ledger.pool())
128 -
    .await
599 +
/// `7d`, `3h`, `30m`, or milliseconds since the epoch.
600 +
pub fn parse_since(v: &str, now: u64) -> Option<i64> {
601 +
    let (n, unit) = v.split_at(v.len().saturating_sub(1));
602 +
    let ms = match unit {
603 +
        "d" => 86_400_000,
604 +
        "h" => 3_600_000,
605 +
        "m" => 60_000,
606 +
        _ => return v.parse().ok(),
607 +
    };
608 +
    n.parse::<u64>().ok().map(|n| now.saturating_sub(n * ms) as i64)
609 +
}
610 +
611 +
fn redact(ctx: &Ctx, args: Json) -> BoxFuture<'_, Output> {
612 +
    Box::pin(async move {
613 +
        let (Some(id), Some(reason)) = (s(&args, "id"), s(&args, "reason")) else { return Output::err("redact needs id and reason") };
614 +
        match conv::redact(&ctx.daemon.ledger, &ctx.author(), id, reason).await {
615 +
            Ok(()) => Output::ok(format!("Redacted {id}."), json!({ "id": id })),
616 +
            Err(e) => fail(e),
617 +
        }
618 +
    })
129 619
}
modified docs/plan.md +1 −1
…
20 unchanged lines
21 21
- [x] #5 Projects, presence and sessions — done 2026-11-02: project add/list with relations from .oxymodules (checked on oxygen, oxygenflow, oxygenhub, spectra), presence and session trackers with events and projections; the daemon feeds them from #7.
22 22
- [x] #6 Channels, threads, messages, inbox and search — done 2026-11-02: spectra-core::conversation (post, read, inbox, ack, redact, search, direct channels), shared by tools, API and CLI.
23 23
- [x] #7 Tuner: MCP over Streamable HTTP — done 2026-11-02: spectra serve, socket + loopback, signed requests, both revisions, refusals, owner events through the daemon; end-to-end tests drive the real binary.
24 -
- [ ] #8 The 0.1 tools
24 +
- [x] #8 The 0.1 tools — done 2026-11-02: eleven tools through one invoke wrapper (record, rules line); wait with the idle guard; end-to-end tests with two agents. Found while testing: serve could hang on shutdown with a stream open (now five seconds at most).
25 25
- [ ] #9 The stdio shim
26 26
- [ ] #10 Radar
27 27
- [ ] #11 JSON API and spectra-api types
…
9 unchanged lines
modified src/cli/serve.rs +20 −3
…
56 unchanged lines
57 57
        shutdown_signal().await;
58 58
        let _ = stop_tx.send(true);
59 59
    };
60 -
    let (u, t, _) = tokio::join!(unix_srv, tcp_srv, signal);
61 -
    u?;
62 -
    t?;
60 +
    // After the signal, open connections get five seconds to finish: a
61 +
    // `subscriptions/listen` stream never does by itself.
62 +
    let (stopping_tx, mut stopping) = tokio::sync::watch::channel(false);
63 +
    tokio::spawn(async move {
64 +
        signal.await;
65 +
        let _ = stopping_tx.send(true);
66 +
    });
67 +
    let servers = async { tokio::join!(unix_srv, tcp_srv) };
68 +
    tokio::select! {
69 +
        (u, t) = servers => {
70 +
            u?;
71 +
            t?;
72 +
        }
73 +
        _ = async {
74 +
            let _ = stopping.wait_for(|s| *s).await;
75 +
            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
76 +
        } => {
77 +
            tracing::info!("connections still open five seconds after the signal; stopping anyway");
78 +
        }
79 +
    }
63 80
    daemon.shutdown().await;
64 81
    std::fs::remove_file(&socket).ok();
65 82
    println!("{} {}", ui::done("Stopped"), ui::dim(format!("ledger checkpointed at {} events", daemon.ledger.head().await.0)));
…
15 unchanged lines
modified tests/serve.rs +3 −87
…
1 unchanged lines
2 2
//! signing requests over the Unix socket, both MCP revisions, and every
3 3
//! refusal the spec promises. Hermetic: temporary homes, data and port 0.
4 4
5 -
use oxy_core::durable::{write_atomic, write_atomic_private};
6 -
use oxy_core::identity::{Delegate, SecretKey};
5 +
mod common;
6 +
7 +
use common::{add_agent, run, setup, text, Serving};
7 8
use serde_json::json;
8 9
use spectra_core::keys::KeyPair;
9 -
use spectra_core::principals::DELEGATION_SCOPE;
10 10
use spectra_mcp::client::{rpc_result, Client};
11 -
use std::path::{Path, PathBuf};
12 -
use std::process::{Child, Command, Output};
13 -
14 -
fn hermetic(dir: &Path) -> Command {
15 -
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
16 -
    c.env("SPECTRA_DATA", dir.join("data"))
17 -
        .env("OXY_HOME", dir.join("owner"))
18 -
        .env("HOME", dir)
19 -
        .env("XDG_DATA_HOME", dir.join("xdg"))
20 -
        .env("NO_COLOR", "1");
21 -
    c
22 -
}
23 -
24 -
fn run(dir: &Path, args: &[&str]) -> Output {
25 -
    hermetic(dir).args(args).output().unwrap()
26 -
}
27 -
28 -
fn text(o: &Output) -> String {
29 -
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
30 -
}
31 -
32 -
/// `oxy identity delegate add` in the owner's home.
33 -
fn delegate(owner_home: &Path, agent: &KeyPair) {
34 -
    let cur = KeyPair::load_home(owner_home).unwrap();
35 -
    let keys = owner_home.join("keys");
36 -
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
37 -
    let mut d = cur.identity.delegates.clone();
38 -
    d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None });
39 -
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
40 -
    let k = next.hash();
41 -
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
42 -
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
43 -
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
44 -
}
45 -
46 -
/// Make an agent's home, delegate to it, and add it.
47 -
fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair {
48 -
    let home = d.join("agents").join(name);
49 -
    let pair = KeyPair::create_home(&home, name, "").unwrap();
50 -
    delegate(&d.join("owner"), &pair);
51 -
    let mut args =
52 -
        vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"];
53 -
    args.extend_from_slice(extra);
54 -
    let o = run(d, &args);
55 -
    assert!(o.status.success(), "{}", text(&o));
56 -
    pair
57 -
}
58 -
59 -
struct Serving {
60 -
    child: Child,
61 -
    socket: PathBuf,
62 -
}
63 -
64 -
impl Serving {
65 -
    fn start(d: &Path) -> Serving {
66 -
        let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap();
67 -
        let socket = d.join("data/spectra.sock");
68 -
        for _ in 0..200 {
69 -
            if std::os::unix::net::UnixStream::connect(&socket).is_ok() {
70 -
                return Serving { child, socket };
71 -
            }
72 -
            std::thread::sleep(std::time::Duration::from_millis(25));
73 -
        }
74 -
        child.kill().ok();
75 -
        child.wait().ok();
76 -
        panic!("spectra serve did not start");
77 -
    }
78 -
    fn stop(mut self) {
79 -
        unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
80 -
        let status = self.child.wait().unwrap();
81 -
        assert!(status.success(), "a clean stop on SIGTERM");
82 -
        assert!(!self.socket.exists(), "the socket is removed");
83 -
    }
84 -
}
85 -
86 -
fn setup() -> tempfile::TempDir {
87 -
    let dir = tempfile::tempdir().unwrap();
88 -
    let d = dir.path();
89 -
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap();
90 -
    assert!(run(d, &["init"]).status.success());
91 -
    std::fs::create_dir_all(d.join("oxygen")).unwrap();
92 -
    assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success());
93 -
    dir
94 -
}
95 11
96 12
fn call(id: u64, name: &str, args: serde_json::Value) -> serde_json::Value {
97 13
    json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args,
…
115 unchanged lines