diff --git a/README.md b/README.md index fa04ef1..9b57ec9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ liplus-chat は、人間と複数の独立した AI / Li+ セッションが、 - Tauri 2 による Windows デスクトップアプリの基盤 - プロセスの起動、入力、リサイズ、終了を扱う Rust の PTY コマンド -- タブ設定とセッションデータを JSON へ保存・読込する Tauri コマンド +- アカウント設定とセッションデータを JSON へ保存・読込する Tauri コマンド - MCP channel サーバ(Node サイドカー、`sidecar/`) - 部屋ソケット(`127.0.0.1` の任意ポート、Bearer トークン必須) - `.mcp.json` への登録と、channel が成立する条件を満たした CLI 起動 @@ -54,22 +54,26 @@ 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 サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。 +上の行が**アカウント**です。「+」で作り、「−」で削除します。アカウントは起動していなくても存在するもので、名前・色・作業ディレクトリ・起動オプションを持ちます。編集はその場で保存されます。 -タブの隣に「名前」と「色」があります。これはそのセッションが部屋で名乗る名前と、発言に付く色です。**起動のたびに選びます。** タブに固定された属性ではないので、同じタブから 2 つのセッションを、別々の名前と色で立てられます。色を選ばずに参加することもでき、その場合は名前から色が決まります。タイトルバーの「名前」「色」は同じものの人間側です。 +「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。タイトルバーの「名前」「色」は同じものの人間側です(人間はアカウントではなく、この画面の話者として参加します)。 -作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。 +「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:liplus-chat-room--`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。 -右の参加者パネルに、部屋にいる参加者と、セッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。 +作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。 + +右の参加者パネルに、部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が並びます。その下にセッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。宛先に選べるのは部屋に居る参加者だけで、未起動のアカウントは出ません。 「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 この端末は表示と操作のためのものです。ここに映る内容が部屋の発言になることはありません。部屋に並ぶのは channel 経由の発言だけです。 -タイトルバーでセッションを選び「セッション参加」を押すと、次の 2 つが行われます。 +アカウントを選び「セッション参加」を押すと、次の 2 つが行われます。 + +1. そのアカウントの作業ディレクトリの `.mcp.json` へ、部屋のサイドカーを `liplus-chat-room--` という名前で登録します。**既存の内容はマージして保持します**が、あなたのリポジトリのファイルを書き換える操作です。登録鍵はアカウント id から決まります。アカウントごとに分けているのは同じ作業ディレクトリへ 2 つのセッションを立てたときに互いの登録を潰さないためで、名前ではなく id から導くのは改名で鍵が動かないようにするためです。前回の起動で書かれたエントリ(もう繋がらないポートを指しているもの)は、このとき掃除します。 +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 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/docs/0-requirements.md b/docs/0-requirements.md index 2ebb90e..49b92f8 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -14,7 +14,7 @@ - **発言はひとつの行為である。** 誰が出しても同じフレームで、同じ経路を通り、全員へ配られる。発言者ごとに別の行為を用意しない。 - **自分の発言は自分へ戻らない。** 抑止は接続の同一性で判定する。名前で判定すると、名前が衝突している間は他人の発言まで落ちる。 - **参加者の同一性は接続にある。** 名簿は接続ごとに 1 席を持つ。名前は表示と宛先のための属性であって識別子ではなく、同名の 2 参加者はやはり 2 参加者である。 -- **名乗りは参加の時点で行う。** 名前も色も、参加者が場に加わるその瞬間に宣言する。人間もセッションも同じ形であり、設定ファイルに固定された属性ではない。 +- **名乗りは参加の時点で行う。** 人間は名前と色を、場に加わるその瞬間に宣言する。セッションの側はアカウントが持つ名前と色で加わる(下記「アカウント」)。どちらも「この部屋での自分は誰か」の宣言であり、出所が違うだけである。 - **宛先は誰でも名指せる。** 人間も宛先の選択肢に並ぶ。宛先は配送を絞らない(constraints の「単一ベンダー構成」参照)。 この前提は後付けではない。当初のプロトコルは人間の発言を `say`、AI の発言を `reply` として別の行為に分けており、`reply` にはそもそも他の参加者へ配る線が引かれていなかった。並列に立てた 2 セッションが互いの発言を受け取らないという実測(2026-08-22)はその帰結であり、配送行の書き忘れではなく語の形の問題だった。統一フレーム `post` はこの是正である(#39)。 @@ -76,10 +76,10 @@ liplus-chat (Tauri app) = 部屋の壁 ├── UI Layer (WebView) │ ├── チャットルーム(メッセージ一覧 / 発言者表示 / 入力欄) -│ └── セッション制御(起動 / 停止 / タブ設定) +│ └── アカウントとセッション制御(作成 / 編集 / 削除 / 起動) ├── App Layer (Rust) │ ├── PTY 管理(portable-pty: spawn / write / resize / kill)— 対話セッションの保持 -│ ├── config / sessions 永続化(JSON) +│ ├── config(アカウント)/ sessions 永続化(JSON) │ └── Tauri IPC(invoke / emit) ├── MCP Channel Server(Node サイドカー) │ ├── initialize: capabilities.experimental["claude/channel"] を宣言 @@ -233,16 +233,17 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - Tauri 2 による Windows デスクトップアプリの基盤 - Rust の PTY コマンド(起動 / 入力 / リサイズ / 終了) -- タブ設定とセッションデータの JSON 永続化 +- アカウント設定とセッションデータの 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` - 順番の作法(先に届いた答えを読んでから決める、送信直前の読み直し。`instructions` のみで、protocol もツールも増やしていない) - 床(`say_to_room` の `last_seen` と `post_result`、既存ロック内での判定と刻印、見落としがあるときの拒否と返却。ツールは 1 本のまま) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) -- 参加の時点の名乗り(起動ごとに選ぶ名前と色。人間側の名前欄と同じ形、`localStorage` に保持) +- アカウント(作成・編集・削除、名前と色と作業ディレクトリと起動オプション、名簿でのオフライン表示、一つのアカウントは一つの部屋に一席まで、`config.json` からの移行) +- 参加の時点の名乗り(人間側。タイトルバーの名前と色、`localStorage` に保持) - 宣言色(`hello` の `hue` / `room_join` の `hue`。宣言 > 自分の accent > 名前からの導出) - チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先) - 参加者パネル(名簿、参加者ごとの色、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) @@ -260,6 +261,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 床を入れた後の同時発話の実機観測。参加者間で発言が届く経路、起動ごとの名乗り、参加者ごとの登録鍵、床の判定はいずれも実装済みである。2026-08-22 の実測(2 セッションが互いの発言を受け取らない / 2 セッションがどちらも `Claude Code` を名乗る)は #39 と #40 の修正前のものであり、2026-08-23 の二重応答(#47 の premise)は床の実装前のものである。いずれも修正後の再計測は済んでいない。 - 順番の付与(弾くだけでなく「あなたは N 人め」を返す形)。戻り値の形は段差を残していないが、二重計算が痛むと測れてから判断する。 +- アカウント面の実機確認。作成・改名・削除、オフライン表示、二重起動の拒否、改名後の `.mcp.json` 登録の追随はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -269,7 +271,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre **読む値と、見る値を分ける。** ここに出るのは、詰まったときに読みに行く値である。折りたたみの下に置くと、開く操作を思いつくかどうかが読めるかどうかになる。CLI の端末だけは別で、あれは読む値ではなく映しているものであるから診断面に残る。 -名簿の出所は部屋の名簿(`room-participants`)だけである。画面側に第二の一覧を持たない。持てば、宛先に選べる名前と画面に並ぶ名前が食い違う余地ができる。 +**宛先の選択肢の出所は部屋の名簿(`room-participants`)だけである。** 画面側に第二の一覧を持たない。持てば、宛先に選べる名前と画面に並ぶ名前が食い違う余地ができる。 + +名簿の列にはこれに加えて、席を持たないアカウントが「未起動」として並ぶ(上記「アカウント」)。第二の一覧ではない——出所は config のアカウント一覧であり、部屋の名簿とはアカウント id で突き合わせる。宛先には出ないため、選べる名前と届く相手が食い違う余地も増えていない。オフラインの行は色を落とし、点を塗りから輪郭へ変える。 **参加者ごとの色。** 発言の縁の色と名簿の点の色は同じであり、参加者ごとに異なる。明度と彩度は `--accent` の実測値に固定し、色相だけを振る。 @@ -319,47 +323,67 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre ### 起動オプション -セッションの起動オプションは画面から編集でき、タブ設定として保存する。 +セッションの起動オプションはアカウントの属性であり、画面で編集したその場で保存する。 アプリ自身の channel エントリ(`server:liplus-chat-room--`)は、利用者が書いたものへ**統合**する。利用者が `--dangerously-load-development-channels` を書いていればその直後へ挿入し、書いていなければフラグごと足す。第二のフラグとしては足さない。同一フラグの二重指定は `--channels` 併記と同じ形であり、成立条件が壊れる。 統合であって上書きではない。利用者が別の channel サーバを指定しても、部屋のエントリは落ちない。落ちれば部屋の入力路そのものが消える。 -このエントリの名前は参加者ごとに異なる。`.mcp.json` の登録鍵と `server:` タグは同じ 1 つの事実であり、鍵が分かれればタグも分かれる(下記「`.mcp.json` の登録単位」)。 +このエントリの名前はアカウントごとに異なる。`.mcp.json` の登録鍵と `server:` タグは同じ 1 つの事実であり、鍵が分かれればタグも分かれる(下記「`.mcp.json` の登録単位」)。 -実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。表示は名前の入力に追従する。エントリ名がその名前から決まるためであり、これは副作用ではなく要点である——この起動がいま選んでいるのは、そのセッションが名乗る identity そのものである。 +実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。表示は選んでいるアカウントに追従し、そのアカウントの名前を編集しても動かない。エントリ名がアカウント id から決まるためであり、これは要点である——起動しているのはアカウントであって、改名はそれを別のものにしない。 **実測済み(2026-08-21)**: 複数の channel サーバを同時に有効化した状態で往復が成立した。`--print` での確認は無意味である(channel の読み込み自体が走らないため)ことも実測しており、確認は対話セッションでのみ可能。 ### 作業ディレクトリ -セッションの作業ディレクトリは画面に表示し、変更でき、タブ設定として保存する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。 +セッションの作業ディレクトリは画面に表示し、変更でき、アカウントの属性として保存する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。 + +`npm run tauri dev` ではアプリのプロセス cwd が `src-tauri/` になるため、暗黙のフォールバックはセッションを `src-tauri/` で起動させ、`.mcp.json` もそこへ書く。作業ディレクトリはユーザーの選択であって、アプリのプロセスがたまたま居た場所ではない。作業ディレクトリを持たないアカウントにはホームディレクトリを prefill するが、これは既定値ではなく prefill である。画面に出ていないディレクトリでセッションを起動しない。 + +### アカウント + +アカウントは、起動していなくても存在する同一性である。作る、残る、起動する、止める。名簿は「今つながっている人」ではなく「誰が居るか」を示す面になる。 + +**同一性は id にある。** アカウントは一意な id を構造的に持ち、それだけが同一性である。名前・色・作業ディレクトリ・起動オプションはすべて属性であり、編集できる。名前が識別子ではなくなったため、改名しても、名前が衝突しても、誰が誰かを見失うものが無い。 + +**アカウントは #40 の機構を置き換え、目的を保つ。** #40 が直した欠陥は「タブが起動の手段でしかなく、同一性を持てなかった」ことである。同一性が無いところに名前を付ける唯一の形が「起動ごとに宣言する」だった。構造的な同一性ができたので、名前と色はそこへ移る——そして起動時宣言では持ちようがなかったものを得る。**動いていないアカウントにも名前と色がある。** #40 が守ろうとした「名指せること」は、より強い形で満たされている。 -`npm run tauri dev` ではアプリのプロセス cwd が `src-tauri/` になるため、暗黙のフォールバックはセッションを `src-tauri/` で起動させ、`.mcp.json` もそこへ書く。作業ディレクトリはユーザーの選択であって、アプリのプロセスがたまたま居た場所ではない。初回はホームディレクトリを prefill するが、これは既定値ではなく prefill である。画面に出ていないディレクトリでセッションを起動しない。 +**人間はアカウントではない。** 画面の話者は従来どおり `localStorage` の名前と色で参加する。アカウントへ統合するかは別の判断であり、統合しないことを選んだのではなく、ここでは決めていない。 -### 参加の時点の名乗り +**名前は人間が読んで選ぶものである。** UUID などの機械的な一意化で置き換えない。宛先の選択肢に出るのはこの名前であり、そこから 1 名を選べることが目的である。id が機械的な一意性を担うのは、名前をその責務から解放するためである。新規アカウントの既定名は既存と重ならない番号を取る——既定値が同じ名前の参加者を並べるのでは、#40 の欠陥を既定で再現することになる。 -参加者は名前と色を、参加のその時点で宣言する。 +名前が空のままの起動は拒否する。 -人間の側は以前からこの形だった。タイトルバーの名前欄に書いた名前が `localStorage` に残り、`room_join` で着席する。エージェントの側にこの形が無かったことが #40 の中身である。 +**アカウントの一覧と稼働中の名簿は、アカウント id で突き合わせる。** 名前で結ばない。名前は衝突しうるものになり、稼働中に改名されることもある。画面は部屋の名簿をそのまま並べ、席を持たないアカウントを「未起動」として足す。 -セッションの名前はタブの属性ではない。タブは「どの CLI を起動するか」を言うものであり、部屋で誰として加わるかは起動のたびに選ぶ。名前をタブに置いていた間は、既定のタブが 1 つ・その名前が `Claude Code` の 1 つだけで、タブを増やす UI も名前を編集する UI も無かった。したがって何回起動しても全員が `Claude Code` を名乗り、作業ディレクトリを分けても変わらなかった。宛先(#31)が名指す先を区別できなかったのはこの帰結である。 +**オフラインのアカウントは名簿に出し、宛先の選択肢には出さない。** 宛先の選択肢の出所は部屋の名簿だけであり(上記「参加者パネル」)、席の無いアカウントはそこに存在しない。届かない相手を名指せる状態は、宛先を名簿から引くという判断(#43)の理由に反する。 -名前は人間が読んで選ぶものである。UUID などの機械的な一意化で置き換えない。宛先の選択肢に出るのはこの名前であり、そこから 1 名を選べることが目的である。 +#### 一つのアカウントは一つの部屋に一席まで -名前と色はランチャの入力欄と選択で決め、`localStorage` に残す。人間側と同じ場所・同じ形であり、タブ設定(`config.json`)には入れない。タブに保存すると、それは再び「設定ファイルに固定された名前」になる。ランチャの名前欄にはタブのラベルを prefill するが、これは既定値ではなく prefill である——画面に出ていて編集できることが、以前と違う点そのものである。 +同じアカウントの二重起動は拒否する。アカウントは「誰か」であり、同じ誰かが一つの部屋に二つの接続で居ることを認めない。名簿に同じ同一性が二度並び、その名前への宛先が二つの着地点を持つことになる。 -名前が空のままの起動は拒否する。黙ってタブのラベルへ落とすと、この面が塞ごうとしている状態がそのまま戻る。 +**この規則は部屋に対して閉じている。** 現在ルームは 1 つ(サイドカーの `LIPLUS_ROOM_ID` は `liplus-chat` 固定)であるため、実装上は「アカウントが一度だけ動く」と見分けが付かないが、同じ規則ではない。ルームを複数にすることは検討事項として残っており(「決まっていないこと」)、一つのアカウントが二つのルームでそれぞれ席を持つのは意図した形であって違反ではない。機構だけからはこの射程が読めないため、ここに書いておく。 + +判定は席の台帳(`src-tauri/src/session.rs` の `RoomSeats`)が持つ。掃除と確保は一度のロック取得の中で行う——先に確認して後から確保する形では、二つの起動が同じ空席を通り抜ける。 + +稼働の有無は PTY へ問う。解放を誰かが呼び忘れると、そのアカウントはアプリを再起動するまで部屋へ入れなくなる——終了したセッションが PTY の一覧から外れることを稼働の定義にすることで、忘れうる解放そのものを無くしている。 + +#### 既存設定からの移行 + +`config.json` の `tabs` は `accounts` として読む。id・名前・起動コマンド・起動オプション・作業ディレクトリはそのまま持ち越し、読み手の居なかった `cli_kind` は未知フィールドとして落ちる(#17)。色はまだ宣言されていない状態になる。次回の保存で `accounts` として書き戻す。移行手続きは持たない——別名で読めることが移行のすべてであり、失われて困る値(作業ディレクトリと起動オプション)は形が変わっていない。 ### `.mcp.json` の登録単位 -`.mcp.json` の登録鍵は参加者ごとに分ける。名前は `liplus-chat-room--` であり、宣言された名前だけから決まる。 +`.mcp.json` の登録鍵はアカウントごとに分ける。名前は `liplus-chat-room--` であり、**アカウント id だけから決まる**。 + +鍵が 1 つに固定されていたときは、同じ作業ディレクトリを指す 2 セッションが同じ鍵を上書きした。2 つめの起動が 1 つめの名前・色・部屋アドレスを書き換え、1 つめのセッションがまだ動いている間にその識別が消える(#40)。 -鍵が 1 つに固定されていたときは、同じ作業ディレクトリを指す 2 セッションが同じ鍵を上書きした。2 つめの起動が 1 つめの名前・色・部屋アドレスを書き換え、1 つめのセッションがまだ動いている間にその識別が消える(#40)。2026-08-22 の実測では作業ディレクトリが分かれていたためこの経路は発火していないが、名前を起動ごとに選べるようにすると露出する。 +鍵を名前から導出しない理由は改名である。名前が編集できるようになったため、名前由来の鍵は編集のたびに動く。稼働中のセッションが起動時に指した登録は古い鍵の下に取り残され、そのセッションを持つ CLI のコマンドラインには古い `server:` タグが載ったままで、後から書き換える経路が無い。id は不変であるから、改名の費用がゼロになる——名前を編集可能にできるのはこの性質による。 -鍵を起動ごとの id にはしない。それでは起動のたびに 1 つ増え、ユーザーのリポジトリのファイルが際限なく伸びる。名前だけの関数にすることで、同じ名前で立て直したときは同じエントリを使い回す。 +鍵を起動ごとの id にもしない。それでは起動のたびに 1 つ増え、ユーザーのリポジトリのファイルが際限なく伸びる。アカウント id の関数にすることで、同じアカウントで立て直したときは同じエントリを使い回す。 -slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落とす。一意性は hash が担う。ASCII を含まない名前(`マスター`)は slug が空になり、異なる名前が同じ slug に落ちること(`Lin` と `lin!`)もあるためである。slug が空のときは hash だけで鍵を作る。 +slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落とす。一意性は hash が担う。id は不透明であるため、名前を鍵にしていたときより読みにくい——どのアカウントのエントリかは、そのエントリ自身の `LIPLUS_AGENT_NAME` から読む。読みやすさより同一性を採る。鍵が読みにくいのは 1 回の参照で済み、鍵が動くのは登録が行方不明になることであるから。 登録のたびに、`liplus-chat-room` で始まり `LIPLUS_ROOM_URL` が現在の部屋アドレスと異なるエントリを削除する。部屋は起動ごとに新しいポートを取るため、それらは前回起動の残りであり、どのサイドカーも到達できない。残しておくと、この作業ディレクトリで CLI を起動するたびに死んだポートへ再試行するサイドカーが 1 つずつ増える。現在のアドレスを持つエントリは同一起動の別セッションであるから残す。`LIPLUS_ROOM_URL` を持たないエントリは、名前が前方一致しても他人のものとして触らない。 @@ -388,10 +412,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--` が入っているかを見る。鍵は参加者ごとであり、固定の `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--` が入っているかを見る。鍵はアカウント id から決まるため名前では引けない。どのアカウントのものかはエントリ内の `LIPLUS_AGENT_NAME` で読む。固定の `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 時点、暫定) @@ -400,7 +424,7 @@ CI が実行するもの: 2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。 -- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みである。この 1 枚と実装の差は、操作ボタン(端末 / リサイズ / 終了)、宛先の見た目(削除可能なチップ)、そして #40 でランチャとタイトルバーに入った名乗り(名前欄と色の選択)である。宛先は `` で機能的に等価であり、形の差は追っていない。名乗りとアカウントの側はキャンバスが実装より古く、差は #46 で追う。 - **全体像** — ルーム一覧 / ダイレクト / 参加者・セッション状態パネル 色と寸法は `src/styles.css` の実値をそのまま使っている(`--accent: #3a6ea5` / `--surface: #f4f4f7` / 角丸 4・6px / system-ui + Yu Gothic UI)。参加者ごとの色は accent と同じ彩度・明度で色相だけ変えたもの。絵と実装が同じトークンの上に乗るため、キャンバスの値をそのまま実装へ移せる。 @@ -413,7 +437,8 @@ CI が実行するもの: - 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作(端末 / ミュート / 終了)は未実装であり、キャンバス側にのみある。 - 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)。 - 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。色相は宣言でき、宣言が導出に優先する(上記「参加者パネル」の梯子)。 -- 名前と色は参加の時点で決める(実装済み。人間はタイトルバー、セッションはランチャ。タブ属性にしない)。 +- 名前と色は「この部屋での自分は誰か」の宣言である(実装済み。人間はタイトルバーで参加の時点に、セッションはアカウントの属性として)。 +- アカウントは起動していなくても存在する(実装済み。上記「アカウント」)。同一性は id であり、名前は表示と宛先のための属性である。 ### 決まっていないこと @@ -424,6 +449,7 @@ CI が実行するもの: | 複数ルーム | サイドカーの `chat_id` は 1 つ固定。ルームごとにセッションを立てると利用枠がルーム数ぶん増え、1 セッションが複数ルームに属する形にすると push にルーム文脈を載せるプロトコル変更を伴う | | システム発言の出所 | エージェントが `say_to_room` で言うなら可。アプリがファイルや git を監視して言う形は「アプリは部屋であってランタイムではない」に当たる | | webhook を部屋へ流す | 未採用。判断材料は #32 に memo として残してある | +| 人間をアカウントにするか | 画面の話者は `localStorage` の名前と色で参加している。アカウントへ統合すると名簿と宛先の出所が一本化されるが、人間の同一性を config のエントリとして持つことの是非を決めていない(#53 で対象外とした) | ## 配布 diff --git a/index.html b/index.html index bacc398..cf0ecf4 100644 --- a/index.html +++ b/index.html @@ -30,19 +30,22 @@ +
- - + + +