From c0a98315467074b2bf1a5b35eb464370cc757808 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 18:13:53 +0900 Subject: [PATCH 1/3] feat(config): make an account the identity the app side runs on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TabConfig` を `Account` へ移し、同一性を id に置く。名前と色はアカウントの属性へ降り、`cli_kind` は読み手が居ないため落とした(#17)。`.mcp.json` の登録鍵はアカウント id から導出するため、改名しても稼働中のセッションの登録が迷子にならない。二重起動は「一つのアカウントは一つの部屋に一席まで」として拒否する。 - `config.rs` — `Account { id, name, command, args, cwd, hue }`。`tabs` を serde alias で読むため既存の `config.json` はそのまま移行でき、`cli_kind` は未知フィールドとして落ちる。 - `session.rs` — `RoomSeats`(account id → 席)。claim / hold / release は既存ロックの内側で行い、稼働判定は PTY へ問う。解放の呼び忘れで席が残る形にしない。 - `pty.rs` — 終了したセッションを map から外す。席の自己解放はこの liveness に乗っており、待機をロックの外へ出す副次的な直しも入る。 - `crates/mcp-config` — 登録鍵をアカウント id の関数へ。改名で鍵が動かないことをテストで押さえた。 部屋は変えていない。protocol も `room-participants` の意味も据え置きで、アカウントは画面と config の側の概念にとどまる。 Refs #53 Refs #17 Co-Authored-By: Claude Opus 5 --- crates/mcp-config/src/lib.rs | 140 +++++++++++++++------- src-tauri/src/config.rs | 76 +++++++++--- src-tauri/src/lib.rs | 5 + src-tauri/src/pty.rs | 39 +++++-- src-tauri/src/session.rs | 219 +++++++++++++++++++++++++++++------ 5 files changed, 372 insertions(+), 107 deletions(-) diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index bbf5be6..e67f921 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -19,24 +19,34 @@ use std::path::{Path, PathBuf}; /// 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 +/// The full name is per account (`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. +/// The `.mcp.json` key, and the `server:` tag, for one account. /// -/// 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); +/// A function of the account id alone. The id is what an account is; the name +/// is an attribute of it, and a key derived from the name moved every time the +/// name was edited — the registration a running session was launched against +/// would be orphaned under the old key while the CLI holding that session still +/// names the old tag on its command line (#53). Deriving from the id makes a +/// rename cost nothing, which is what makes the name editable at all. +/// +/// Per account rather than per launch: relaunching one account reuses its +/// entry, rather than growing the user's file by one key per launch. +/// +/// The slug half is legibility and the hash half is what makes the key total, +/// as it was under the name. An id is opaque, so the slug reads less well than +/// a name did; which account an entry belongs to is read from +/// `LIPLUS_AGENT_NAME` in its own env instead. Legibility loses to identity +/// here — a key that reads oddly costs one lookup, and a key that moves is a +/// registration nobody can find. +pub fn server_name_for(account_id: &str) -> String { + let slug = slugify(account_id); + let hash = fnv1a(account_id); if slug.is_empty() { format!("{SERVER_PREFIX}-{hash:08x}") } else { @@ -49,9 +59,9 @@ pub fn server_name_for(agent_name: &str) -> String { /// 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 { +fn slugify(text: &str) -> String { let mut out = String::new(); - for ch in name.chars() { + for ch in text.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); } else if !out.ends_with('-') && !out.is_empty() { @@ -86,7 +96,12 @@ pub struct RoomRegistration<'a> { pub room_url: &'a str, /// Bearer token the sidecar must present. pub token: &'a str, - /// Display name this session speaks under. + /// Id of the account being launched. The registration key derives from + /// this, so the entry stays put across a rename of the account. + pub account_id: &'a str, + /// Display name this session speaks under. Written into the env for the + /// sidecar to declare in `hello`, and it is what says whose entry this is + /// when the file is read by eye. Not the key: see `server_name_for`. 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 @@ -118,7 +133,7 @@ pub fn reject_incompatible_flags(args: &[String]) -> Result<(), &'static str> { /// The flag that loads channel servers into a session. pub const CHANNEL_FLAG: &str = "--dangerously-load-development-channels"; -/// The launch arguments for a channel-enabled session, given the tab's own. +/// The launch arguments for a channel-enabled session, given the account's own. /// /// The room's entry is merged into whatever the person wrote rather than added /// as a second flag: `--channels` alongside this one registers a server twice @@ -126,9 +141,8 @@ pub const CHANNEL_FLAG: &str = "--dangerously-load-development-channels"; /// shape. Merging also means the room's input path cannot be dropped by /// configuring a different server — losing it is losing the room. /// -/// `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. +/// `server_name` is this account's own (`server_name_for`), so the flag and the +/// `.mcp.json` key stay one fact even though that fact differs per account. pub fn channel_launch_args(base: &[String], server_name: &str) -> Vec { let room = format!("server:{server_name}"); let mut args = base.to_vec(); @@ -211,7 +225,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 server_name = server_name_for(room.account_id); let path = dir.join(".mcp.json"); let mut root: Value = if path.exists() { @@ -244,10 +258,10 @@ pub fn register_sidecar(dir: &Path, room: &RoomRegistration<'_>) -> Result(entry: &'a Path, runner: &'a Path) -> RoomRegistration<'a> { RoomRegistration { room_url: "ws://127.0.0.1:1234", token: "tok", + account_id: LIN, agent_name: "Lin", agent_hue: None, sidecar_entry: entry, @@ -336,7 +355,7 @@ mod tests { register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("register"); let json = read(&path); - let server = &json["mcpServers"][server_name_for("Lin")]; + 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"); @@ -383,11 +402,11 @@ mod tests { let json = read(&path); assert_eq!(json["mcpServers"]["theirs"]["command"], "their-server"); assert_eq!(json["unrelated"], 42); - assert!(json["mcpServers"][server_name_for("Lin")].is_object()); + assert!(json["mcpServers"][server_name_for(LIN)].is_object()); } #[test] - fn re_registering_the_same_name_replaces_its_own_entry() { + fn re_registering_the_same_account_replaces_its_own_entry() { let scratch = Scratch::new(); let entry = PathBuf::from(ENTRY); let runner = PathBuf::from(RUNNER); @@ -396,6 +415,7 @@ mod tests { let second = RoomRegistration { room_url: "ws://127.0.0.1:1234", token: "tok2", + account_id: LIN, agent_name: "Lin", agent_hue: Some(145.0), sidecar_entry: &entry, @@ -404,18 +424,51 @@ mod tests { let path = register_sidecar(scratch.path(), &second).expect("second"); let json = read(&path); - let server = &json["mcpServers"][server_name_for("Lin")]; + 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, - "relaunching under one name must not accumulate entries" + "relaunching one account must not accumulate entries" + ); + } + + #[test] + fn renaming_an_account_leaves_its_registration_where_it_was() { + // The reason the key moved off the name (#53). An account's name is + // editable, and a key derived from it moved on every edit: the entry + // the running session was launched against would be orphaned under the + // old key, while the CLI holding that session still names the old + // `server:` tag on a command line nothing can go back and change. + let scratch = Scratch::new(); + let entry = PathBuf::from(ENTRY); + let runner = PathBuf::from(RUNNER); + + register_sidecar(scratch.path(), ®istration(&entry, &runner)).expect("as Lin"); + let renamed = RoomRegistration { + room_url: "ws://127.0.0.1:1234", + token: "tok", + account_id: LIN, + agent_name: "リン", + agent_hue: None, + sidecar_entry: &entry, + sidecar_runner: &runner, + }; + let path = register_sidecar(scratch.path(), &renamed).expect("as リン"); + + let json = read(&path); + let servers = json["mcpServers"].as_object().expect("servers"); + assert_eq!(servers.len(), 1, "a rename must not open a second entry"); + assert_eq!( + json["mcpServers"][server_name_for(LIN)]["env"]["LIPLUS_AGENT_NAME"], + "リン", + "the new name belongs in the entry the id already had" ); } #[test] - fn two_participants_in_one_directory_keep_separate_entries() { + fn two_accounts_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, @@ -428,6 +481,7 @@ mod tests { let lay = RoomRegistration { room_url: "ws://127.0.0.1:1234", token: "tok", + account_id: LAY, agent_name: "Lay", agent_hue: Some(25.0), sidecar_entry: &entry, @@ -437,12 +491,12 @@ mod tests { let json = read(&path); assert_eq!( - json["mcpServers"][server_name_for("Lin")]["env"]["LIPLUS_AGENT_NAME"], + 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"], + json["mcpServers"][server_name_for(LAY)]["env"]["LIPLUS_AGENT_NAME"], "Lay" ); assert_eq!(json["mcpServers"].as_object().expect("servers").len(), 2); @@ -486,20 +540,20 @@ mod tests { "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"))); + 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-")); + fn a_server_name_is_a_function_of_the_account_id() { + 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(SERVER_PREFIX)); + // Total over whatever an id turns out to be, as it was over a name: an + // input that slugs to nothing still gets a key of its own, and two that + // slug alike still get two. The uniqueness lives in the hash, and the + // ids the app mints do not lean on the slug for it. 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") @@ -558,7 +612,7 @@ mod tests { .map(|s| s.to_string()) .collect(); - let room = server_name_for("Lin"); + let room = server_name_for(LIN); let merged = channel_launch_args(&base, &room); assert_eq!( merged, @@ -578,7 +632,7 @@ mod tests { #[test] fn does_not_add_the_room_twice() { - let room = server_name_for("Lin"); + let room = server_name_for(LIN); let base = vec![CHANNEL_FLAG.to_string(), format!("server:{room}")]; assert_eq!(channel_launch_args(&base, &room), base); } @@ -624,7 +678,7 @@ mod tests { .expect("one entry") .clone(); - let args = channel_launch_args(&["--verbose".to_string()], &server_name_for("Lin")); + let args = channel_launch_args(&["--verbose".to_string()], &server_name_for(LIN)); assert_eq!( args, vec![ diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4ef400a..ec852af 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -3,24 +3,53 @@ use std::path::PathBuf; use tauri::AppHandle; use tauri::Manager; +/// One account: someone who exists in this app whether or not they are running. +/// +/// The identity is `id`, and only `id`. It is minted once and never changes; +/// everything else here is an attribute the person edits, the name included. +/// +/// That is the whole of what an account adds over the launch recipe it replaces +/// (`TabConfig`). A tab was a way of starting something, so the only handle +/// anyone had on a session was the name it took — and two launches off one tab +/// took the same name, which left them indistinguishable and unaddressable +/// (#40). With a structural identity underneath, the name can come down to +/// being a display attribute: it may be edited, and it may collide, without +/// anything losing track of who is who. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TabConfig { +pub struct Account { + /// Immutable, and the only identity. The `.mcp.json` registration key + /// derives from this rather than from the name, so a rename cannot move the + /// key out from under a session already running under it. 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). + /// What the room lists this account under and what a post is addressed to. + /// Display and addressing; never identity. pub name: String, pub command: String, pub args: Vec, pub cwd: Option, - pub cli_kind: String, + /// The hue this account is drawn in, in oklch degrees, or `None` when none + /// was chosen — the screen derives one from the name in that case. Absent + /// rather than defaulted: chosen and derived are different states (#45). + /// + /// Stored here rather than declared per launch, which is where #40 put both + /// this and the name. #40 was fixing that a session could not be named at + /// all, and with no identity to hang a name on, declaring at the moment of + /// joining was the way to reach that. There is an identity now, so the pair + /// moves onto it and gains what the launch-time form could not have: an + /// account is named and coloured while it is not running. + #[serde(default)] + pub hue: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { - pub tabs: Vec, + /// Also read from `tabs`, the name this key had while an account was a + /// launch recipe. That alias is the whole migration: id, name, command, + /// args and working directory carry over as they are, `cli_kind` is + /// dropped on the floor by serde because nothing is left to read it (#17), + /// and a hue is simply not declared yet. The next save writes `accounts`. + #[serde(alias = "tabs")] + pub accounts: Vec, } // --------------------------------------------------------------------------- @@ -53,20 +82,23 @@ impl Default for AppConfig { // One vendor, by decision rather than by omission: the room is built // on a channel capability only this CLI is known to have, and the // 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. + // design. Shipping an account 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. + // One account, and its name is the CLI's. That is a starting point + // sitting in an editable field, not the fixed label it used to be: + // every session answered to this one name because there was nowhere to + // change it and nothing else to tell two launches apart (#40). A second + // account is created on screen and is named there. AppConfig { - tabs: vec![TabConfig { - id: "tab-1".to_string(), + accounts: vec![Account { + id: "account-1".to_string(), name: "Claude Code".to_string(), command: "claude".to_string(), args: vec![], cwd: None, - cli_kind: "claude".to_string(), + hue: None, }], } } @@ -101,9 +133,15 @@ pub fn load_config(app: AppHandle) -> Result { let content = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?; - // No legacy migration path exists. liplus-chat has never shipped a - // release, and its app data directory is keyed to its own identifier - // (org.liplus-project.liplus-chat), so no config in the older + // A config written before accounts existed parses here as it stands: the + // `tabs` alias on `AppConfig` reads the old key, and `cli_kind` is an + // unknown field serde ignores. No migration step runs, because there is + // nothing left for one to do — the person's own working directory and + // launch options are what would have been lost, and they carry over. + // + // No path exists for anything older than that. liplus-chat has never + // shipped a release, and its app data directory is keyed to its own + // identifier (org.liplus-project.liplus-chat), so no config in the older // left/right pane format from liplus-desktop can reach this app. serde_json::from_str::(&content) .map_err(|e| format!("Failed to parse config: {e}")) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9661425..1759614 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod session; use pty::PtyState; use room::RoomState; +use session::RoomSeats; use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -14,6 +15,9 @@ pub fn run() { .plugin(tauri_plugin_clipboard_manager::init()) .manage(PtyState::new()) .manage(RoomState::new()) + // Which account is in the room, so a second launch of one account is + // refused rather than seating one identity twice (session::RoomSeats). + .manage(RoomSeats::new()) .setup(|app| { // The room has to be listening before any session is started: the // port goes into the `.mcp.json` a session launch writes. @@ -41,6 +45,7 @@ pub fn run() { room::room_participants, room::room_join, room::room_post, + session::seated_accounts, session::parse_launch_options, session::preview_launch_args, session::start_session, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 4ed7d9b..16cf706 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -26,6 +26,18 @@ impl PtyState { procs: Arc::new(Mutex::new(HashMap::new())), } } + + /// Whether the session on `id` is still running. + /// + /// Membership is liveness, not history: a session is taken out of the map + /// by its own reader thread the moment it is reaped. That is what lets a + /// seat in the room release itself (`session::RoomSeats`) instead of + /// depending on someone remembering to call for its release — a release + /// that never came would lock an account out of the room for the rest of + /// the run, with no way back short of restarting the app. + pub fn is_running(&self, id: &str) -> bool { + self.procs.lock().contains_key(id) + } } #[tauri::command] @@ -109,14 +121,25 @@ pub fn spawn_pty( Err(_) => break, } } - // Collect exit code before emitting exit event - let exit_code: Option = { - let mut map = ptys_clone.lock(); - if let Some(pty) = map.get_mut(&id_clone) { - pty.child.wait().ok().map(|status| status.exit_code()) - } else { - None - } + // Collect exit code before emitting exit event. + // + // Taken out of the map first, for two reasons. The map is what says a + // session is running, and a session that has ended must stop saying so + // — a seat in the room is held for exactly as long as this entry is + // here. And the wait happens outside the lock, so a child that is slow + // to be reaped no longer blocks every other session's input. + // + // The instance is dropped after the wait, never before: dropping the + // master closes the PTY, and closing it under a child that has not been + // reaped is how an exit code goes missing. + // Bound before the match: a guard held in the scrutinee lives as long + // as the match does, which would put the wait back inside the lock. + let ended = ptys_clone.lock().remove(&id_clone); + let exit_code: Option = match ended { + Some(mut pty) => pty.child.wait().ok().map(|status| status.exit_code()), + // Already removed: `kill_pty` took it. The exit is this thread's to + // announce either way; the code is not knowable from here. + None => None, }; // Emit exit event with exit code payload (None if killed by signal/unknown) let _ = app_clone.emit(&format!("pty-exit-{}", id_clone), exit_code); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 09246b0..b8fafbf 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -8,14 +8,17 @@ //! //! The session must be interactive: `--print` never receives a push. -use crate::config::TabConfig; +use crate::config::Account; use crate::pty::{self, PtyState}; use crate::room::RoomState; use mcp_config::{ channel_launch_args, register_sidecar, reject_incompatible_flags, server_name_for, RoomRegistration, }; -use std::path::PathBuf; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use tauri::AppHandle; /// The sidecar entry point and the runner that executes it. @@ -72,6 +75,104 @@ fn resolve_sidecar_paths() -> Result<(PathBuf, PathBuf), String> { .to_string()) } +/// Which seat in the room each account is holding. +/// +/// **One account, one seat per room.** An account is who someone is, and the +/// same someone cannot be in one room twice: two connections under one account +/// would put one identity in the roster twice, and a post addressed to that +/// name would have two places to land. +/// +/// The rule is scoped to the room, not to the account. There is one room today +/// — the sidecar's `LIPLUS_ROOM_ID` is fixed to `liplus-chat` — so this map +/// needs no room in its key yet, and with one room the refusal is +/// indistinguishable from "an account runs once". They are not the same rule. +/// Rooms are meant to become plural (`design/Vision.dc.html`), and one account +/// holding a seat in each of two rooms is the intended shape rather than a +/// violation of this one. The scope is written down here because the mechanism +/// cannot show it: a rule remembered as "an account runs once" would outlive +/// the reason for it and block that case later, when nobody remembers why the +/// line was drawn. +#[derive(Clone)] +pub struct RoomSeats { + seats: Arc>>, +} + +/// One account's seat, from the launch being decided to the session ending. +enum Seat { + /// A launch is in flight: the registration is being written, or the CLI is + /// being spawned. Held so that two launches racing for one account cannot + /// both find the seat empty — the loser is refused before a second process + /// exists, rather than after. + Starting, + /// A session is running. The PTY id is what says so, and asking the PTY is + /// the only liveness question anyone asks here. + Running(String), +} + +impl RoomSeats { + pub fn new() -> Self { + RoomSeats { + seats: Arc::new(Mutex::new(BTreeMap::new())), + } + } + + /// The accounts holding a seat right now. + /// + /// Liveness is read off the PTY rather than from a release call anyone has + /// to remember to make. A seat whose session has exited is a free seat, and + /// a release that never arrived would otherwise lock an account out of the + /// room for the rest of the run with no way back short of restarting. + pub fn seated(&self, ptys: &PtyState) -> Vec { + let mut seats = self.seats.lock(); + seats.retain(|_, seat| match seat { + Seat::Starting => true, + Seat::Running(pty_id) => ptys.is_running(pty_id), + }); + seats.keys().cloned().collect() + } + + /// Claim the seat for `account_id`, or fail because it is taken. + /// + /// The sweep and the claim are one acquisition of the lock: checking first + /// and claiming after would let two launches pass the same empty seat. + fn claim(&self, account_id: &str, ptys: &PtyState) -> Result<(), ()> { + let mut seats = self.seats.lock(); + let taken = match seats.get(account_id) { + Some(Seat::Starting) => true, + Some(Seat::Running(pty_id)) => ptys.is_running(pty_id), + None => false, + }; + if taken { + return Err(()); + } + seats.insert(account_id.to_string(), Seat::Starting); + Ok(()) + } + + /// The launch got a session up; the seat is now held by that session. + fn hold(&self, account_id: &str, pty_id: &str) { + self.seats + .lock() + .insert(account_id.to_string(), Seat::Running(pty_id.to_string())); + } + + /// The launch failed. Nothing is running, so nothing holds the seat. + fn release(&self, account_id: &str) { + self.seats.lock().remove(account_id); + } +} + +/// The accounts with a session in the room, for the screen to draw against its +/// own list of accounts. +/// +/// Account ids, never names. The screen matches these against its accounts by +/// id, so an account renamed while its session runs is still the same account +/// on both sides, and two accounts sharing a name are still two. +#[tauri::command] +pub fn seated_accounts(pty_state: tauri::State, seats: tauri::State) -> Vec { + seats.seated(&pty_state) +} + /// Split a launch-options string into arguments. /// /// The splitter lives in `mcp-config` so it is covered by tests; this is the @@ -87,13 +188,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. +/// The entry names this account's own server, which is a function of the +/// account id, so the preview changes when a different account is selected and +/// holds still while that account's name is edited. Holding still is the point: +/// the identity being launched is the account, and renaming it does not make it +/// something else (#53). #[tauri::command] -pub fn preview_launch_args(args: Vec, name: String) -> Vec { - channel_launch_args(&args, &server_name_for(name.trim())) +pub fn preview_launch_args(args: Vec, account_id: String) -> Vec { + channel_launch_args(&args, &server_name_for(account_id.trim())) } /// What the caller gets back after a session joins. @@ -112,39 +214,37 @@ pub struct StartedSession { pub started_at: String, } -/// Launch one session under a declared identity. +/// Put one account into the room. /// -/// `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). +/// The account carries who this is: its id is the identity, and its name and +/// hue are what the room lists it under. Both used to be declared per launch, +/// beside a tab that said only which CLI to run — the way to name a session at +/// all before there was anything durable to hang a name on (#40). The account +/// is that durable thing, so the launch no longer declares anything; it starts +/// someone who already exists. #[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, + seats: tauri::State, + account: Account, cols: u16, rows: u16, ) -> Result { - let name = name.trim().to_string(); + let name = account.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 \ + "This account 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) { + if let Err(flag) = reject_incompatible_flags(&account.args) { return Err(format!( - "Tab \"{}\" passes {flag}, which stops channel pushes from arriving. \ - Remove it from the tab configuration.", - tab.name + "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ + Remove it from the launch options." )); } @@ -156,30 +256,75 @@ pub fn start_session( // No fallback to the app's own process directory. Under `tauri dev` that // is `src-tauri`, and a session silently launched there is a session the // person never chose and cannot see they got (#20). - let cwd = match tab.cwd.as_deref().map(str::trim) { + let cwd = match account.cwd.as_deref().map(str::trim) { Some(dir) if !dir.is_empty() => PathBuf::from(dir), _ => { return Err(format!( - "Tab \"{}\" has no working directory. Set one before starting a session.", - tab.name + "Account \"{name}\" has no working directory. Set one before starting a session." )) } }; if !cwd.is_dir() { - return Err(format!("Tab \"{}\" points at a missing directory: {}", tab.name, cwd.display())); + return Err(format!( + "Account \"{name}\" points at a missing directory: {}", + cwd.display() + )); + } + + // One account, one seat per room (`RoomSeats`). Claimed before anything is + // written or spawned, so a refusal costs nothing and leaves nothing behind. + seats.claim(&account.id, &pty_state).map_err(|()| { + format!( + "Account \"{name}\" already holds a seat in this room. One account holds one seat \ + per room: stop its running session before starting it again." + ) + })?; + + match launch(app, &room, pty_state, &account, &name, &room_url, &cwd, cols, rows) { + Ok(started) => { + seats.hold(&account.id, &started.pty_id); + Ok(started) + } + Err(err) => { + // Nothing is running, so nothing holds the seat. Without this the + // account would stay locked out by a launch that never happened. + seats.release(&account.id); + Err(err) + } } +} +/// Write the registration and spawn the CLI, with the seat already claimed. +/// +/// Split out so the seat has exactly one release point: every failure from here +/// down leaves the account seatless, and the caller does not have to remember +/// that at each `?`. +#[allow(clippy::too_many_arguments)] +fn launch( + app: AppHandle, + room: &RoomState, + pty_state: tauri::State, + account: &Account, + name: &str, + room_url: &str, + cwd: &Path, + cols: u16, + rows: u16, +) -> Result { 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); + // Keyed on the account id, so two accounts launched into one working + // directory write two entries instead of overwriting each other's identity + // (#40), and renaming an account does not move the key out from under the + // session running on it (#53). + let server_name = server_name_for(&account.id); let mcp_config = register_sidecar( - &cwd, + cwd, &RoomRegistration { - room_url: &room_url, + room_url, token: &room.token(), - agent_name: &name, - agent_hue: hue, + account_id: &account.id, + agent_name: name, + agent_hue: account.hue, sidecar_entry: &sidecar_entry, sidecar_runner: &sidecar_runner, }, @@ -189,8 +334,8 @@ pub fn start_session( let pty_id = pty::spawn_pty( app, pty_state, - tab.command.clone(), - channel_launch_args(&tab.args, &server_name), + account.command.clone(), + channel_launch_args(&account.args, &server_name), cols, rows, Some(cwd.to_string_lossy().to_string()), From 40ed383b9ad0a49a2da1ee928f57217a91ea9b28 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 18:18:07 +0900 Subject: [PATCH 2/3] feat(ui): make an account a thing on screen, running or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit アカウントを作成・編集・削除する面を入れ、名簿を「アカウント一覧 ⋈ 稼働中の名簿」として描く。突き合わせはアカウント id で行い、名前では結ばない。起動していないアカウントも名簿に並び、オフラインと分かる。 - ランチャの行がアカウントの行になった。名前・色・作業ディレクトリ・起動オプションはそのアカウントのものであり、編集したその場で保存する。起動時に宣言する値ではなくなった(#40 の機構をアカウントが置き換える)。 - 名簿は部屋の名簿をそのまま並べ、席を持たないアカウントを「未起動」として下に足す。宛先の選択肢は部屋の名簿だけを出所とするため、オフラインのアカウントは構造上そこへ出ない。 - 二重起動は画面でも理由を出して止める。判定の権威はアプリ側(`seated_accounts`)にあり、画面はそれを読むだけで自分の数え方を持たない。 - 新規アカウントの既定名は既存と重ならない番号を取る。同じ名前を名乗る参加者が並ぶ状態(#40)を既定値で作らない。 - 起動行のプレビューはアカウント id に追従するため、改名しても動かない。 Refs #53 Co-Authored-By: Claude Opus 5 --- index.html | 25 ++- src/main.ts | 456 ++++++++++++++++++++++++++++++++++++------------- src/styles.css | 35 +++- 3 files changed, 383 insertions(+), 133 deletions(-) diff --git a/index.html b/index.html index bacc398..cf0ecf4 100644 --- a/index.html +++ b/index.html @@ -30,19 +30,22 @@ +
- - + + +