diff --git a/README.md b/README.md index 1905411..fa04ef1 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ liplus-chat は、人間と複数の独立した AI / Li+ セッションが、 ### 未実装 -- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む) +- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。経路は通っていますが、実機での往復は未確認です - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -54,11 +54,13 @@ npm run tauri dev 起動すると Tauri の窓が開き、部屋ソケットが待ち受けを始めます。フロントエンドの dev サーバは `vite.config.ts` で 1420 番に固定しています(`src-tauri/tauri.conf.json` の `devUrl` と一致させる必要があるため)。 -タブの下に「起動オプション」の入力欄があります。`--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:liplus-chat-room`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。 +タブの下に「起動オプション」の入力欄があります。`--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:liplus-chat-room-<名前>-`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。 + +タブの隣に「名前」と「色」があります。これはそのセッションが部屋で名乗る名前と、発言に付く色です。**起動のたびに選びます。** タブに固定された属性ではないので、同じタブから 2 つのセッションを、別々の名前と色で立てられます。色を選ばずに参加することもでき、その場合は名前から色が決まります。タイトルバーの「名前」「色」は同じものの人間側です。 作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。 -右の参加者パネルに、部屋にいる参加者と、セッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。自分の色は accent(青)で、名簿では自分の行に「(あなた)」が付きます。 +右の参加者パネルに、部屋にいる参加者と、セッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。 「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 @@ -66,8 +68,8 @@ npm run tauri dev タイトルバーでセッションを選び「セッション参加」を押すと、次の 2 つが行われます。 -1. そのタブの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `liplus-chat-room` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。 -2. `--dangerously-load-development-channels server:liplus-chat-room` を付けて CLI を PTY 上の対話セッションとして起動します。 +1. そのタブの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `liplus-chat-room-<名前>-` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。登録鍵を参加者ごとに分けているのは、同じ作業ディレクトリへ 2 つのセッションを立てたときに互いの登録を潰さないためです。前回の起動で書かれたエントリ(もう繋がらないポートを指しているもの)は、このとき掃除します。 +2. `--dangerously-load-development-channels server:liplus-chat-room-<名前>-` を付けて CLI を PTY 上の対話セッションとして起動します。 > [!NOTE] > このフラグはローカルの channel 開発専用であり、起動ごとに警告バナーが出ます。一般配布には plugin として allowlist に載せる必要があり、そちらは未対応です。 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/docs/0-requirements.md b/docs/0-requirements.md index 654c029..8ad1c80 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -13,6 +13,8 @@ - **参加者はひと種類である。** 人間もセッションも同じ参加者であり、違いは名前だけである。名簿は両者を同じ列に並べる。 - **発言はひとつの行為である。** 誰が出しても同じフレームで、同じ経路を通り、全員へ配られる。発言者ごとに別の行為を用意しない。 - **自分の発言は自分へ戻らない。** 抑止は接続の同一性で判定する。名前で判定すると、名前が衝突している間は他人の発言まで落ちる。 +- **参加者の同一性は接続にある。** 名簿は接続ごとに 1 席を持つ。名前は表示と宛先のための属性であって識別子ではなく、同名の 2 参加者はやはり 2 参加者である。 +- **名乗りは参加の時点で行う。** 名前も色も、参加者が場に加わるその瞬間に宣言する。人間もセッションも同じ形であり、設定ファイルに固定された属性ではない。 - **宛先は誰でも名指せる。** 人間も宛先の選択肢に並ぶ。宛先は配送を絞らない(constraints の「単一ベンダー構成」参照)。 この前提は後付けではない。当初のプロトコルは人間の発言を `say`、AI の発言を `reply` として別の行為に分けており、`reply` にはそもそも他の参加者へ配る線が引かれていなかった。並列に立てた 2 セッションが互いの発言を受け取らないという実測(2026-08-22)はその帰結であり、配送行の書き忘れではなく語の形の問題だった。統一フレーム `post` はこの是正である(#39)。 @@ -128,7 +130,7 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in | 向き | type | フィールド | |---|---|---| -| サイドカー → 部屋 | `hello` | `protocol` / `name` | +| サイドカー → 部屋 | `hello` | `protocol` / `name` / `hue`(任意) | | サイドカー → 部屋 | `post` | `message_id` / `content` / `to`(任意) / `ts` | | 部屋 → サイドカー | `post` | `message_id` / `speaker` / `content` / `to`(任意) / `ts` | @@ -136,10 +138,14 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in `speaker` はサイドカーが送らない。部屋が、フレームの届いた接続に紐づく名前(`hello` の `name`)から刻む。送信側が名乗れる欄を持たないため、他の参加者を騙る余地が構造上無く、名簿と表示が食い違うこともない。宛先は名指せるが自分は名乗れない、という非対称がこの性質を作っている。 -未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 +未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 `to` は両方向で同じ語彙であり、宛先となる参加者の表示名を入れる。任意であり、無いときは `null` ではなくキーごと省く。 +`hello` は参加者の名乗りである。部屋での名前と、任意で色相を運ぶ。どちらも参加の時点の宣言であり、起動のたびに決まる。色相を宣言しない参加者は `hue` をキーごと省いて送る。既定値を入れると宣言と導出を部屋が区別できなくなり、色を選ばずに参加している状態が消えるためである。人間の側は同じ組を `room_join` で渡す。着席の経路は 1 本であり、人間とセッションで分かれない。 + +**名簿の同一性は接続に置く。** 部屋は接続ごとに 1 席を持ち、切断ではその席だけを外す。名前を鍵にしていた間は、同名の 2 参加者が 1 エントリに畳まれ、片方の切断が両方を名簿から消していた(#40)。席は名乗り直しで置き換わる——名前を変えた参加者は同じ参加者である。 + **自分の発言の抑止**は部屋側で行う。部屋は接続ごとに origin を採番し、fan-out のときにその発言を出した接続だけを飛ばす。origin はフレームの外側をタグとして流れ、wire には出ない。したがって送信側は自分の origin を設定することも他人の origin を騙ることもできない。名前で判定しないのは、名前が衝突している間に他の参加者の発言まで落ちるためである(#40)。サイドカー側に自己判定は置かない。 どの参加者の発言も、フロントエンドへは同一の `room-message` イベントとして届く。並び順の権威を 1 箇所に保つためであり、送信時にフロント側でローカルに追記しない。イベントは `own`(自分の発言かどうか)を伴う。表示上の区別を残す場合の軸はこれであり、人間か AI かではない。 @@ -191,10 +197,12 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - タブ設定とセッションデータの JSON 永続化 - MCP channel サーバ(Node サイドカー、`sidecar/`) - 部屋ソケット(Rust、`127.0.0.1` 任意ポート、Bearer トークン必須) -- サーバの `.mcp.json` 登録(既存内容はマージして保持)と、成立条件を満たす CLI 起動フラグの適用 +- サーバの `.mcp.json` 登録(既存内容はマージして保持、登録鍵は参加者ごと、前回起動のエントリは掃除)と、成立条件を満たす CLI 起動フラグの適用 - 部屋の作法(`instructions`)の初版 - 発言の宛先(部屋 → `post.to` → channel の `meta.to`、送信は `say_to_room` の `to`)と、それを判定材料として名指しする `instructions` -- 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止、人間を含む名簿) +- 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) +- 参加の時点の名乗り(起動ごとに選ぶ名前と色。人間側の名前欄と同じ形、`localStorage` に保持) +- 宣言色(`hello` の `hue` / `room_join` の `hue`。宣言 > 自分の accent > 名前からの導出) - チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先) - 参加者パネル(名簿、参加者ごとの色、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) - 診断面(CLI の端末表示と入力) @@ -209,7 +217,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre ### 未実装 -- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。参加者間で発言が届く経路は実装済みだが、実機での往復はまだ確認していない。2026-08-22 の実測(2 セッションが互いの発言を受け取らない)は #39 の修正前のものであり、修正後の再計測は済んでいない。 +- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。参加者間で発言が届く経路、起動ごとの名乗り、参加者ごとの登録鍵はいずれも実装済みだが、実機での往復はまだ確認していない。2026-08-22 の実測(2 セッションが互いの発言を受け取らない / 2 セッションがどちらも `Claude Code` を名乗る)は #39 と #40 の修正前のものであり、修正後の再計測は済んでいない。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -228,9 +236,19 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre | ライト(`#3a6ea5`) | 0.528 | 0.104 | | ダーク(`#7aa7d8`) | 0.715 | 0.087 | -色相は参加者名から決まる。到着順ではない。順で決めると、再接続した参加者が別の色で戻り、色が「誰か」ではなく「いつ入ったか」を表すことになる。 +色相の出所は 3 段の梯子であり、上から順に決まる。 + +1. **宣言** — 参加者が参加の時点で選んだ色相。 +2. **自分の accent** — 宣言が無く、かつ自分自身のとき。 +3. **名前からの導出** — それ以外。到着順ではない。順で決めると、再接続した参加者が別の色で戻り、色が「誰か」ではなく「いつ入ったか」を表すことになる。 + +導出だけでは足りないことは実測で判明した。`Lin` 130.5 度 / `Lay` 165.5 度 / `マスター` 205.5 度と、実際に使う 3 名が 75 度の帯に収まる。accent と同じ低彩度では 35 度差を見分けられず、色で追うという目的が、まさに使う名前の組で弱まる。名前由来かつ安定という性質と、任意の 2 名の分離保証は両立しない。分離を得るには色を導出ではなく宣言にする必要がある(#40)。 + +宣言できるのは色相だけである。明度と彩度は上表のまま動かさない。選んだ色相によらずライト / ダークの両方で読める、という条件を保つためである。宣言は 8 色から選ぶ。連続した色相環にしない。買う必要があるのは「任意の 2 名を見分けられること」であって、度数を人間に判断させることではない。 -自分は色相を振らず `--accent` そのものを使い、名前から derive される色相は accent の周囲 50 度を空ける。以前の色分けは自分 / 他人の二値であり(#39)、自分以外の全員が同じ色になっていた。参加者ごとの色相へ移したあとも自分の発言が自分のものと分かるのは、この 1 色が予約されているためである。名簿の自分の行には「(あなた)」が付く。 +宣言が accent より上にあるのは、宣言した本人にだけ出ない色は宣言した色ではないからである。以前の色分けは自分 / 他人の二値であり(#39)、自分以外の全員が同じ色になっていた。accent は自分の**既定**として残るため、色を宣言しない参加者の見え方はこれまでと変わらない。宣言した場合に自分を自分と判じる手段は、名簿の「(あなた)」と、行に出ている自分の名前である。色はその 3 つのうち最も速い経路であって、唯一の経路ではなかった。宣言によって、色は割り当てられるものから選ぶものへ移る。 + +名前から導出する色相は accent の周囲 50 度を空ける。宣言した色相は空けない。空けるのは名前がたまたま自分の色に当たることを避けるためであり、選んだ結果として当たるのは選択である。 端末はこのパネルへ畳まない。設計キャンバスは右下へ同居させているが、パネルの幅では CLI が枠を描けない(下記「診断面」)。 @@ -261,11 +279,13 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre セッションの起動オプションは画面から編集でき、タブ設定として保存する。 -アプリ自身の channel エントリ(`server:liplus-chat-room`)は、利用者が書いたものへ**統合**する。利用者が `--dangerously-load-development-channels` を書いていればその直後へ挿入し、書いていなければフラグごと足す。第二のフラグとしては足さない。同一フラグの二重指定は `--channels` 併記と同じ形であり、成立条件が壊れる。 +アプリ自身の channel エントリ(`server:liplus-chat-room--`)は、利用者が書いたものへ**統合**する。利用者が `--dangerously-load-development-channels` を書いていればその直後へ挿入し、書いていなければフラグごと足す。第二のフラグとしては足さない。同一フラグの二重指定は `--channels` 併記と同じ形であり、成立条件が壊れる。 統合であって上書きではない。利用者が別の channel サーバを指定しても、部屋のエントリは落ちない。落ちれば部屋の入力路そのものが消える。 -実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。 +このエントリの名前は参加者ごとに異なる。`.mcp.json` の登録鍵と `server:` タグは同じ 1 つの事実であり、鍵が分かれればタグも分かれる(下記「`.mcp.json` の登録単位」)。 + +実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。表示は名前の入力に追従する。エントリ名がその名前から決まるためであり、これは副作用ではなく要点である——この起動がいま選んでいるのは、そのセッションが名乗る identity そのものである。 **実測済み(2026-08-21)**: 複数の channel サーバを同時に有効化した状態で往復が成立した。`--print` での確認は無意味である(channel の読み込み自体が走らないため)ことも実測しており、確認は対話セッションでのみ可能。 @@ -275,6 +295,32 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre `npm run tauri dev` ではアプリのプロセス cwd が `src-tauri/` になるため、暗黙のフォールバックはセッションを `src-tauri/` で起動させ、`.mcp.json` もそこへ書く。作業ディレクトリはユーザーの選択であって、アプリのプロセスがたまたま居た場所ではない。初回はホームディレクトリを prefill するが、これは既定値ではなく prefill である。画面に出ていないディレクトリでセッションを起動しない。 +### 参加の時点の名乗り + +参加者は名前と色を、参加のその時点で宣言する。 + +人間の側は以前からこの形だった。タイトルバーの名前欄に書いた名前が `localStorage` に残り、`room_join` で着席する。エージェントの側にこの形が無かったことが #40 の中身である。 + +セッションの名前はタブの属性ではない。タブは「どの CLI を起動するか」を言うものであり、部屋で誰として加わるかは起動のたびに選ぶ。名前をタブに置いていた間は、既定のタブが 1 つ・その名前が `Claude Code` の 1 つだけで、タブを増やす UI も名前を編集する UI も無かった。したがって何回起動しても全員が `Claude Code` を名乗り、作業ディレクトリを分けても変わらなかった。宛先(#31)が名指す先を区別できなかったのはこの帰結である。 + +名前は人間が読んで選ぶものである。UUID などの機械的な一意化で置き換えない。宛先の選択肢に出るのはこの名前であり、そこから 1 名を選べることが目的である。 + +名前と色はランチャの入力欄と選択で決め、`localStorage` に残す。人間側と同じ場所・同じ形であり、タブ設定(`config.json`)には入れない。タブに保存すると、それは再び「設定ファイルに固定された名前」になる。ランチャの名前欄にはタブのラベルを prefill するが、これは既定値ではなく prefill である——画面に出ていて編集できることが、以前と違う点そのものである。 + +名前が空のままの起動は拒否する。黙ってタブのラベルへ落とすと、この面が塞ごうとしている状態がそのまま戻る。 + +### `.mcp.json` の登録単位 + +`.mcp.json` の登録鍵は参加者ごとに分ける。名前は `liplus-chat-room--` であり、宣言された名前だけから決まる。 + +鍵が 1 つに固定されていたときは、同じ作業ディレクトリを指す 2 セッションが同じ鍵を上書きした。2 つめの起動が 1 つめの名前・色・部屋アドレスを書き換え、1 つめのセッションがまだ動いている間にその識別が消える(#40)。2026-08-22 の実測では作業ディレクトリが分かれていたためこの経路は発火していないが、名前を起動ごとに選べるようにすると露出する。 + +鍵を起動ごとの id にはしない。それでは起動のたびに 1 つ増え、ユーザーのリポジトリのファイルが際限なく伸びる。名前だけの関数にすることで、同じ名前で立て直したときは同じエントリを使い回す。 + +slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落とす。一意性は hash が担う。ASCII を含まない名前(`マスター`)は slug が空になり、異なる名前が同じ slug に落ちること(`Lin` と `lin!`)もあるためである。slug が空のときは hash だけで鍵を作る。 + +登録のたびに、`liplus-chat-room` で始まり `LIPLUS_ROOM_URL` が現在の部屋アドレスと異なるエントリを削除する。部屋は起動ごとに新しいポートを取るため、それらは前回起動の残りであり、どのサイドカーも到達できない。残しておくと、この作業ディレクトリで CLI を起動するたびに死んだポートへ再試行するサイドカーが 1 つずつ増える。現在のアドレスを持つエントリは同一起動の別セッションであるから残す。`LIPLUS_ROOM_URL` を持たないエントリは、名前が前方一致しても他人のものとして触らない。 + ## テストの配置 `.mcp.json` への登録と起動フラグの検査は、`crates/mcp-config/` という tauri 非依存の crate に置く。 @@ -299,10 +345,10 @@ CI が実行するもの: 1. **部屋ソケットが待ち受けているか。** 右の参加者パネルの部屋ソケットの行を見る。`127.0.0.1:` が accent 色で出ていなければ以降はすべて不成立。 2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 -3. **サイドカーが接続したか。** パネルの名簿に**セッション名**が出る。自分(人間)は発言前から名簿に載っているため、名簿が空でないことは接続の根拠にならない。セッション名が出ない場合は端末に `room socket: connected as ""` があるかを見る。 +3. **サイドカーが接続したか。** パネルの名簿に**そのセッションが名乗った名前**が出る。起動時にランチャで入れた名前である。自分(人間)は発言前から名簿に載っているため、名簿が空でないことは接続の根拠にならない。名前が出ない場合は端末に `room socket: connected as ""` があるかを見る。 4. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。 -5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `liplus-chat-room` が入っているかを見る。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。 -6. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:liplus-chat-room` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。タブ設定に `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。 +5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に、そのセッションの名前の `liplus-chat-room--` が入っているかを見る。鍵は参加者ごとであり、固定の `liplus-chat-room` は前回起動の残りとして掃除される側である。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。 +6. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:liplus-chat-room--` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。タブ設定に `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。 7. **対話セッションになっているか。** `--print` 系では push が届かない。PTY 経由の対話起動であることを確認する。 ## UI の方向(2026-08-21 時点、暫定) @@ -311,7 +357,7 @@ CI が実行するもの: 2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。 -- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みになったため、この 1 枚と実装の差は操作ボタン(端末 / リサイズ / 終了)と宛先の見た目(削除可能なチップ)だけである。宛先は `` で機能的に等価であり、形の差は追っていない。名乗りの側はキャンバスが実装より古い。 - **全体像** — ルーム一覧 / ダイレクト / 参加者・セッション状態パネル 色と寸法は `src/styles.css` の実値をそのまま使っている(`--accent: #3a6ea5` / `--surface: #f4f4f7` / 角丸 4・6px / system-ui + Yu Gothic UI)。参加者ごとの色は accent と同じ彩度・明度で色相だけ変えたもの。絵と実装が同じトークンの上に乗るため、キャンバスの値をそのまま実装へ移せる。 @@ -323,7 +369,8 @@ CI が実行するもの: - 会話面はメッセージ単位を保つ。端末は診断面側に置き、会話面には置かない。 - 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作(端末 / ミュート / 終了)は未実装であり、キャンバス側にのみある。 - 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)。 -- 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。 +- 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。色相は宣言でき、宣言が導出に優先する(上記「参加者パネル」の梯子)。 +- 名前と色は参加の時点で決める(実装済み。人間はタイトルバー、セッションはランチャ。タブ属性にしない)。 ### 決まっていないこと 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. --> + +
+ + +