OXYGENHUB

Agents get their marks; contributors one row each; an assistant is tested when added 206c1fc1b06a

A repository's contributors show one row per account with each agent's
share beneath, as oxy log shows via; agents' names on change pages,
reviews and the ask buttons carry their brand mark, served at
/avatar/agent/{name} (Claude, ChatGPT, Perplexity, DeepSeek, Mistral,
Antigravity with the Gemini mark; others an identicon). Adding an
assistant tests the connection at once and says what it answered, and
each one has a TEST CONNECTION button.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 18:01 UTC
Signature
✔ signed by Andy McCall
Hash
206c1fc1b06a90b3388b36ad52ef832b9ba9e24ab182773e44022baf4c7c27bb
Depends on
101740454617 145306044896 19bb31ff1f5c 749d9a5fcd9f 7d2eb590051f 83d5052bf76c 8933faff2043 8e96499f2705 a160b98fb922 a1b9869565aa b5668108487f d23464c0ac60 de505df5cf8d e71846aa07b9 efd29a0033e8
Depended on by
ac689cd21adf c7067c7bda56 a7aa76ac52d7 ffb67a81188c c9aacc0e7dc1 155cab142b63 3ea0b8af2d46 c21ef37dd4a0
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
34 atoms, 8 tree operations, 20 files

Browse the tree at this change

added assets/agents/README.md +1 −0
1 +
Brand marks for AI agents' avatars (hub spec §6): the paths are from simple-icons (CC0), each set white on the brand's colour. `antigravity` uses the Gemini mark, Antigravity having none of its own there; Grok has no mark and falls back to the identicon.
added assets/agents/antigravity.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Google Gemini"><title>Google Gemini</title><circle cx="12" cy="12" r="12" fill="#4285F4"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></g></svg>
added assets/agents/chatgpt.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="OpenAI"><title>OpenAI</title><circle cx="12" cy="12" r="12" fill="#10A37F"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></g></svg>
added assets/agents/claude.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Claude"><title>Claude</title><circle cx="12" cy="12" r="12" fill="#D97757"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></g></svg>
added assets/agents/deepseek.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="DeepSeek"><title>DeepSeek</title><circle cx="12" cy="12" r="12" fill="#4D6BFE"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></g></svg>
added assets/agents/mistral.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Mistral AI"><title>Mistral AI</title><circle cx="12" cy="12" r="12" fill="#FA520F"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></g></svg>
added assets/agents/perplexity.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Perplexity"><title>Perplexity</title><circle cx="12" cy="12" r="12" fill="#20808D"/><g transform="translate(5 5) scale(0.5833)" fill="#fff"><path d="M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z"/></g></svg>
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Agents get their marks, contributors one row each.** A repository's contributors show one row per account with each agent's share beneath (`↳ via Claude Code · 1`), as `oxy log` does; agents' names on change pages, reviews and the ask buttons carry their brand mark (Claude, ChatGPT, Perplexity, DeepSeek, Mistral; Antigravity with the Gemini mark), served at `/avatar/agent/{name}`.
8 +
7 9
- **Jobs go to runners of the right architecture (oxygenflow#36).** Runners report each image's architecture; the hub matches a job's `arch` or its image's against the runner's, and the queue says what a waiting job asks for.
8 10
9 11
- **Sign in with GitHub or Google (#106).** Set up per provider under Administration (client id, sealed secret, on or off); the login page offers them. A first sign-in links the account with the same verified email, or makes an account when registration is open; connect or disconnect under Settings › Security.
…
111 unchanged lines
modified crates/hub/src/web/assistants.rs +8 −2
…
120 unchanged lines
121 121
            match assistants::create(&app.db, at.scope, &fields, &token).await? {
122 122
                Some(a) => {
123 123
                    accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok();
124 -
                    format!("{} added.", a.name)
124 +
                    // Try it at once, so a wrong endpoint or token shows here.
125 +
                    match assistants::test(app, &a).await {
126 +
                        Ok(answer) => format!("{} added and answered: {answer}", a.name),
127 +
                        Err(why) => {
128 +
                            format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name)
129 +
                        }
130 +
                    }
125 131
                }
126 132
                None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))),
127 133
            }
…
30 unchanged lines
158 164
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
159 165
            match assistants::test(app, &a).await {
160 166
                Ok(answer) => format!("{} answered: {answer}", a.name),
161 -
                Err(why) => return Ok(Err(format!("{} did not answer: {why}", a.name))),
167 +
                Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))),
162 168
            }
163 169
        }
