From dc70957aac0eb889ff566cda8281613c106acdb5 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 22 Aug 2026 20:53:39 +0900 Subject: [PATCH 1/3] feat(room): seat a participant on their connection, named and coloured at join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参加者の同一性を名前から接続へ移し、名前と色を参加の時点の宣言にする。 - 名簿を `BTreeMap` に変更。同名の 2 参加者が 1 エントリに畳まれ、 片方の切断で両方が消える経路を塞ぐ。 - `hello` に `hue` を追加し protocol を 3 へ。名前と色は同じ宣言であり、 同じフレームで届く。宣言が無いときはキーごと省く(導出と区別するため)。 - `room_join` も同じ `seat` を通る。人間とセッションで着席の経路を分けない。 - `RoomMessage` に `hue` を載せる。画面が名前で色を引くと、名前が一意でない 瞬間に別人の色を引くため。 - `start_session` が名前と色を受け取る。タブ属性ではなく起動ごとの値であり、 `TabConfig.name` は起動するCLIのラベルに戻る。 - `.mcp.json` の登録鍵を参加者ごとに分ける(`server_name_for`)。同一作業 ディレクトリの 2 セッションが互いの識別を上書きする経路を塞ぐ。前回起動の エントリ(部屋アドレスが異なるもの)は登録時に掃除する。 #40 Co-Authored-By: Claude Opus 5 --- crates/mcp-config/src/lib.rs | 264 +++++++++++++++++++++++++++---- sidecar/src/index.ts | 35 +++- sidecar/test/round-trip.test.mjs | 57 ++++++- src-tauri/src/config.rs | 9 ++ src-tauri/src/room.rs | 172 +++++++++++++++----- src-tauri/src/session.rs | 41 ++++- 6 files changed, 499 insertions(+), 79 deletions(-) diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index ca42612..bbf5be6 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -17,9 +17,63 @@ use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; -/// The name the sidecar is registered under in `.mcp.json`. The launch flag -/// carries the same name (`server:`), so the two must not drift apart. -pub const SERVER_NAME: &str = "liplus-chat-room"; +/// Prefix of the name the sidecar is registered under in `.mcp.json`. +/// +/// The full name is per participant (`server_name_for`), not one fixed key. Two +/// sessions pointed at the same working directory write into the same file, and +/// a single key means the second launch overwrites the first one's name, hue +/// and room address — the identity the first session was launched with is gone +/// while that session is still running (#40). +pub const SERVER_PREFIX: &str = "liplus-chat-room"; + +/// The `.mcp.json` key, and the `server:` tag, for one participant. +/// +/// A function of the declared name alone, so relaunching under the same name +/// reuses its entry rather than accumulating a new one per launch. The readable +/// half is a slug of the name; the hash is what makes the key total — a name +/// with no ASCII in it (`マスター`) slugs to nothing, and two names can slug +/// alike (`Lin` and `lin!`), and a key that collides is the collision this whole +/// function exists to remove. +pub fn server_name_for(agent_name: &str) -> String { + let slug = slugify(agent_name); + let hash = fnv1a(agent_name); + if slug.is_empty() { + format!("{SERVER_PREFIX}-{hash:08x}") + } else { + format!("{SERVER_PREFIX}-{slug}-{hash:08x}") + } +} + +/// Lowercase ASCII alphanumerics, everything else a single separator. +/// +/// This rides in a command-line flag (`server:`) as well as in JSON, so +/// it stays inside the character set every shell and console on the way leaves +/// alone. Legibility only — `server_name_for` carries the uniqueness. +fn slugify(name: &str) -> String { + let mut out = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + } else if !out.ends_with('-') && !out.is_empty() { + out.push('-'); + } + if out.len() >= 24 { + break; + } + } + out.trim_matches('-').to_string() +} + +/// FNV-1a, 32-bit. The same stable-spread hash the frontend derives a hue with; +/// one hash idea in the codebase rather than two. +fn fnv1a(text: &str) -> u32 { + let mut hash: u32 = 2_166_136_261; + for byte in text.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(16_777_619); + } + hash +} /// Flags that silently stop channel pushes from arriving. pub const INCOMPATIBLE_FLAGS: &[&str] = @@ -34,6 +88,11 @@ pub struct RoomRegistration<'a> { pub token: &'a str, /// Display name this session speaks under. pub agent_name: &'a str, + /// Hue this session declared, in oklch degrees, or `None` when it declared + /// none. Absent rather than a default: the room derives a hue from the name + /// for an undeclared participant, and a value written here would be a + /// declaration the person never made. + pub agent_hue: Option, /// Absolute path of the sidecar entry point. pub sidecar_entry: &'a Path, /// Absolute path of the TypeScript runner that executes the entry point. @@ -66,8 +125,12 @@ pub const CHANNEL_FLAG: &str = "--dangerously-load-development-channels"; /// and takes the whole room down, and two copies of this flag is the same /// shape. Merging also means the room's input path cannot be dropped by /// configuring a different server — losing it is losing the room. -pub fn channel_launch_args(base: &[String]) -> Vec { - let room = format!("server:{SERVER_NAME}"); +/// +/// `server_name` is this participant's own (`server_name_for`), so the flag and +/// the `.mcp.json` key stay one fact even though that fact now differs per +/// session. +pub fn channel_launch_args(base: &[String], server_name: &str) -> Vec { + let room = format!("server:{server_name}"); let mut args = base.to_vec(); if args.iter().any(|arg| *arg == room) { @@ -148,6 +211,7 @@ fn spawn_form(runner: &Path, entry: &Path) -> (&'static str, Vec) { /// touched; existing servers and unrelated top-level keys survive verbatim. pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result { let (command, args) = spawn_form(room.sidecar_runner, room.sidecar_entry); + let server_name = server_name_for(room.agent_name); let path = dir.join(".mcp.json"); let mut root: Value = if path.exists() { @@ -175,17 +239,42 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result url == room.room_url, + _ => true, + } + }); + + let mut env = Map::new(); + env.insert("LIPLUS_ROOM_URL".into(), json!(room.room_url)); + env.insert("LIPLUS_ROOM_TOKEN".into(), json!(room.token)); + env.insert("LIPLUS_AGENT_NAME".into(), json!(room.agent_name)); + env.insert("LIPLUS_ROOM_ID".into(), json!("liplus-chat")); + // Only when declared. An undeclared participant is a participant the room + // derives a hue for, which is not the same state as one who chose that hue. + if let Some(hue) = room.agent_hue { + env.insert("LIPLUS_AGENT_HUE".into(), json!(format!("{hue:.1}"))); + } + + servers.insert( + server_name, json!({ "command": command, "args": args, - "env": { - "LIPLUS_ROOM_URL": room.room_url, - "LIPLUS_ROOM_TOKEN": room.token, - "LIPLUS_AGENT_NAME": room.agent_name, - "LIPLUS_ROOM_ID": "liplus-chat", - }, + "env": Value::Object(env), }), ); @@ -228,6 +317,7 @@ mod tests { room_url: "ws://127.0.0.1:1234", token: "tok", agent_name: "Lin", + agent_hue: None, sidecar_entry: entry, sidecar_runner: runner, } @@ -246,11 +336,20 @@ mod tests { register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); let json = read(&path); - let server = &json["mcpServers"][SERVER_NAME]; + let server = &json["mcpServers"][server_name_for("Lin")]; assert_eq!(server["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:1234"); assert_eq!(server["env"]["LIPLUS_ROOM_TOKEN"], "tok"); assert_eq!(server["env"]["LIPLUS_AGENT_NAME"], "Lin"); assert_eq!(server["env"]["LIPLUS_ROOM_ID"], "liplus-chat"); + // Undeclared is the key absent, not a default value: a hue written here + // would be a declaration this participant never made. + assert!( + !server["env"] + .as_object() + .expect("env") + .contains_key("LIPLUS_AGENT_HUE"), + "an undeclared hue must leave no key behind" + ); // Absolute paths and nothing looked up by name: the CLI runs this from // the user's own directory, where `npx tsx` found no tsx and asked to @@ -284,34 +383,128 @@ mod tests { let json = read(&path); assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server"); assert_eq!(json["unrelated"], 42); - assert!(json["mcpServers"][SERVER_NAME].is_object()); + assert!(json["mcpServers"][server_name_for("Lin")].is_object()); } #[test] - fn re_registering_replaces_only_its_own_entry() { + fn re_registering_the_same_name_replaces_its_own_entry() { let scratch = Scratch::new(); let entry = PathBuf::from(ENTRY); let runner = PathBuf::from(RUNNER); register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("first"); let second = RoomRegistration { - room_url: "ws://127.0.0.1:9999", + room_url: "ws://127.0.0.1:1234", token: "tok2", - agent_name: "Lay", + agent_name: "Lin", + agent_hue: Some(145.0), sidecar_entry: &entry, sidecar_runner: &runner, }; let path = register_sidecar(scratch.path(), &second).expect("second"); let json = read(&path); - let server = &json["mcpServers"][SERVER_NAME]; - assert_eq!(server["env"]["LIPLUS_ROOM_URL"], "ws://127.0.0.1:9999"); - assert_eq!(server["env"]["LIPLUS_AGENT_NAME"], "Lay"); + let server = &json["mcpServers"][server_name_for("Lin")]; + assert_eq!(server["env"]["LIPLUS_ROOM_TOKEN"], "tok2"); + assert_eq!(server["env"]["LIPLUS_AGENT_HUE"], "145.0"); assert_eq!( json["mcpServers"].as_object().expect("servers").len(), 1, - "re-registering must not accumulate entries" + "relaunching under one name must not accumulate entries" + ); + } + + #[test] + fn two_participants_in_one_directory_keep_separate_entries() { + // The failure this key scheme exists for: two sessions pointed at the + // same working directory. Under one fixed key the second launch + // overwrote the first one's name while that session was still running, + // so the room heard one identity twice (#40). + let scratch = Scratch::new(); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("Lin"); + let lay = RoomRegistration { + room_url: "ws://127.0.0.1:1234", + token: "tok", + agent_name: "Lay", + agent_hue: Some(25.0), + sidecar_entry: &entry, + sidecar_runner: &runner, + }; + let path = register_sidecar(scratch.path(), &lay).expect("Lay"); + + let json = read(&path); + assert_eq!( + json["mcpServers"][server_name_for("Lin")]["env"]["LIPLUS_AGENT_NAME"], + "Lin", + "the first session's identity must survive the second launch" + ); + assert_eq!( + json["mcpServers"][server_name_for("Lay")]["env"]["LIPLUS_AGENT_NAME"], + "Lay" + ); + assert_eq!(json["mcpServers"].as_object().expect("servers").len(), 2); + } + + #[test] + fn entries_from_a_previous_run_go_and_foreign_ones_stay() { + // The room binds a fresh port every run, so an entry carrying another + // address is one no sidecar can reach. Left behind, every CLI started + // in this directory would spawn one more sidecar retrying a dead port, + // and the file would grow by one key per name ever used here. + let scratch = Scratch::new(); + std::fs::write( + scratch.path().join(".mcp.json"), + r#"{"mcpServers":{ + "liplus-chat-room": {"env":{"LIPLUS_ROOM_URL":"ws://127.0.0.1:1"}}, + "liplus-chat-room-lay-00000000": {"env":{"LIPLUS_ROOM_URL":"ws://127.0.0.1:1234"}}, + "liplus-chat-room-theirs": {"command":"not-ours"}, + "theirs": {"command":"their-server"} + }}"#, + ) + .expect("seed"); + + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + let path = + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); + + let json = read(&path); + let servers = json["mcpServers"].as_object().expect("servers"); + assert!( + !servers.contains_key("liplus-chat-room"), + "an entry from a previous run must go" + ); + assert!( + servers.contains_key("liplus-chat-room-lay-00000000"), + "a live sibling of this run must stay" + ); + assert!( + servers.contains_key("liplus-chat-room-theirs"), + "an entry with no room address of ours is not ours to remove" ); + assert!(servers.contains_key("theirs")); + assert!(servers.contains_key(&server_name_for("Lin"))); + } + + #[test] + fn a_server_name_is_a_function_of_the_declared_name() { + // Readable where the name has ASCII in it, and total where it has none: + // a name that slugged to nothing would put every such participant back + // on one key, which is the collision this replaces. + assert_eq!(server_name_for("Lin"), server_name_for("Lin")); + assert_ne!(server_name_for("Lin"), server_name_for("Lay")); + assert!(server_name_for("Lin").starts_with("liplus-chat-room-lin-")); + assert!(server_name_for("マスター").starts_with("liplus-chat-room-")); + assert_ne!(server_name_for("マスター"), server_name_for("ますたー")); + // Two names that slug alike are still two entries. + assert_ne!(server_name_for("Lin"), server_name_for("lin!")); + // Nothing outside the set a console and a JSON key both leave alone. + assert!(server_name_for("Lin さん / 2") + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-')); } #[test] @@ -365,13 +558,14 @@ mod tests { .map(|s| s.to_string()) .collect(); - let merged = channel_launch_args(&base); + let room = server_name_for("Lin"); + let merged = channel_launch_args(&base, &room); assert_eq!( merged, vec![ "--dangerously-skip-permissions".to_string(), CHANNEL_FLAG.to_string(), - format!("server:{SERVER_NAME}"), + format!("server:{room}"), "server:github-webhook-mcp".to_string(), ] ); @@ -384,8 +578,9 @@ mod tests { #[test] fn does_not_add_the_room_twice() { - let base = vec![CHANNEL_FLAG.to_string(), format!("server:{SERVER_NAME}")]; - assert_eq!(channel_launch_args(&base), base); + let room = server_name_for("Lin"); + let base = vec![CHANNEL_FLAG.to_string(), format!("server:{room}")]; + assert_eq!(channel_launch_args(&base, &room), base); } #[test] @@ -416,13 +611,26 @@ mod tests { fn the_launch_flag_names_the_server_the_config_registers() { // The flag and the `.mcp.json` key are one fact in two places; a drift // between them fails as a room that never receives anything. - let args = channel_launch_args(&["--verbose".to_string()]); + let scratch = Scratch::new(); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + let path = + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); + let registered = read(&path)["mcpServers"] + .as_object() + .expect("servers") + .keys() + .next() + .expect("one entry") + .clone(); + + let args = channel_launch_args(&["--verbose".to_string()], &server_name_for("Lin")); assert_eq!( args, vec![ "--verbose".to_string(), "--dangerously-load-development-channels".to_string(), - format!("server:{SERVER_NAME}"), + format!("server:{registered}"), ] ); } diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index d688cc8..a919a04 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -38,7 +38,23 @@ const AGENT_NAME = process.env.LIPLUS_AGENT_NAME ?? "session"; const ROOM_TOKEN = process.env.LIPLUS_ROOM_TOKEN ?? ""; const CHAT_ID = process.env.LIPLUS_ROOM_ID ?? "liplus-chat"; -const PROTOCOL_VERSION = 2; +/** + * The hue this session was launched under, in oklch degrees, or null when it + * was launched without one. + * + * Null rather than a default. An undeclared participant is one the room derives + * a colour for from their name, and a number invented here would be indelible: + * the room cannot tell a declaration from a fallback once it is on the wire. + */ +const AGENT_HUE = readHue(process.env.LIPLUS_AGENT_HUE); + +function readHue(raw: string | undefined): number | null { + if (raw === undefined || raw.trim() === "") return null; + const hue = Number(raw); + return Number.isFinite(hue) ? hue : null; +} + +const PROTOCOL_VERSION = 3; function log(line: string): void { process.stderr.write(`[liplus-chat sidecar] ${line}\n`); @@ -47,7 +63,7 @@ function log(line: string): void { // ── Room frames ────────────────────────────────────────────────────────────── // // Sidecar -> room: -// { type: "hello", protocol, name } +// { type: "hello", protocol, name, hue? } // { type: "post", message_id, content, to?, ts } // Room -> sidecar: // { type: "post", message_id, speaker, content, to?, ts } @@ -61,6 +77,12 @@ function log(line: string): void { // everyone regardless — whether an utterance is yours to answer is decided // here, by the agent, not by the room narrowing its delivery. // +// `hello` is where this session says who it is: the name it answers to and, +// when it was launched with one, the hue it is drawn in. Both arrive from the +// launch (`LIPLUS_AGENT_NAME` / `LIPLUS_AGENT_HUE`) rather than from anything +// this file decides, because both are the person's declaration, made at the +// moment of joining. +// // A participant never receives its own post. The room drops it on the way out, // judged on the connection it arrived on, so nothing here has to recognise // itself — and a name collision cannot make this side swallow someone else's @@ -269,7 +291,14 @@ function connectRoom(): void { retryCount = 0; lastError = ""; log(`room socket: connected as "${AGENT_NAME}"`); - sendToRoom({ type: "hello", protocol: PROTOCOL_VERSION, name: AGENT_NAME }); + // The hue key is omitted when none was declared, for the same reason `to` + // is: the room must be able to tell "declared nothing" from a value. + sendToRoom({ + type: "hello", + protocol: PROTOCOL_VERSION, + name: AGENT_NAME, + ...(AGENT_HUE === null ? {} : { hue: AGENT_HUE }), + }); pingTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.ping(); }, PING_INTERVAL); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index d4c891b..3ea2815 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -71,6 +71,7 @@ test("a room post reaches the channel, and say_to_room reaches the room", async ...process.env, LIPLUS_ROOM_URL: `ws://127.0.0.1:${port}`, LIPLUS_AGENT_NAME: "test-agent", + LIPLUS_AGENT_HUE: "145", LIPLUS_ROOM_ID: "test-room", }, stdio: ["pipe", "pipe", "pipe"], @@ -189,8 +190,13 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // ── room -> agent ────────────────────────────────────────────────────────── await withTimeout(connected.promise, "sidecar to connect to the room"); const hello = await withTimeout(helloSeen.promise, "hello frame"); + // Who this session is in the room, declared at the moment of joining: the + // name it answers to, and the hue it is drawn in. Both come from the launch, + // not from a stored tab attribute — a stored one made every session answer to + // the same name (#40). assert.equal(hello.name, "test-agent"); - assert.equal(hello.protocol, 2); + assert.equal(hello.hue, 145); + assert.equal(hello.protocol, 3); roomSocket.send( JSON.stringify({ @@ -303,3 +309,52 @@ test("a room post reaches the channel, and say_to_room reaches the room", async "a send with no room attached must report failure, not silence", ); }); + +test("a session launched without a declared hue says so by omission", async (t) => { + // The undeclared state has to survive the wire. The room derives a colour + // from the name for a participant who chose none, and it can only do that + // while "chose none" is still distinguishable from a number a default put + // there (#40). + const http = createServer(); + const wss = new WebSocketServer({ server: http }); + await new Promise((r) => http.listen(0, "127.0.0.1", r)); + const port = http.address().port; + + const helloSeen = deferred(); + wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const frame = JSON.parse(raw.toString()); + if (frame.type === "hello") helloSeen.resolve(frame); + }); + }); + + const child = spawn( + process.execPath, + [join(REPO, "node_modules", "tsx", "dist", "cli.mjs"), ENTRY], + { + cwd: REPO, + env: { + ...process.env, + LIPLUS_ROOM_URL: `ws://127.0.0.1:${port}`, + LIPLUS_AGENT_NAME: "no-colour", + LIPLUS_AGENT_HUE: "", + LIPLUS_ROOM_ID: "test-room", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + // Nothing reads either pipe in this test, and a full one would block the + // sidecar before it ever reaches the socket. + child.stdout.resume(); + child.stderr.resume(); + + t.after(() => { + child.kill(); + wss.close(); + http.close(); + }); + + const hello = await withTimeout(helloSeen.promise, "hello frame"); + assert.equal(hello.name, "no-colour"); + assert.equal("hue" in hello, false, "an undeclared hue must carry no key"); +}); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7573617..4ef400a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -6,6 +6,11 @@ use tauri::Manager; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TabConfig { pub id: String, + /// The tab's label — which CLI this launches. Not the name a session speaks + /// under in the room: that is declared per launch and passed to + /// `start_session` beside the tab. A tab is a launch recipe and lives in + /// this file; who joins the room is a choice made at the moment of joining, + /// and a stored one made every session answer to the same name (#40). pub name: String, pub command: String, pub args: Vec, @@ -50,6 +55,10 @@ impl Default for AppConfig { // spec drops the second vendor to keep that premise out of the // design. Shipping a tab that cannot join the room by default would // present a session that never speaks. See docs/0-requirements.md. + // + // `Claude Code` names the CLI, and that is all it names now. It used to + // be the name the session took in the room as well, which is why every + // session took that one. AppConfig { tabs: vec![TabConfig { id: "tab-1".to_string(), diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index e5e706a..f0db8e8 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -6,7 +6,7 @@ //! //! Frames on the wire are the room protocol: //! -//! sidecar -> room : { type: "hello", protocol, name } +//! sidecar -> room : { type: "hello", protocol, name, hue? } //! both ways : { type: "post", message_id, speaker, content, to?, ts } //! //! One frame kind carries speech, whoever produced it. A person and a session @@ -24,13 +24,23 @@ //! never read off the frame. A sender cannot claim to be someone else, and the //! roster and the attribution cannot disagree. //! +//! `hello` carries who this participant is in the room: the name they answer to +//! and, optionally, the hue they chose to be drawn in. Both are declarations +//! made at the moment of joining, which is the one moment a participant has to +//! make them — the same moment, and the same pair, the screen's own person +//! declares through `room_join`. +//! +//! Roster identity is the connection, not the name. Two participants may answer +//! to one name; they are still two, and one of them leaving must not take the +//! other off the roster (#40). +//! //! Everything the frontend needs arrives as a `room-message` event. The room //! never reads a CLI's terminal output; that is not a message source. use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::BTreeMap; use std::sync::Arc; use tauri::{AppHandle, Emitter}; use tokio::net::TcpListener; @@ -43,7 +53,8 @@ use uuid::Uuid; /// Bumped when a frame's shape changes in a way a sidecar must notice. /// /// 2: `say` and `reply` collapsed into one `post` frame; `agent` became `name`. -pub const PROTOCOL_VERSION: u32 = 2; +/// 3: `hello` carries the declared `hue` beside the name. +pub const PROTOCOL_VERSION: u32 = 3; /// One post of the room, as the frontend sees it. /// @@ -55,6 +66,11 @@ pub struct RoomMessage { pub message_id: String, /// Display name of the speaker. pub speaker: String, + /// The hue the speaker declared, in oklch degrees, or `None` when they + /// declared none. Carried on the message rather than looked up by name on + /// the screen: a name is not an identity here, so a lookup by name is the + /// wrong participant as soon as two answer to one name. + pub hue: Option, pub content: String, pub to: Option, pub ts: String, @@ -62,6 +78,23 @@ pub struct RoomMessage { pub own: bool, } +/// One participant of the room, as the roster shows them. +/// +/// `id` is the connection, and it is what the roster is keyed on. `name` is +/// what they are called and what a post can be addressed to — a display and +/// addressing attribute, never the identity. +#[derive(Debug, Clone, Serialize)] +pub struct Participant { + pub id: String, + pub name: String, + /// Declared at join; `None` when this participant declared none, which the + /// screen answers by deriving one from the name. + pub hue: Option, + /// True for this screen's own person. Viewer-relative, like a message's + /// `own`, and there is one screen. + pub own: bool, +} + /// A post as it happened, before any viewer-relative framing. struct Post { message_id: String, @@ -89,19 +122,28 @@ struct IncomingFrame { kind: String, message_id: Option, name: Option, + hue: Option, content: Option, to: Option, ts: Option, protocol: Option, } +/// What the room holds about one seated participant. +#[derive(Debug, Clone)] +struct Seat { + name: String, + hue: Option, +} + struct RoomInner { port: Option, - /// Everyone in the room, people and sessions alike. - participants: BTreeSet, - /// The name this screen's person currently answers to, so a rename - /// replaces the roster entry rather than adding a second one. - local_name: Option, + /// Everyone in the room, people and sessions alike, keyed by the connection + /// they are in it on. Keyed on the connection rather than the name because + /// a name is not unique: under a name-keyed roster two participants called + /// `Claude Code` were one entry, and either of them disconnecting removed + /// both (#40). + participants: BTreeMap, } /// Shared handle to the room socket. Cloneable; all clones share one room. @@ -124,8 +166,7 @@ impl RoomState { RoomState { inner: Arc::new(Mutex::new(RoomInner { port: None, - participants: BTreeSet::new(), - local_name: None, + participants: BTreeMap::new(), })), to_participants, token: Uuid::new_v4().to_string(), @@ -141,39 +182,69 @@ impl RoomState { self.inner.lock().port } - pub fn participants(&self) -> Vec { - self.inner.lock().participants.iter().cloned().collect() - } - - fn set_port(&self, port: u16) { - self.inner.lock().port = Some(port); + /// The roster, in name order. + /// + /// Ordered by name because that is what is read, and tie-broken on the id + /// so two participants sharing a name hold a stable order rather than + /// swapping places between emits. + pub fn participants(&self) -> Vec { + let inner = self.inner.lock(); + let mut roster: Vec = inner + .participants + .iter() + .map(|(id, seat)| Participant { + id: id.clone(), + name: seat.name.clone(), + hue: seat.hue, + own: *id == self.local_origin, + }) + .collect(); + roster.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + roster } - fn add_participant(&self, name: &str) { - self.inner.lock().participants.insert(name.to_string()); + /// The hue declared by whoever is on `origin`, for stamping onto a post. + fn hue_of(&self, origin: &str) -> Option { + self.inner.lock().participants.get(origin).and_then(|seat| seat.hue) } - fn remove_participant(&self, name: &str) { - self.inner.lock().participants.remove(name); + fn set_port(&self, port: u16) { + self.inner.lock().port = Some(port); } - /// Seat this screen's person in the room under `name`, replacing an - /// earlier seat. + /// Seat a participant on the connection they arrived on. /// - /// Returns true when the roster changed, so a rename that is not a rename - /// does not emit a roster event. - fn seat_local(&self, name: &str) -> bool { + /// One seat per connection, so re-seating replaces rather than adds: a + /// participant who renames themselves is the same participant. + /// + /// Returns true when the roster changed, so a declaration that declares + /// nothing new does not emit a roster event. + fn seat(&self, origin: &str, name: &str, hue: Option) -> bool { let mut inner = self.inner.lock(); - if inner.local_name.as_deref() == Some(name) { - return false; - } - if let Some(previous) = inner.local_name.take() { - inner.participants.remove(&previous); + let seat = Seat { + name: name.to_string(), + hue, + }; + match inner.participants.get(origin) { + Some(current) if current.name == seat.name && current.hue == seat.hue => false, + _ => { + inner.participants.insert(origin.to_string(), seat); + true + } } - inner.participants.insert(name.to_string()); - inner.local_name = Some(name.to_string()); - true } + + fn unseat(&self, origin: &str) { + self.inner.lock().participants.remove(origin); + } +} + +/// A hue is a position on the colour wheel, so it is taken modulo a turn rather +/// than rejected. `None` for a value that is not a number at all: an undeclared +/// hue and an unusable one are the same state to the screen, which derives one. +fn normalize_hue(hue: Option) -> Option { + hue.filter(|value| value.is_finite()) + .map(|value| value.rem_euclid(360.0)) } /// Absent is the key omitted, never an empty one: a participant matching `to` @@ -253,6 +324,9 @@ fn deliver(app: &AppHandle, room: &RoomState, origin: &str, post: Post) { RoomMessage { message_id: post.message_id, speaker: post.speaker, + // Read off the seat on this connection, so the colour of a line and + // the colour of its author's roster entry are the one declaration. + hue: room.hue_of(origin), content: post.content, to: post.to, ts: post.ts, @@ -368,7 +442,9 @@ async fn serve_participant( frame.protocol ); } - room.add_participant(&name); + // Seated on this connection. A second session answering to the + // same name is a second seat, not the same one. + room.seat(&origin, &name, normalize_hue(frame.hue)); joined_as = Some(name); let _ = app.emit("room-participants", room.participants()); } @@ -396,8 +472,11 @@ async fn serve_participant( } pump.abort(); - if let Some(name) = joined_as { - room.remove_participant(&name); + if joined_as.is_some() { + // By connection. Removing by name took every participant answering to + // that name off the roster, so one session ending emptied the other's + // seat too (#40). + room.unseat(&origin); let _ = app.emit("room-participants", room.participants()); } Ok(()) @@ -411,26 +490,32 @@ pub fn room_port(state: tauri::State) -> Option { } #[tauri::command] -pub fn room_participants(state: tauri::State) -> Vec { +pub fn room_participants(state: tauri::State) -> Vec { state.participants() } -/// Seat this screen's person in the room under `name`. +/// Seat this screen's person in the room, under the name and hue they declared. /// /// A person is in the room by being there, not by speaking: without this the /// roster would list only sessions until the first utterance, and nobody could /// address someone who had not spoken yet. +/// +/// `hue` is optional and the same declaration a session makes in its `hello`. +/// The screen's person and a session take one seat of the same kind, and there +/// is one path to it. #[tauri::command] pub fn room_join( app: AppHandle, state: tauri::State, name: String, + hue: Option, ) -> Result<(), String> { let name = name.trim().to_string(); if name.is_empty() { return Err("name is empty".to_string()); } - if state.seat_local(&name) { + let local_origin = state.local_origin.clone(); + if state.seat(&local_origin, &name, normalize_hue(hue)) { let _ = app.emit("room-participants", state.participants()); } Ok(()) @@ -459,13 +544,16 @@ pub fn room_post( return Err("speaker is empty".to_string()); } // Speaking is being present. A post under a name the roster has not seen - // seats it, so the two cannot disagree. - if state.seat_local(&speaker) { + // seats it, so the two cannot disagree. The hue is left as it stands: the + // composer declares nothing, and passing `None` here would silently + // withdraw a declaration the person made in the titlebar. + let local_origin = state.local_origin.clone(); + let seated_hue = state.hue_of(&local_origin); + if state.seat(&local_origin, &speaker, seated_hue) { let _ = app.emit("room-participants", state.participants()); } let message_id = Uuid::new_v4().to_string(); - let local_origin = state.local_origin.clone(); deliver( &app, &state, diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index f608edb..09246b0 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -11,7 +11,10 @@ use crate::config::TabConfig; use crate::pty::{self, PtyState}; use crate::room::RoomState; -use mcp_config::{channel_launch_args, register_sidecar, reject_incompatible_flags, RoomRegistration}; +use mcp_config::{ + channel_launch_args, register_sidecar, reject_incompatible_flags, server_name_for, + RoomRegistration, +}; use std::path::PathBuf; use tauri::AppHandle; @@ -83,9 +86,14 @@ pub fn parse_launch_options(text: String) -> Vec { /// /// The app merges its own channel entry into what the person wrote, so the /// line they typed is not the line that runs. This returns the line that runs. +/// +/// The entry names this session's own server, which is a function of the name +/// being launched under, so the preview moves as the name field is typed in. +/// That is the point rather than a side effect: the identity a session is about +/// to take is the thing this launch is now choosing. #[tauri::command] -pub fn preview_launch_args(args: Vec) -> Vec { - channel_launch_args(&args) +pub fn preview_launch_args(args: Vec, name: String) -> Vec { + channel_launch_args(&args, &server_name_for(name.trim())) } /// What the caller gets back after a session joins. @@ -104,15 +112,34 @@ pub struct StartedSession { pub started_at: String, } +/// Launch one session under a declared identity. +/// +/// `name` and `hue` are the session's own, not the tab's. A tab is which CLI to +/// run; who joins the room is chosen at the moment of joining, the same way the +/// screen's person chooses theirs. Under the tab-attribute form every launch +/// answered to the one name in the default config, so two sessions were both +/// `Claude Code` and neither could be addressed (#40). #[tauri::command] +#[allow(clippy::too_many_arguments)] pub fn start_session( app: AppHandle, room: tauri::State, pty_state: tauri::State, tab: TabConfig, + name: String, + hue: Option, cols: u16, rows: u16, ) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err( + "This session has no name. Give it one before starting: it is what the room \ + lists it under and what a post is addressed to." + .to_string(), + ); + } + if let Err(flag) = reject_incompatible_flags(&tab.args) { return Err(format!( "Tab \"{}\" passes {flag}, which stops channel pushes from arriving. \ @@ -143,12 +170,16 @@ pub fn start_session( } let (sidecar_entry, sidecar_runner) = resolve_sidecar_paths()?; + // Keyed on the name, so two sessions launched into one working directory + // write two entries instead of overwriting each other's identity (#40). + let server_name = server_name_for(&name); let mcp_config = register_sidecar( &cwd, &RoomRegistration { room_url: &room_url, token: &room.token(), - agent_name: &tab.name, + agent_name: &name, + agent_hue: hue, sidecar_entry: &sidecar_entry, sidecar_runner: &sidecar_runner, }, @@ -159,7 +190,7 @@ pub fn start_session( app, pty_state, tab.command.clone(), - channel_launch_args(&tab.args), + channel_launch_args(&tab.args, &server_name), cols, rows, Some(cwd.to_string_lossy().to_string()), From 84da5653be44bd02ae1874795194d83df34bf085 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 22 Aug 2026 20:56:23 +0900 Subject: [PATCH 2/3] feat(ui): declare a name and a colour at the moment of joining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起動ごとに名前と色を選べるようにし、宣言色を導出色より優先する。 - ランチャに名前欄と色の選択を追加。人間側の `#display-name` と同じ形で、 タブ属性ではなく起動ごとの値。`localStorage` に残る。 - タイトルバーに人間側の色の選択を追加。名前と色は同じ宣言なので、 変更は同じハンドラで同じ着席を通る。 - 色の解決順は 宣言 > 自分の accent > 名前からの導出。宣言が accent より 上なのは、宣言した本人にだけ出ない色は宣言した色ではないため。自分を 自分と判じる手段は名簿の「(あなた)」と行の名前に残る。 - 名簿と宛先を `Participant` 記録から描く。自分の判定は接続で、名前では 行わない。宛先は表示名なので重複を畳む。 - 色相のみ宣言可能。明度と彩度は accent のままで、ライト / ダークの両方で 読める条件を維持する(#43)。 #40 Co-Authored-By: Claude Opus 5 --- index.html | 20 +++++ src/main.ts | 219 +++++++++++++++++++++++++++++++++++++++++-------- src/styles.css | 32 ++++++-- 3 files changed, 233 insertions(+), 38 deletions(-) diff --git a/index.html b/index.html index b85d88c..bacc398 100644 --- a/index.html +++ b/index.html @@ -15,15 +15,35 @@ participant panel's list now: two renderings of one roster is one surface too many, and the panel is always on screen. --> + +
+ + +