164 170
        ("prompt", _) => {
…
14 unchanged lines
modified crates/hub/src/web/avatar.rs +27 −0
…
140 unchanged lines
141 141
    Ok("Picture saved.".into())
142 142
}
143 143
144 +
/// The brand mark for an AI agent, by the name its identity carries
145 +
/// (hub spec §6): `Claude Code` → Claude's, `ChatGPT` → OpenAI's, and so
146 +
/// on; `None` for a name the hub has no mark for.
147 +
pub fn agent_mark(name: &str) -> Option<&'static str> {
148 +
    const MARKS: &[(&str, &str)] = &[
149 +
        ("claude", include_str!("../../../../assets/agents/claude.svg")),
150 +
        ("anthropic", include_str!("../../../../assets/agents/claude.svg")),
151 +
        ("chatgpt", include_str!("../../../../assets/agents/chatgpt.svg")),
152 +
        ("openai", include_str!("../../../../assets/agents/chatgpt.svg")),
153 +
        ("codex", include_str!("../../../../assets/agents/chatgpt.svg")),
154 +
        ("perplexity", include_str!("../../../../assets/agents/perplexity.svg")),
155 +
        ("deepseek", include_str!("../../../../assets/agents/deepseek.svg")),
156 +
        ("mistral", include_str!("../../../../assets/agents/mistral.svg")),
157 +
        ("antigravity", include_str!("../../../../assets/agents/antigravity.svg")),
158 +
        ("gemini", include_str!("../../../../assets/agents/antigravity.svg")),
159 +
    ];
160 +
    let lower = name.to_lowercase();
161 +
    MARKS.iter().find(|(k, _)| lower.contains(k)).map(|(_, svg)| *svg)
162 +
}
163 +
164 +
/// `GET /avatar/agent/{name}`: an AI agent's brand mark, or its identicon.
165 +
pub async fn agent(Path(name): Path<String>) -> Response {
166 +
    let name = name.trim_end_matches(".svg");
167 +
    let svg = agent_mark(name).map(String::from).unwrap_or_else(|| identicon(name));
168 +
    ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], svg).into_response()
169 +
}
170 +
144 171
/// `GET /avatar/{name}`: the uploaded picture, or the identicon.
145 172
pub async fn avatar(State(app): State<Shared>, Path(name): Path<String>) -> Response {
146 173
    let name = name.trim_end_matches(".svg");
…
85 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
53 unchanged lines
54 54
    Router::new()
55 55
        .route("/", get(home::index))
56 56
        .route("/explore", get(home::explore))
57 +
        .route("/avatar/agent/{name}", get(avatar::agent))
57 58
        .route("/avatar/{name}", get(avatar::avatar))
58 59
        .route("/health", get(home::health))
59 60
        .route("/about", get(home::about))
…
639 unchanged lines
modified crates/hub/src/web/repo.rs +20 −0
…
107 unchanged lines
108 108
    for c in &mut o.contributors {
109 109
        c.account = linked.get(&c.key).cloned();
110 110
    }
111 +
    // One row per account: a person's keys over time are one contributor,
112 +
    // their agents' shares summed beneath (as `oxy log` shows via).
113 +
    let mut merged: Vec<view::Contributor> = Vec::new();
114 +
    for c in std::mem::take(&mut o.contributors) {
115 +
        match c.account.as_ref().and_then(|a| merged.iter_mut().find(|m| m.account.as_deref() == Some(a))) {
116 +
            Some(m) => {
117 +
                m.changes += c.changes;
118 +
                for (who, n) in c.via {
119 +
                    match m.via.iter_mut().find(|(w, _)| *w == who) {
120 +
                        Some((_, k)) => *k += n,
121 +
                        None => m.via.push((who, n)),
122 +
                    }
123 +
                }
124 +
                m.via.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
125 +
            }
126 +
            None => merged.push(c),
127 +
        }
128 +
    }
129 +
    merged.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name)));
130 +
    o.contributors = merged;
111 131
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
112 132
    let readme_html = o.readme_html(&rs, &o.tree.target.label);
113 133
    let watching = match viewer.account() {
…
392 unchanged lines
modified crates/hub/tests/accounts.rs +4 −4
…
678 unchanged lines
679 679
        )
680 680
    };
681 681
    let r = post(app, "/admin/assistants", Some(&andy), &add("Claude", "")).await;
682 -
    assert!(r.body.contains("Claude added.") && r.body.contains("token set"), "{}", r.body);
682 +
    assert!(r.body.contains("Claude added and answered: OK from mock") && r.body.contains("token set"), "{}", r.body);
683 683
    assert!(!r.body.contains("sk-test-secret"), "the token never comes back");
684 684
    let site = assistants::list(&app.db, Scope::Site).await.unwrap();
685 685
    assert_eq!(site.len(), 1);
…
17 unchanged lines
703 703
    assert!(r.body.contains("Claude answered: OK from mock"), "{}", r.body);
704 704
    {
705 705
        let calls = seen.lock().unwrap();
706 -
        assert_eq!(calls.len(), 1);
706 +
        assert_eq!(calls.len(), 2, "once on add, once on test");
707 707
        assert!(calls[0].starts_with("Bearer sk-test-secret "), "{}", calls[0]);
708 708
        assert!(calls[0].contains("\"model\":\"claude-opus-5-5\"") && calls[0].contains("Reply with the single word OK"), "{}", calls[0]);
709 709
    }
…
39 unchanged lines
749 749
    .await;
750 750
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
751 751
    let r = post(app, "/orgs/acme/settings/assistants", Some(&andy), &add("DeepSeek", "")).await;
752 -
    assert!(r.body.contains("DeepSeek added."), "{}", r.body);
752 +
    assert!(r.body.contains("DeepSeek added and answered"), "{}", r.body);
753 753
    let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap();
754 754
    assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}");
755 755
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
…
7 unchanged lines
763 763
    let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await;
764 764
    assert!(r.body.contains("Claude is off."), "{}", r.body);
765 765
    let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await;
766 -
    assert!(r.body.contains("Claude added."), "{}", r.body);
766 +
    assert!(r.body.contains("Claude added and answered"), "{}", r.body);
767 767
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
768 768
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]);
769 769
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
…
215 unchanged lines
modified crates/hub/tests/edits.rs +5 −1
…
563 unchanged lines
564 564
    assert_eq!(agent_change["author"]["account"], "andy");
565 565
    // Contributors: the agent's change counts for Andy, named under him.
566 566
    let overview = send(app, "GET", "/andy/demo", None, None).await.body;
567 -
    assert!(overview.contains("via Antigravity 1"), "{overview}");
567 +
    assert!(overview.contains("/avatar/agent/Antigravity") && overview.contains("Antigravity <span class=\"mute small\" title=\"changes signed by this agent for them\">· 1</span>"), "{overview}");
568 +
    assert_eq!(overview.matches("class=\"avatar sm\" src=\"/avatar/andy\"").count(), 1, "one row for Andy, whatever his keys");
569 +
    let mark = send(app, "GET", "/avatar/agent/Claude%20Code", None, None).await;
570 +
    assert!(mark.body.contains("<title>Claude</title>"), "a known agent gets its brand mark");
571 +
    assert!(send(app, "GET", "/avatar/agent/HAL", None, None).await.body.contains("<svg"), "an unknown one its identicon");
568 572
    // The badge: no runs yet.
569 573
    let badge = send(app, "GET", "/andy/demo/flow/badge.svg", None, None).await;
570 574
    assert_eq!(badge.status, StatusCode::OK);
…
220 unchanged lines
modified crates/hub/tests/proposals.rs +2 −1
…
385 unchanged lines
386 386
        &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"),
387 387
    )
388 388
    .await;
389 -
    assert!(r.body.contains("Claude added."), "{}", r.body);
389 +
    assert!(r.body.contains("Claude added and answered"), "{}", r.body);
390 +
    seen.lock().unwrap().clear();
390 391
    let r =
391 392
        post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await;
392 393
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
…
90 unchanged lines
modified templates/assistants_panel.html +2 −2
…
22 unchanged lines
23 23
      {% if o.on() %}<span class="tag head">on</span>{% else if o.off_above %}<span class="tag dim" title="An assistant of this name is turned off at a level above">off above</span>{% else %}<span class="tag dim">off</span>{% endif %}
24 24
    </div>
25 25
    <p class="small mute"><code>{{ r.endpoint }}</code> · token {% if r.has_token() %}set{% else %}none{% endif %} · context {{ r.budget }} characters</p>
26 -
    <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="test">test</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form>
26 +
    <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="test" title="Send a one-line message and show the answer or the error">TEST CONNECTION</button> <button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form>
27 27
    <details class="hook-edit"><summary class="small">edit</summary>
28 28
      <form class="card" method="post" action="{{ base }}/assistants">
29 29
        <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><input type="hidden" name="action" value="update">
…
25 unchanged lines
55 55
    <label for="aheaders">Extra headers</label><textarea id="aheaders" name="headers" rows="2" placeholder="Name: value"></textarea>
56 56
    <label for="abudget">Context budget (characters)</label><input type="text" id="abudget" name="budget" value="{{ a.default_budget }}">
57 57
    <div class="choice"><label><input type="checkbox" name="enabled" value="1" checked> on</label></div>
58 -
    <button type="submit">ADD</button>
58 +
    <button type="submit">ADD AND TEST</button>
59 59
  </form>
60 60
  <form class="card" method="post" action="{{ base }}/assistants">
61 61
    <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="prompt">
…
8 unchanged lines
modified templates/change.html +1 −1
…
9 unchanged lines
10 10
  {{ body_html|safe }}
11 11
  <dl class="facts">
12 12
    <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute">&lt;{{ c.view.author.email }}&gt;</span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %}
13 -
    <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute">&lt;{{ v.email }}&gt;</span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %}
13 +
    <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> <img class="avatar xs" src="/avatar/agent/{{ v.name }}" alt="" width="16" height="16"> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute">&lt;{{ v.email }}&gt;</span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %}
14 14
    <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd>
15 15
    <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd>
16 16
    <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd>
…
12 unchanged lines
modified templates/overview.html +1 −1
…
47 unchanged lines
48 48
    {% if !o.contributors.is_empty() %}
49 49
    <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3>
50 50
    <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %}
51 -
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<br><span class="mute small">via {% for v in c.via %}{{ v.0 }} {{ v.1 }}{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</li>
51 +
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<ul class="plain via-list">{% for v in c.via %}<li><span class="mute" aria-hidden="true">↳</span> <span class="mute small">via</span> <img class="avatar sm" src="/avatar/agent/{{ v.0 }}" alt="" width="16" height="16"> {{ v.0 }} <span class="mute small" title="changes signed by this agent for them">· {{ v.1 }}</span></li>{% endfor %}</ul>{% endif %}</li>
52 52
    {% endfor %}</ul>
53 53
    {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %}
54 54
    {% endif %}
…
20 unchanged lines
modified templates/proposal.html +2 −2
…
110 unchanged lines
111 111
  <div class="tl-item review-{{ r.row.verdict }}">
112 112
    <img class="tl-avatar" src="/avatar/{{ r.row.author }}" alt="" width="36" height="36">
113 113
    <div class="tl-card">
114 -
      <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a>{% if !r.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant answered for {{ r.row.author }}, who asked it">agent</span> <strong>{{ r.row.via }}</strong>{% endif %} {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div>
114 +
      <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a>{% if !r.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant answered for {{ r.row.author }}, who asked it">agent</span> <img class="avatar xs" src="/avatar/agent/{{ r.row.via }}" alt="" width="16" height="16"> <strong>{{ r.row.via }}</strong>{% endif %} {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div>
115 115
      {% if !r.row.body.is_empty() %}<div class="tl-text">{{ r.body_html|safe }}</div>{% endif %}
116 116
    </div>
117 117
  </div>
…
86 unchanged lines
204 204
        <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div>
205 205
      </form></details>{% endif %}</div>
206 206
    {% if requested.is_empty() %}<p class="side-empty">No reviews requested</p>{% else %}<ul class="side-people">{% for r in requested %}<li><a href="/{{ r }}"><img class="avatar xs" src="/avatar/{{ r }}" alt="" loading="lazy"> {{ r }}</a> <span class="side-note-inline">awaiting review</span></li>{% endfor %}</ul>{% endif %}
207 -
    {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><span class="tag">agent</span> ask {{ a }}</button></form>{% endfor %}</div>{% endif %}
207 +
    {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><img class="avatar xs" src="/avatar/agent/{{ a }}" alt="" width="14" height="14"> ask {{ a }}</button></form>{% endfor %}</div>{% endif %}
208 208
  </section>
209 209
  <section class="side-sec">
210 210
    <div class="side-head"><h3>Labels</h3>
…
40 unchanged lines
modified templates/style.css +3 −0
…
565 unchanged lines
566 566
.bar .inline-find { flex: 1 1 200px; }
567 567
ul.contributors li { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0; }
568 568
ul.contributors img.avatar.sm { width: 20px; height: 20px; vertical-align: middle; }
569 +
ul.contributors ul.via-list { flex-basis: 100%; margin: 0 0 0 26px; padding: 0; }
570 +
ul.contributors ul.via-list li { margin: 2px 0; }
571 +
ul.contributors ul.via-list img.avatar.sm { width: 16px; height: 16px; }
569 572
.avatar-none { display: inline-block; width: 20px; text-align: center; color: var(--mute); }
570 573
.lang-bar { display: flex; height: 8px; overflow: hidden; background: var(--soft); margin: 6px 0 8px; }
571 574
.lang-bar > span { display: block; height: 100%; }
…
227 unchanged lines