From 6cb1c26f5bc60f94c21913e3e61c93c9e922817a Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Thu, 27 Aug 2026 19:06:45 +0900 Subject: [PATCH 1/6] feat(room): keep the log per topic and let the room move between topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部屋のログを一本の流れから「一トピック一ファイル + 索引」へ移す。トピックは会話の記録ではなくセッションの器であり、開き直せばその会話が部屋に戻る。 - `logs/{room}/{topic}.jsonl` と `logs/{room}/index.json`。索引はトピック名・作成時刻・`{account_id: session_uuid}` を持つ(決定5)。 - ディレクトリが実在であり、索引はその注釈である。索引に無い `.jsonl` は読み出し時に拾う。これにより新規トピックの遅延生成が安全になる——起動のたびに空の行が索引へ残らず、投稿がファイルへ届いて索引へ届く前に落ちても、ファイルだけから索引を建て直せる。 - 起動時は新しいトピックを開く。索引へは書かない(Master 判断5、決定5から導かれること)。 - 既存の `logs/main.jsonl` は移動して一トピックとして引き継ぐ。捨てない(決定8)。 - トピック名は最初の発言の冒頭から自動生成し、後から変更できる(決定9)。 - `history` / `history_result` フレームを追加し protocol を 6 へ。参加者は現在のトピックの過去発言を自分で引ける。pull のみであり、部屋から push する経路は作らない(決定4C)。 - トピック切り替えで床を空にし、席の `since` を戻す。前のトピックの発言を「見落とし」として返す閉路を作らないため。 #115 --- crates/room-floor/src/lib.rs | 33 ++ src-tauri/src/lib.rs | 7 +- src-tauri/src/room.rs | 209 ++++++++++++- src-tauri/src/room_log.rs | 587 +++++++++++++++++++++++++++++++---- 4 files changed, 764 insertions(+), 72 deletions(-) diff --git a/crates/room-floor/src/lib.rs b/crates/room-floor/src/lib.rs index ee2792d..ad359b8 100644 --- a/crates/room-floor/src/lib.rs +++ b/crates/room-floor/src/lib.rs @@ -140,6 +140,23 @@ impl Floor { self.seq } + /// Empty the floor and start its numbering over. + /// + /// For a room changing topic: the posts of the topic being left are not the + /// floor of the one being entered, and leaving them would refuse the first + /// thing said in the new one and hand back the old one's contents as + /// "missed" (`room.rs`). + /// + /// The caller has seats holding `since` positions taken against the old + /// numbering. Every one of them is now past the end of this floor, which + /// reads as having seen everything rather than nothing — so the caller puts + /// them back to [`Floor::seq`] itself. That is the room's to do: this type + /// holds no seats. + pub fn reset(&mut self) { + self.seq = 0; + self.log.clear(); + } + /// Check the speaker against the floor and, if they are clear, put their /// post on it. /// @@ -248,6 +265,22 @@ mod tests { } } + #[test] + fn a_reset_floor_admits_a_speaker_carrying_a_watermark_from_before_it() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "前のトピック")); + floor.admit("claude", 0, Some("m-1"), post("c-1", "Claude", "はい")); + + floor.reset(); + assert_eq!(floor.seq(), 0); + + // The watermark names a post the floor no longer holds, which resolves + // to position 0. An empty floor has nothing past 0, so the speaker is + // admitted rather than handed back a topic they have already read. + let admission = floor.admit("master", 0, Some("c-1"), post("m-2", "Master", "続き")); + assert_eq!(admitted_seq(&admission), 1); + } + #[test] fn an_empty_floor_admits_the_first_speaker() { let mut floor = Floor::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 567c5ee..43d8914 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,7 +47,12 @@ pub fn run() { room::room_participants, room::room_join, room::room_post, - room_log::room_log, + room::room_current_topic, + room::room_new_topic, + room::room_select_topic, + room_log::room_topics, + room_log::room_topic_log, + room_log::room_rename_topic, session::seated_accounts, session::parse_launch_options, session::preview_launch_args, diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index accc533..2d83954 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -80,7 +80,7 @@ //! the room before the disk is touched, and a failure there costs the record, //! not the utterance (#48). -use crate::room_log; +use crate::room_log::{self, TopicRef}; use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex; use room_floor::{Admission, Floor, Missed, Post}; @@ -104,7 +104,10 @@ use uuid::Uuid; /// 5: `hello` may carry the `account_id` the session was launched as, and the /// roster hands it back. Carried only — identity stays on the connection /// (#59). -pub const PROTOCOL_VERSION: u32 = 5; +/// 6: `history` / `history_result` — a participant may pull what was said in +/// the current topic before it arrived. Pull only: the room still pushes +/// nothing it did not fan out live (#115, decision 4C). +pub const PROTOCOL_VERSION: u32 = 6; /// One post of the room, as the frontend sees it. /// @@ -202,6 +205,13 @@ struct IncomingFrame { ts: Option, last_seen: Option, protocol: Option, + /// Correlates a `history` request with the `history_result` that answers + /// it, the way `message_id` correlates a post with its receipt. Minted by + /// the asker: two pulls may be in flight, and settling the wrong one would + /// hand back another request's posts. + request_id: Option, + limit: Option, + before: Option, } /// What the room holds about one seated participant. @@ -232,6 +242,19 @@ struct RoomInner { /// than beside the lock so the check and the stamp are one critical /// section (#47). floor: Floor, + /// The topic the room is in: where a post is written down, and what a pull + /// reads back. + /// + /// Under the same lock the floor is, for the same reason the append is + /// inside the critical section — a post has to reach the file of the topic + /// it was admitted into, and reading the topic outside the acquisition + /// leaves a window where a switch lands between the two. + /// + /// It exists before anything is written down. A launch opens a new topic + /// (#115, Master 判断5) and most runs of the app say nothing, so the index + /// entry waits for the first post rather than the app being opened + /// (`room_log::TopicRef`). + topic: TopicRef, } /// The name whoever is on `origin` answers to, or `None` when no one is seated @@ -273,6 +296,10 @@ impl RoomState { port: None, participants: BTreeMap::new(), floor: Floor::new(), + // A new one, every launch. Nothing of the previous run is + // reopened by starting the app: the room begins empty and a + // past topic is opened by being picked (#48 / #115). + topic: TopicRef::new(now_iso()), })), to_participants, token: Uuid::new_v4().to_string(), @@ -379,6 +406,35 @@ impl RoomState { fn unseat(&self, origin: &str) { self.inner.lock().participants.remove(origin); } + + /// The topic the room is in. + pub fn topic(&self) -> TopicRef { + self.inner.lock().topic.clone() + } + + /// Put the room in a topic. + /// + /// **The floor is emptied and every seat is put back to its start.** A + /// topic is where the conversation is, so the floor of the one being left + /// is not the floor of the one being entered — and a watermark naming a + /// post from elsewhere resolves to position 0, which the floor reads as + /// having seen nothing (`room-floor`). Left standing, the first thing said + /// in a reopened topic would be refused and handed back the whole of the + /// previous topic's retained floor, by a participant who had in fact seen + /// all of it. + /// + /// The seats stay. Switching a topic does not put anyone out of the room: + /// the sessions are still connected and the person is still at the screen. + /// What changes is where they are speaking. + pub fn enter_topic(&self, topic: TopicRef) { + let mut inner = self.inner.lock(); + inner.topic = topic; + inner.floor.reset(); + let start = inner.floor.seq(); + for seat in inner.participants.values_mut() { + seat.since = start; + } + } } /// A hue is a position on the colour wheel, so it is taken modulo a turn rather @@ -469,7 +525,7 @@ fn deliver( // One acquisition, both halves. Concurrent speakers serialise here, so the // loser's check runs against a floor the winner has already changed. - let (admission, hue, logged) = { + let (admission, hue, logged, topic) = { let mut inner = room.inner.lock(); let (since, hue) = match inner.participants.get(origin) { Some(seat) => (seat.since, seat.hue), @@ -496,11 +552,17 @@ fn deliver( // The failure is carried out rather than reported here: the room is // stopped while this lock is held, and telling the screen is not work // to do with everyone waiting. + // + // The topic is read under this same acquisition, so the post reaches + // the file of the topic it was admitted into. Read outside it, a switch + // landing between the two would put a post in one topic's order and the + // other topic's file. + let topic = inner.topic.clone(); let logged = match &admission { - Admission::Admitted { .. } => room_log::append(app, &post), - Admission::Unseen(_) => Ok(()), + Admission::Admitted { .. } => room_log::append(app, &topic.topic_id, &post), + Admission::Unseen(_) => Ok(false), }; - (admission, hue, logged) + (admission, hue, logged, topic) }; if let Admission::Unseen(missed) = admission { @@ -515,8 +577,14 @@ fn deliver( // the record, never the utterance — so nothing below is conditional on // this, and the one thing that must not happen is it passing unnoticed // (#48). - if let Err(err) = logged { - room_log::report(app, err); + match logged { + Err(err) => room_log::report(app, err), + // The first thing said in this topic. The topic gets its entry and its + // name from it, out here rather than under the lock: the index is a + // second file read and rewritten whole, and nothing about a post waits + // on it (#115, decision 9). + Ok(true) => room_log::realize_from_first_post(app, &topic, &post.content), + Ok(false) => {} } let mut frame = serde_json::json!({ @@ -563,6 +631,47 @@ fn deliver( } } +/// How many posts one pull answers with when the asker names no number. +const HISTORY_PAGE: usize = 50; + +/// The most one pull answers with, whatever the asker names. +/// +/// A topic has no ceiling, and a participant asking for all of one would be +/// handed a context's worth of text in a single tool result. The page and the +/// `before` cursor together are what make a long topic readable in the +/// direction it is actually read — backwards from the end. +const HISTORY_MAX: usize = 200; + +/// One page of a topic, oldest first, ending at `before`. +/// +/// The tail of the eligible window rather than its head: what a participant +/// joining late needs first is what was just said, and paging further back is +/// what `before` is for. `has_more` says whether there is anything older, so +/// the asker knows whether the top of the page is the top of the topic. +/// +/// A `before` the topic does not contain is read as naming its end. Erring the +/// other way — answering with nothing — would be indistinguishable from an +/// empty topic, and the asker would stop. +fn history_answer( + request_id: &str, + posts: Vec, + before: Option<&str>, + limit: Option, +) -> serde_json::Value { + let end = before + .and_then(|id| posts.iter().position(|post| post.message_id == id)) + .unwrap_or(posts.len()); + let window = &posts[..end]; + let limit = limit.unwrap_or(HISTORY_PAGE).clamp(1, HISTORY_MAX); + let start = window.len().saturating_sub(limit); + serde_json::json!({ + "type": "history_result", + "request_id": request_id, + "posts": &window[start..], + "has_more": start > 0, + }) +} + /// Bind the room socket and start accepting sidecars. /// /// Port 0: the OS picks. The port is handed to sidecars through `.mcp.json`, @@ -788,6 +897,43 @@ async fn serve_participant( frame: receipt.to_string(), }); } + // The read-out (#115, decision 4C). A participant asks for what + // was said in the current topic before it got here; the room + // answers on this connection alone. + // + // **Pull, and only pull.** The room still fans out nothing it did + // not deliver live — a later joiner missed what predates its seat, + // and that is unchanged. What changes is that the participant can + // now go and get it, which is a different thing from the room + // holding who has heard what (#31 / #39). Nothing here is recorded + // against the asker, and asking twice is the same as asking once. + // + // The topic is read and the lock released before the file is + // touched: this is the room's own lock, and a read of a log with + // no ceiling is not work to do with everyone waiting. + "history" => { + let request_id = frame.request_id.unwrap_or_default(); + let topic = room.topic(); + let answer = match room_log::topic_posts(&app, &topic.topic_id) { + Ok(posts) => history_answer(&request_id, posts, frame.before.as_deref(), frame.limit), + Err(err) => { + room_log::report(&app, err.clone()); + // Said rather than answered with an empty page: a + // participant told "nothing was said" would go on to + // act on that. + serde_json::json!({ + "type": "history_result", + "request_id": request_id, + "error": err, + }) + } + }; + let _ = room.to_participants.send(Fanout { + origin: origin.clone(), + target: Some(origin.clone()), + frame: answer.to_string(), + }); + } _ => {} } } @@ -815,6 +961,53 @@ pub fn room_participants(state: tauri::State) -> Vec { state.participants() } +/// The topic the room is in. +/// +/// It may not be in the index yet — a launch opens a new one and nothing is +/// written until something is said in it (#115) — so the screen draws this +/// beside the list rather than looking for it inside the list. +#[tauri::command] +pub fn room_current_topic(state: tauri::State) -> TopicRef { + state.topic() +} + +/// Cut here: a new topic, current from now on. +/// +/// The 新規 button, and the whole of what a topic boundary is — drawn by hand, +/// independent of when the app was started (#115, decision 1). Nothing is +/// written down: the index entry waits for the first post, so a topic opened +/// and left unspoken leaves no row behind. +#[tauri::command] +pub fn room_new_topic(state: tauri::State) -> TopicRef { + let topic = TopicRef::new(now_iso()); + state.enter_topic(topic.clone()); + topic +} + +/// Put an existing topic back in the room. +/// +/// Selecting one from the list. What comes back with it is its posts, which the +/// screen reads for itself, and the session each account was in, which a launch +/// reads when a seat is started (`session.rs`). Nothing is started here: the +/// topic opens whether or not anything can be resumed into it (#115, decision +/// 6). +#[tauri::command] +pub fn room_select_topic( + app: AppHandle, + state: tauri::State, + topic_id: String, + created_at: String, +) -> Result<(), String> { + if !room_log::topic_exists(&app, &topic_id) { + return Err(format!("トピック {topic_id} は見つかりません。")); + } + state.enter_topic(TopicRef { + topic_id, + created_at, + }); + Ok(()) +} + /// 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 diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs index fa3ddf8..1a384fb 100644 --- a/src-tauri/src/room_log.rs +++ b/src-tauri/src/room_log.rs @@ -1,19 +1,43 @@ -//! The room's log. +//! The room's log, kept one file per topic. //! -//! What was said in the room, kept as one append-only jsonl file so a past -//! exchange can be read back after the window that showed it has closed. The -//! room itself keeps nothing: its lines live in the DOM, and closing the app -//! took them (#48). +//! What was said in the room, so an exchange can be read back after the window +//! that showed it has closed. The room itself keeps nothing: its lines live in +//! the DOM, and closing the app took them (#48). //! -//! The log is the room's, not a tab's. `config.rs` already holds a saving -//! apparatus — `SavedChatMessage` / `SessionData` / `TabSessions` — and none of -//! it is used here, deliberately. `SavedChatMessage` carries `role` and -//! `content_type`, which is a chat assistant's vocabulary: the room has no axis -//! `role` lands on, because a person and a session are one kind of participant -//! and what separates two lines is the name on them (#39). `TabSessions` is -//! keyed on a tab, and a tab is how something is launched, not the vessel a -//! conversation happens in. Wiring either through would put the asymmetry #39 -//! removed back into the app from underneath. +//! **A topic is the unit.** The log used to be one file per room and one flow +//! of posts, which is the shape #48 left behind and #115 replaces: a topic is +//! not a section of a transcript, it is the vessel a conversation happens in. +//! Opening one puts it back in the room and the talk continues in it. The +//! boundary between two topics is drawn by hand — 新規 — and has nothing to do +//! with when the app was started: one run may hold several topics, and one +//! topic may span several runs. +//! +//! The layout that follows from that: +//! +//! ```text +//! logs/{room}/index.json one entry per topic: name, when it was made, +//! and the session each account was in +//! logs/{room}/{topic}.jsonl one topic's posts, append-only +//! ``` +//! +//! One file per topic rather than one file with a `topic_id` column, for two +//! reasons that both have to hold. Reading one topic reads one file, where a +//! column would make every read a read of everything ever said (#114). And a +//! topic has something to carry that a post does not: the session each account +//! was in while it was open. A per-post column has nowhere to put that; the +//! index does. +//! +//! **The directory is what exists; the index annotates it.** A topic's posts +//! are the file, and `read_index` adopts any `.jsonl` it finds without an +//! entry. That is what makes lazy creation safe: a launch mints a topic and +//! writes nothing, so a run where nothing was said leaves no row in the list +//! (#115, 決定5から導かれること) — and the one window that would otherwise +//! open, a crash between the post reaching the file and the entry reaching the +//! index, closes because the file alone is enough to rebuild the entry. +//! +//! `logs/{room}.jsonl` from before this — the single flow — is carried in as +//! one topic the first time the index is built. It is moved, not copied and not +//! dropped (#115, decision 8), and the scan above is what gives it its entry. //! //! One line is one post, and it carries the five fields a post is: //! `message_id` / `speaker` / `content` / `to` / `ts`. @@ -26,23 +50,24 @@ //! spoke (`room.rs`) — so a file that recorded it would be recording one //! viewer's position as if it were part of the utterance. //! -//! The file name is `logs/{room}.jsonl`, which is where `design/Vision.dc.html` -//! put it. The room's name occupies a position in that path and is fixed at -//! `main`: several rooms are not implemented, and the path is shaped so that +//! The room's name occupies a position in the path and is fixed at `main`: +//! several rooms are not implemented, and the path is shaped so that //! implementing them adds a value here rather than a directory level (#48). //! -//! No rotation and no ceiling. Vision holds one `main.jsonl` carrying no date, -//! and a log that drops its own oldest entries answers the question this exists -//! for — what was actually said — with silence at exactly the distance that -//! makes the question worth asking. +//! No rotation and no ceiling, per topic or across them. A log that drops its +//! own oldest entries answers the question this exists for — what was actually +//! said — with silence at exactly the distance that makes the question worth +//! asking. +use parking_lot::Mutex; use room_floor::Post; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::{AppHandle, Emitter, Manager}; -/// The room whose log this is, as it appears in the file name. +/// The room whose log this is, as it appears in the path. /// /// One room, and this constant is the seam rather than the omission: what a /// second room would need is another value here, not another shape of path. @@ -56,6 +81,29 @@ const ROOM_NAME: &str = "main"; /// quietly (#48). const LOG_ERROR_EVENT: &str = "room-log-error"; +/// The event the topic list reaches the screen on. +/// +/// Emitted whenever the index changes under the screen rather than because of +/// it: a topic realised by its own first post, or a session id recorded by a +/// launch. What the screen did itself it already knows about, and is told again +/// here rather than being trusted to keep a second copy in step. +const TOPICS_EVENT: &str = "room-topics"; + +/// How long an auto-generated title is allowed to be, in characters. +/// +/// Characters rather than bytes: the titles are Japanese more often than not, +/// and a byte cut would land inside one. +const TITLE_CHARS: usize = 40; + +/// One acquisition for every read-modify-write of the index. +/// +/// The index is a single small file read and rewritten whole. Two writers +/// interleaving would drop one of the two changes — a launch recording a +/// session id while a post realises the topic it landed in is the concrete +/// pair — and neither is a change anyone would notice losing until the topic +/// failed to resume. +static INDEX_LOCK: Mutex<()> = Mutex::new(()); + /// One post, as the log holds it. /// /// The same five fields going in and coming out. @@ -89,7 +137,98 @@ impl LoggedPost { } } -fn log_path(app: &AppHandle) -> Result { +/// One topic, as the index holds it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Topic { + /// Opaque, minted once, and the file name of this topic's posts. Never a + /// title: a title is edited, and a file whose name moved with it would + /// leave the posts behind. + pub topic_id: String, + /// What the list shows. Generated from the opening of the first post said + /// in it and editable afterwards (#115, decision 9). Empty until that first + /// post lands, which is a state the screen draws rather than a value + /// missing. + pub title: String, + /// When it was made, RFC 3339. The room's clock (`room::now_iso`), or the + /// first post's own stamp for a topic adopted from a file. + pub created_at: String, + /// The session each account was in while this topic was open, keyed by + /// account id. + /// + /// This is what makes a topic a vessel rather than a transcript: reopening + /// it hands these back to the launch, and the participant returns carrying + /// its own context instead of being read a summary of it (#115, decisions 3 + /// and 4). + /// + /// Keyed on the account id, which is the identity (#53). Not on the name, + /// which is editable, and not on the room's connection id, which is minted + /// per connection and is gone by the time a topic is reopened. + #[serde(default)] + pub sessions: BTreeMap, +} + +/// The topic the room is in, before anything has been said in it. +/// +/// Held by the room rather than written down. A launch opens a new topic +/// (#115, Master 判断5) and most of what an app run does is not speaking, so a +/// row written at launch would be a row for every time the app was opened. +#[derive(Debug, Clone, Serialize)] +pub struct TopicRef { + pub topic_id: String, + pub created_at: String, +} + +impl TopicRef { + pub fn new(created_at: String) -> Self { + TopicRef { + topic_id: uuid::Uuid::new_v4().to_string(), + created_at, + } + } +} + +/// The topics, as the file holds them. +/// +/// Oldest first, by `created_at`. The screen reverses it — a list is read +/// newest first — and the file keeps the order the conversation happened in. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TopicIndex { + #[serde(default)] + topics: Vec, +} + +impl TopicIndex { + fn find(&self, topic_id: &str) -> Option<&Topic> { + self.topics.iter().find(|one| one.topic_id == topic_id) + } + + fn find_mut(&mut self, topic_id: &str) -> Option<&mut Topic> { + self.topics.iter_mut().find(|one| one.topic_id == topic_id) + } +} + +fn room_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; + Ok(dir.join("logs").join(ROOM_NAME)) +} + +fn index_path(app: &AppHandle) -> Result { + Ok(room_dir(app)?.join("index.json")) +} + +/// Where one topic's posts live. +/// +/// The id is a UUID minted by `TopicRef`, so nothing a person types reaches a +/// path. A title with a slash in it would otherwise be a directory. +fn topic_path(app: &AppHandle, topic_id: &str) -> Result { + Ok(room_dir(app)?.join(format!("{topic_id}.jsonl"))) +} + +/// The single flow this log kept before topics existed. +fn legacy_path(app: &AppHandle) -> Result { let dir = app .path() .app_data_dir() @@ -97,7 +236,183 @@ fn log_path(app: &AppHandle) -> Result { Ok(dir.join("logs").join(format!("{ROOM_NAME}.jsonl"))) } -/// Put one post at the end of the log. +/// Carry the pre-topic single flow in as one topic. +/// +/// Moved rather than copied. Two files holding one conversation is a second +/// ordering of it, and the one thing the room is the authority on is that there +/// is one (`room.rs`). Nothing is dropped: the posts are the file, and the file +/// is what moves (#115, decision 8). The entry it gets is the scan's, like any +/// other file in the directory. +/// +/// Call under `INDEX_LOCK`. +fn migrate_legacy(app: &AppHandle) -> Result<(), String> { + let legacy = legacy_path(app)?; + if !legacy.is_file() { + return Ok(()); + } + let dir = room_dir(app)?; + std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create the room log dir: {e}"))?; + let destination = dir.join(format!("{}.jsonl", uuid::Uuid::new_v4())); + if std::fs::rename(&legacy, &destination).is_ok() { + return Ok(()); + } + // `rename` fails across volumes, which is not a case this app's own app + // data directory produces. The fallback's result is taken from the remove: + // a copy that succeeded and a remove that failed is the one way this ends + // with the conversation in two places. + std::fs::copy(&legacy, &destination) + .map_err(|e| format!("Failed to carry the existing log into a topic: {e}"))?; + std::fs::remove_file(&legacy) + .map_err(|e| format!("Failed to remove the log after carrying it into a topic: {e}")) +} + +/// Read the index off disk and reconcile it with the directory. +/// +/// The reconciliation is not a repair path bolted on: it is what lets a topic +/// exist before any entry is written for it. An entry with no file stays — a +/// topic whose session was launched but which nobody has spoken in yet is that +/// case, and its session id is the whole reason to keep it. +/// +/// Call under `INDEX_LOCK`. +fn read_index(app: &AppHandle) -> Result { + migrate_legacy(app)?; + + let path = index_path(app)?; + let mut index = if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read the topic index: {e}"))?; + serde_json::from_str::(&content) + .map_err(|e| format!("Failed to parse the topic index: {e}"))? + } else { + TopicIndex::default() + }; + + let adopted = adopt_orphans(app, &mut index)?; + index + .topics + .sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.topic_id.cmp(&b.topic_id))); + if adopted || !path.exists() { + write_index(app, &index)?; + } + Ok(index) +} + +/// Give an entry to every topic file the index does not name. +/// +/// Answers true when it changed anything. +fn adopt_orphans(app: &AppHandle, index: &mut TopicIndex) -> Result { + let dir = room_dir(app)?; + let Ok(entries) = std::fs::read_dir(&dir) else { + // No directory is no topics, not a failure. It is the first run. + return Ok(false); + }; + let mut adopted = false; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + let Some(topic_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if index.find(topic_id).is_some() { + continue; + } + let (posts, _) = read_posts(&path); + let first = posts.first(); + index.topics.push(Topic { + topic_id: topic_id.to_string(), + title: first.map(|post| title_from(&post.content)).unwrap_or_default(), + // The first thing said in it, which is the closest thing a file + // carries to when it began. A topic made through the app has its + // own stamp and never reaches here. + created_at: first + .map(|post| post.ts.clone()) + .unwrap_or_else(crate::room::now_iso), + sessions: BTreeMap::new(), + }); + adopted = true; + } + Ok(adopted) +} + +/// Call under `INDEX_LOCK`. +fn write_index(app: &AppHandle, index: &TopicIndex) -> Result<(), String> { + let path = index_path(app)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create the room log dir: {e}"))?; + } + let content = serde_json::to_string_pretty(index) + .map_err(|e| format!("Failed to serialize the topic index: {e}"))?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write the topic index: {e}")) +} + +/// Make sure `topic` has an entry, and hand back a mutable hold on it. +/// +/// The one place a topic reaches the index. Every caller is a deliberate act +/// that has to survive the app closing — the first post, a session id, a +/// rename — and none of them is "the app was opened". +/// +/// Call under `INDEX_LOCK`. +fn realize<'a>(index: &'a mut TopicIndex, topic: &TopicRef) -> &'a mut Topic { + if index.find(&topic.topic_id).is_none() { + index.topics.push(Topic { + topic_id: topic.topic_id.clone(), + title: String::new(), + created_at: topic.created_at.clone(), + sessions: BTreeMap::new(), + }); + } + index + .find_mut(&topic.topic_id) + .expect("just inserted when absent") +} + +/// A title from the opening of the first thing said in the topic. +/// +/// The first line, whitespace collapsed, cut at `TITLE_CHARS`. A list of +/// timestamps is a list nobody can read (#115, decision 9), and the opening of +/// the first post is what a person would have written there anyway. +fn title_from(content: &str) -> String { + let flat = content.split_whitespace().collect::>().join(" "); + if flat.is_empty() { + return String::new(); + } + let mut chars = flat.chars(); + let head: String = chars.by_ref().take(TITLE_CHARS).collect(); + if chars.next().is_some() { + format!("{head}…") + } else { + head + } +} + +/// Say on the screen that the log failed. +/// +/// The room went on without it, so nothing here stops anything. What it stops is +/// the failure being invisible: a log that had quietly stopped recording would +/// still look like a log, and would read as nothing having been said (#48). +pub fn report(app: &AppHandle, message: String) { + eprintln!("[room] {message}"); + let _ = app.emit(LOG_ERROR_EVENT, message); +} + +/// Hand the screen the topic list as it now stands. +fn announce(app: &AppHandle) { + let read = { + let _guard = INDEX_LOCK.lock(); + read_index(app) + }; + match read { + Ok(index) => { + let _ = app.emit(TOPICS_EVENT, index.topics); + } + Err(err) => report(app, err), + } +} + +/// Put one post at the end of a topic. /// /// Called from inside the room's own critical section, which is what makes the /// file's order the floor's order. The alternative — appending once the lock is @@ -105,18 +420,30 @@ fn log_path(app: &AppHandle) -> Result { /// in whichever order the scheduler hands them, and the room would then hold two /// orderings of one conversation instead of one (`room.rs`). /// -/// Returns the failure rather than reporting it. The caller is holding the room -/// lock at this point, and emitting from under it would be doing the screen's -/// work with the room stopped; `room.rs` reports once it is out. +/// Returns whether this was the first post of its topic, so the caller can +/// realise and name the topic once it is out of the lock. The index is a second +/// file read and rewritten whole, and it has no business happening with the room +/// stopped; nothing about a post waits on it. +/// +/// Returns the failure rather than reporting it, for the same reason: the caller +/// is holding the room lock at this point, and emitting from under it would be +/// doing the screen's work with the room stopped; `room.rs` reports once it is +/// out. /// /// The content may contain newlines. It stays one line per post regardless, /// because what is written is JSON and JSON escapes them. -pub fn append(app: &AppHandle, post: &Post) -> Result<(), String> { - let path = log_path(app)?; +pub fn append(app: &AppHandle, topic_id: &str, post: &Post) -> Result { + let path = topic_path(app, topic_id)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create the room log dir: {e}"))?; } + // Read before the write, so "first" means the first in the topic and not + // the first this process wrote. A missing file and an empty one are the + // same state here. + let first = std::fs::metadata(&path) + .map(|meta| meta.len() == 0) + .unwrap_or(true); let mut line = serde_json::to_string(&LoggedPost::of(post)) .map_err(|e| format!("Failed to serialize the post: {e}"))?; @@ -130,43 +457,57 @@ pub fn append(app: &AppHandle, post: &Post) -> Result<(), String> { // One write of the whole line, so no line is half put down by one call and // finished by another's. file.write_all(line.as_bytes()) - .map_err(|e| format!("Failed to append to the room log: {e}")) + .map_err(|e| format!("Failed to append to the room log: {e}"))?; + Ok(first) } -/// Say on the screen that the log failed. +/// Give a topic its entry and its name, from the first thing said in it. /// -/// The room went on without it, so nothing here stops anything. What it stops is -/// the failure being invisible: a log that had quietly stopped recording would -/// still look like a log, and would read as nothing having been said (#48). -pub fn report(app: &AppHandle, message: String) { - eprintln!("[room] {message}"); - let _ = app.emit(LOG_ERROR_EVENT, message); +/// Out of the room's lock. Silent about a title when the topic already has one: +/// a title the person edited is theirs, and a first post arriving in a topic +/// renamed before anyone spoke would otherwise take it back. +pub fn realize_from_first_post(app: &AppHandle, topic: &TopicRef, content: &str) { + let written = { + let _guard = INDEX_LOCK.lock(); + match read_index(app) { + Err(err) => { + report(app, err); + return; + } + Ok(mut index) => { + let entry = realize(&mut index, topic); + if entry.title.is_empty() { + entry.title = title_from(content); + } + match write_index(app, &index) { + Ok(()) => true, + Err(err) => { + report(app, err); + false + } + } + } + } + }; + if written { + announce(app); + } } -/// The log, oldest first. -/// -/// For the screen, and only for the screen. Nothing here is re-delivered to -/// participants: a session that joined late missed what predates its seat, and -/// that is the room's existing answer (`room.rs`). Feeding the log back into a -/// channel would make the room start holding who has heard what, which is the -/// shape #31 and #39 turned down. +/// One topic's posts, oldest first. /// -/// A line that does not parse is skipped rather than failing the read. The case -/// it covers is a torn tail from a run that ended mid-write, and refusing the -/// whole history over the last line of it would lose everything to protect -/// nothing. It is not skipped quietly — the count goes back to the screen on -/// the same event a failed append does. -#[tauri::command] -pub fn room_log(app: AppHandle) -> Result, String> { - let path = log_path(&app)?; - // No file is no history, not a failure. It is what the first run of the app - // looks like. - if !path.exists() { - return Ok(Vec::new()); - } - - let content = - std::fs::read_to_string(&path).map_err(|e| format!("Failed to read the room log: {e}"))?; +/// The tuple's second half is how many lines did not parse. A line that does +/// not parse is skipped rather than failing the read: the case it covers is a +/// torn tail from a run that ended mid-write, and refusing the whole topic over +/// the last line of it would lose everything to protect nothing. It is not +/// skipped quietly — the count goes back to the screen on the same event a +/// failed append does. +fn read_posts(path: &Path) -> (Vec, usize) { + // No file is no history, not a failure. It is what a topic nobody has + // spoken in yet looks like. + let Ok(content) = std::fs::read_to_string(path) else { + return (Vec::new(), 0); + }; let mut posts = Vec::new(); let mut skipped = 0usize; @@ -179,10 +520,130 @@ pub fn room_log(app: AppHandle) -> Result, String> { Err(_) => skipped += 1, } } + (posts, skipped) +} +/// One topic's posts, for a caller inside this process. +/// +/// The pull the sidecar's read tool reaches (`room.rs`), and the same read the +/// screen makes. One function, so the two surfaces cannot disagree about what a +/// topic contains. +pub fn topic_posts(app: &AppHandle, topic_id: &str) -> Result, String> { + let path = topic_path(app, topic_id)?; + let (posts, skipped) = read_posts(&path); if skipped > 0 { - report(&app, format!("読めなかった記録が {skipped} 件あります")); + report(app, format!("読めなかった記録が {skipped} 件あります")); } - Ok(posts) } + +/// The session `account_id` was in while `topic_id` was open, if one is on +/// record. +pub fn session_of(app: &AppHandle, topic_id: &str, account_id: &str) -> Option { + let _guard = INDEX_LOCK.lock(); + read_index(app) + .ok()? + .find(topic_id)? + .sessions + .get(account_id) + .cloned() +} + +/// Whether a topic has an entry in the index. +/// +/// What selecting one from the list is checked against. A topic the room is +/// currently in but nothing has realised yet answers false here, and the room +/// answers for that one itself (`room.rs`). +pub fn topic_exists(app: &AppHandle, topic_id: &str) -> bool { + let _guard = INDEX_LOCK.lock(); + read_index(app) + .map(|index| index.find(topic_id).is_some()) + .unwrap_or(false) +} + +/// Record which session an account was launched into a topic under. +/// +/// Written at launch rather than at exit, because the id is decided before the +/// CLI starts and a session that ends badly is exactly the one worth being able +/// to resume. It realises the topic: starting a session in a topic is a +/// deliberate act, and the id has to outlive the run to be worth anything. +pub fn record_session( + app: &AppHandle, + topic: &TopicRef, + account_id: &str, + session_id: &str, +) -> Result<(), String> { + { + let _guard = INDEX_LOCK.lock(); + let mut index = read_index(app)?; + realize(&mut index, topic) + .sessions + .insert(account_id.to_string(), session_id.to_string()); + write_index(app, &index)?; + } + announce(app); + Ok(()) +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +/// The topics, oldest first. +/// +/// The topic the room is currently in is in this list only once something has +/// realised it. The screen holds its own current topic and draws it whether or +/// not the list names it (`room_current_topic`). +#[tauri::command] +pub fn room_topics(app: AppHandle) -> Result, String> { + let _guard = INDEX_LOCK.lock(); + Ok(read_index(&app)?.topics) +} + +/// One topic's posts, oldest first. +/// +/// For the screen. The topic is named rather than assumed, because the screen +/// reads a topic that is not the current one every time the list is used: that +/// is what selecting one is. +#[tauri::command] +pub fn room_topic_log(app: AppHandle, topic_id: String) -> Result, String> { + topic_posts(&app, &topic_id) +} + +/// Rename a topic. +/// +/// The generated title is a starting point in an editable field, which is the +/// same thing an account's name is (#115, decision 9). Blank is refused rather +/// than stored: an empty title is the state a topic has before anything is said +/// in it, and a topic deliberately cleared would read as one nobody has spoken +/// in. +/// +/// Realising is deliberate here too — naming a topic before speaking in it is +/// the person deciding it exists. +#[tauri::command] +pub fn room_rename_topic( + app: AppHandle, + state: tauri::State, + topic_id: String, + title: String, +) -> Result<(), String> { + let title = title.trim().to_string(); + if title.is_empty() { + return Err("トピック名を入力してください。".to_string()); + } + { + let _guard = INDEX_LOCK.lock(); + let mut index = read_index(&app)?; + match index.find_mut(&topic_id) { + Some(topic) => topic.title = title, + None => { + let current = state.topic(); + if current.topic_id != topic_id { + return Err(format!("トピック {topic_id} は見つかりません。")); + } + realize(&mut index, ¤t).title = title; + } + } + write_index(&app, &index)?; + } + announce(&app); + Ok(()) +} From 3ce02cb05a06f8d66f975a256ead338d33755966 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Thu, 27 Aug 2026 19:10:23 +0900 Subject: [PATCH 2/6] feat(session): resume the session a topic held, through a per-account resume line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit トピックが持つ `{account_id: session_uuid}` を起動へ差し込む。復帰の手段は二段構えであり、ここはネイティブ resume を持つ CLI の側(決定4B)。 - アカウントに `再開コマンド` を追加。`{session_id}` を含む一行であり、先頭語が command になる(例: `claude --resume {session_id}`)。 - 「こちらが UUID を決める」側は欄を増やさず、同じ `{session_id}` を起動オプションに書く形にした。どのフラグが id を運ぶかは CLI ごとの問いであり、再開コマンドが答えているのと同じ問いである。書かなければ id を配らない——resume を持たない CLI が恒常的に置かれる状態がこれである。 - 判定は「アカウントが resume を持つか」ではなく「このトピックがこのアカウントのセッションを持つか」で行う。どちらかが欠ければ新規起動であり、失敗ではない。読み出し(決定4C)が残るため。 - 起動チェック(`reject_incompatible_flags` / `declares_settings`)は解決後の行に対して走る。アカウントの行を見て別の行を spawn する形にしない。 - `StartedSession.resumed` を画面へ返す。トピックは復帰できた席とできなかった席の両方を抱えて開く(決定6)。 #115 --- crates/mcp-config/src/lib.rs | 61 +++++++++++++++ src-tauri/src/config.rs | 36 +++++++++ src-tauri/src/session.rs | 141 +++++++++++++++++++++++++++++++++-- 3 files changed, 230 insertions(+), 8 deletions(-) diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index 3f72399..f118b11 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -185,6 +185,39 @@ pub fn declares_settings(args: &[String]) -> bool { .any(|arg| arg.split('=').next().unwrap_or(arg) == SETTINGS_FLAG) } +/// What an account writes where the id of a CLI session goes. +/// +/// The one thing this app knows about resuming a session is that the id is +/// decided here rather than read back out of the CLI's output. Which flag +/// carries it is the CLI's business, and the CLI is per-account +/// (`Account::command`) — so the app substitutes into a line the person wrote +/// instead of holding a flag of its own. `claude` spells the two halves +/// `--session-id ` and `--resume `; another CLI spells them +/// otherwise, or not at all, and an account that writes the placeholder nowhere +/// simply has no session id (#115, decision 4B). +pub const SESSION_ID_PLACEHOLDER: &str = "{session_id}"; + +/// Whether these arguments have somewhere to put a session id. +/// +/// What decides whether one is minted at all. Minting unconditionally would +/// hand out an id no launch passes on, and the topic would then record a +/// session that never existed under that name. +pub fn declares_session_id(args: &[String]) -> bool { + args.iter().any(|arg| arg.contains(SESSION_ID_PLACEHOLDER)) +} + +/// Put the session id where the account said it goes. +/// +/// Every occurrence in every argument, and inside a larger argument as well as +/// alone: `--session-id={session_id}` is one argument, and so is +/// `--resume={session_id}`. Arguments naming no placeholder come through +/// untouched. +pub fn substitute_session_id(args: &[String], session_id: &str) -> Vec { + args.iter() + .map(|arg| arg.replace(SESSION_ID_PLACEHOLDER, session_id)) + .collect() +} + /// The character an account speaks as, or `None` when it declares none. /// /// Blank is the same state as absent. The field is a text input on the screen, @@ -1055,4 +1088,32 @@ mod tests { ] ); } + #[test] + fn a_session_id_is_substituted_wherever_the_account_wrote_it() { + let args = split_launch_options("--resume {session_id} --verbose"); + let filled = substitute_session_id(&args, "0f5a-uuid"); + assert_eq!(filled, vec!["--resume", "0f5a-uuid", "--verbose"]); + } + + #[test] + fn a_session_id_is_substituted_inside_one_argument_too() { + let args = split_launch_options("--session-id={session_id}"); + let filled = substitute_session_id(&args, "0f5a-uuid"); + assert_eq!(filled, vec!["--session-id=0f5a-uuid"]); + } + + #[test] + fn options_with_no_placeholder_declare_no_session_id() { + let args = split_launch_options("--dangerously-skip-permissions"); + assert!(!declares_session_id(&args)); + assert_eq!(substitute_session_id(&args, "0f5a-uuid"), args); + } + + #[test] + fn options_naming_the_placeholder_declare_a_session_id() { + assert!(declares_session_id(&split_launch_options( + "--session-id {session_id}" + ))); + } + } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 8a5b90c..48077b3 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -92,6 +92,33 @@ pub struct Account { /// nobody made. #[serde(default)] pub character: Option, + /// The whole command line that puts this account back into a session it was + /// already in, with `{session_id}` where the id goes — for example + /// `claude --resume {session_id}`. `None` when the account declares none. + /// + /// A topic holds which session each account was in while it was open + /// (`room_log::Topic::sessions`), and reopening one hands that id to this + /// line. What comes back is the participant's own context, carried by the + /// CLI rather than read out to it — which is why this is a resume and not a + /// replay of the log (#115, decision 4B). + /// + /// A whole command line rather than options alone, because resuming may not + /// be the same invocation: it is the line the person would type. It is split + /// the way launch options are, and the first token is the command. + /// + /// The other half of the pair is not a field. A fresh launch hands the CLI + /// an id this app decided, and where that id goes on the line is the same + /// per-CLI question this field answers — so it is written into the launch + /// options with the same `{session_id}` placeholder + /// (`mcp_config::SESSION_ID_PLACEHOLDER`). An account that writes it + /// nowhere is launched with no id at all, which is the state a CLI with no + /// resume of its own is permanently in. + /// + /// Absent is a real state and the common one. An account that declares no + /// resume line is launched fresh into a reopened topic and reads back what + /// it needs through the room's own pull instead (#115, decision 4C). + #[serde(default)] + pub resume_command: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -154,6 +181,11 @@ impl Default for AppConfig { hue: None, kind: AccountKind::Ai, character: None, + // Nothing, rather than a line guessed from the command above. + // A resume line naming the wrong flag fails at the one moment + // it is needed, and the person has no reason to go looking at a + // field they never filled in. + resume_command: None, }], } } @@ -201,6 +233,10 @@ pub fn load_config(app: AppHandle) -> Result { // joining under — lives in the webview's own storage and never reached // this file (#59). // + // `resume_command` is the same shape of nothing again: an account saved + // before it existed declares no way of resuming, which is what every + // account did then — there was no topic for a session to be resumed into. + // // `character` is the same again, and its absence is the state it means: // an account saved before it existed declared no character, so its launch // reads whatever its working directory's own `settings.json` names — which diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 879ffcb..f7e390f 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -11,9 +11,11 @@ use crate::config::{Account, AccountKind}; use crate::pty::{self, PtyState}; use crate::room::RoomState; +use crate::room_log::{self, TopicRef}; use mcp_config::{ - declared_character, declares_settings, launch_args, other_room_servers, register_sidecar, - reject_incompatible_flags, server_name_for, RoomRegistration, + declared_character, declares_session_id, declares_settings, launch_args, other_room_servers, + register_sidecar, reject_incompatible_flags, server_name_for, split_launch_options, + substitute_session_id, RoomRegistration, }; use parking_lot::Mutex; use std::collections::BTreeMap; @@ -284,6 +286,87 @@ pub fn preview_launch_args( launch_args(&args, &server_name, character.as_deref(), &others) } +/// The line one launch runs, resolved against the topic it is being started +/// into. +/// +/// Two lines exist for one account and this picks between them. The account's +/// own command starts a session; its resume line puts it back into one it was +/// already in, and which applies is not a property of the account — it is +/// whether *this topic* holds a session for it (#115, decisions 3 and 4B). +struct LaunchLine { + command: String, + args: Vec, + /// The session id this launch is handing the CLI, when it is handing one. + /// + /// `Some` only on a fresh launch that had somewhere to put it: the id is + /// decided here and recorded on the topic, so the next opening of that + /// topic has something to resume. A resume passes an id it was given and + /// mints nothing, so it is `None` — there is nothing new to record. + session_id: Option, + /// Whether this is the resume line rather than the launch line. + resumed: bool, +} + +/// Which of the account's two lines this launch is, and with which id. +/// +/// Resume needs both halves: a session recorded for this account in this topic, +/// and a line that knows how to go back into one. Missing either, the launch is +/// a fresh one — the account then reads back what it needs through the room's +/// own pull instead, which is the second tier of the two-tier answer and the +/// reason a missing resume line is a degraded state rather than a failure +/// (#115, decision 4). +/// +/// A fresh launch mints an id whenever the account's options name the +/// placeholder, including when the topic already holds one. The old id is +/// replaced rather than kept: without a resume line it can never be used again, +/// and what a topic should hold is the session that is actually in it. +fn resolve_launch( + app: &AppHandle, + account: &Account, + topic: &TopicRef, +) -> Result { + let recorded = room_log::session_of(app, &topic.topic_id, &account.id); + let resume = account + .resume_command + .as_deref() + .map(str::trim) + .filter(|line| !line.is_empty()); + + if let (Some(session_id), Some(line)) = (recorded.as_deref(), resume) { + let mut parts = split_launch_options(line); + if parts.is_empty() { + return Err(format!( + "Account \"{}\" has a resume command that splits into nothing. Write the whole line, command first.", + account.name.trim() + )); + } + let command = parts.remove(0); + return Ok(LaunchLine { + command, + args: substitute_session_id(&parts, session_id), + session_id: None, + resumed: true, + }); + } + + if declares_session_id(&account.args) { + let session_id = uuid::Uuid::new_v4().to_string(); + return Ok(LaunchLine { + command: account.command.clone(), + args: substitute_session_id(&account.args, &session_id), + session_id: Some(session_id), + resumed: false, + }); + } + + Ok(LaunchLine { + command: account.command.clone(), + args: account.args.clone(), + session_id: None, + resumed: false, + }) +} + /// What the caller gets back after a session joins. #[derive(Debug, serde::Serialize)] pub struct StartedSession { @@ -298,6 +381,15 @@ pub struct StartedSession { /// late. Same clock as a post's `ts`, so the panel's start time and the /// first line of the conversation can be read against each other. pub started_at: String, + /// True when this went in through the account's resume line rather than its + /// launch line. + /// + /// Handed back so the screen can say which of the two happened. Under + /// decision 6 a topic opens whether or not a seat could be resumed, and a + /// seat that came back fresh is not a failure — but it is a different thing + /// from one that came back carrying its own context, and the person is the + /// one who can tell whether that matters. + pub resumed: bool, } /// Put one account into the room. @@ -348,7 +440,17 @@ pub fn start_session( )); } - if let Err(flag) = reject_incompatible_flags(&account.args) { + // Which topic this seat is being started into. Read from the room rather + // than passed in by the screen: the room is where the current topic lives, + // and a value carried through the screen could name a topic the room has + // since left. + let topic = room.topic(); + // Resume or fresh, decided here so every check below runs against the line + // that will actually be spawned. Checking the account's own options and + // then spawning the resume line would be checking the wrong line. + let launch_line = resolve_launch(&app, &account, &topic)?; + + if let Err(flag) = reject_incompatible_flags(&launch_line.args) { return Err(format!( "Account \"{name}\" passes {flag}, which stops channel pushes from arriving. \ Remove it from the launch options." @@ -397,7 +499,7 @@ pub fn start_session( // as the sibling's sidecar started anyway (#103). Refused rather than // stripped, for the reason the flag guard above is: a launch that quietly // dropped half of what was asked for looks like it worked. - if (character.is_some() || !others.is_empty()) && declares_settings(&account.args) { + if (character.is_some() || !others.is_empty()) && declares_settings(&launch_line.args) { // Two refusals rather than one sentence with a hole in it: what the // person can do about it differs. A character is theirs to clear; a // sibling registration is another account's, and the way out of that @@ -427,10 +529,11 @@ pub fn start_session( })?; match launch( - app, + app.clone(), &room, pty_state, &account, + &launch_line, &name, character, &others, @@ -441,6 +544,22 @@ pub fn start_session( rows, ) { Ok(started) => { + // On the topic, so the next opening of it can resume this session. + // Recorded after the spawn rather than before: an id written for a + // launch that failed would be resumed into a session that was never + // started. + // + // A failure here does not fail the launch. The session is running — + // what is lost is the ability to resume it later, and the room's own + // pull still stands for that topic (#115, decision 4C). It is said + // on the same surface a failed append is said on, for the same + // reason: a record that quietly stopped being kept still looks like + // one. + if let Some(session_id) = &launch_line.session_id { + if let Err(err) = room_log::record_session(&app, &topic, &account.id, session_id) { + room_log::report(&app, err); + } + } // The launch's own values, not the account's. The account may be // edited while this runs, and what is running would then be // reported as whatever was typed into the form afterwards. @@ -449,7 +568,9 @@ pub fn start_session( RunningSession { pty_id: started.pty_id.clone(), started_at: started.started_at.clone(), - command: account.command.clone(), + // The line that ran, which on a resume is not the account's + // launch command at all. + command: launch_line.command.clone(), cwd: cwd.to_string_lossy().to_string(), }, ); @@ -475,6 +596,9 @@ fn launch( room: &RoomState, pty_state: tauri::State, account: &Account, + // Command and arguments as resolved against the topic, so what is spawned + // is what was checked. + line: &LaunchLine, name: &str, character: Option<&str>, // The sibling registrations this session must not start, read from the @@ -508,12 +632,12 @@ fn launch( let pty_id = pty::spawn_pty( app, pty_state, - account.command.clone(), + line.command.clone(), // The same function the preview goes through, so what the form showed // is what spawns. Nothing is written for the settings: `--settings` // takes the JSON inline, and a file per account would grow the very // directory this account is sharing (#99). - launch_args(&account.args, server_name, character, others), + launch_args(&line.args, server_name, character, others), cols, rows, Some(cwd.to_string_lossy().to_string()), @@ -523,5 +647,6 @@ fn launch( pty_id, mcp_config: mcp_config.to_string_lossy().to_string(), started_at, + resumed: line.resumed, }) } From 2b76da0c323e8fc9c2316d8282e77c2cef04b155 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Thu, 27 Aug 2026 19:14:31 +0900 Subject: [PATCH 3/6] feat(sidecar): give a participant a way to pull this topic's past posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `say_to_room` の隣に `read_room_history` を置く(決定4C)。部屋は過去を配らないままであり、変わるのは参加者が自分で取りに行けることだけ。#31 / #39 が退けた「部屋が誰の既読を持つ」形には触れない。 - `history` / `history_result` フレームで往復する。相関は `request_id`。投稿の相関が `message_id` であるのと同じ形。 - 既定 50 件、上限 200 件。`before` に message_id を渡すとその手前が返る。トピックに上限が無い以上、一回の tool 結果へ全部載せる形は採らない。 - 押し付けない。起動時に流し込む力業は、トピックが伸びるほど毎回の起動が高くつき、CLI は流し込まれた文を自分の入力と区別できず、端末ペインにその塊が見える(#84)。 - 「発言する道具は一本」の制約は投稿の軸のものであり、この道具は投稿しない。ラウンドトリップテストでその境界を書き直した。 - protocol 6。 #115 --- sidecar/src/index.ts | 219 ++++++++++++++++++++++++++++++- sidecar/test/round-trip.test.mjs | 142 +++++++++++++++++++- 2 files changed, 354 insertions(+), 7 deletions(-) diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index adbb036..0fc6f26 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -17,6 +17,11 @@ * Direction of travel: * someone posts -> WebSocket frame -> channel notification -> agent reacts * this agent posts -> `say_to_room` tool -> WebSocket frame -> the room + * this agent looks back -> `read_room_history` tool -> WebSocket frame -> the room + * + * The third one is a pull and only a pull. The room pushes nothing it did not + * fan out live, so a session that joined a topic late is still handed nothing + * — what changes is that it can now go and get it (#115, decision 4C). * * Both directions carry the same frame. A person and a session are both * participants of the room, and what separates them is a name (#39). @@ -70,7 +75,7 @@ function readHue(raw: string | undefined): number | null { */ const ACCOUNT_ID = process.env.PULLCEPT_ACCOUNT_ID?.trim() || null; -const PROTOCOL_VERSION = 5; +const PROTOCOL_VERSION = 6; /** * How long a post waits for the room to answer it. @@ -166,6 +171,26 @@ interface PostResultFrame { missed?: MissedPost[]; } +/** One post as the room's log kept it. No hue and no `own`: see room_log.rs. */ +interface LoggedPost { + message_id?: string; + speaker?: string; + content?: string; + to?: string; + ts?: string; +} + +/** The room's answer to one pull of the current topic's past posts. */ +interface HistoryResultFrame { + type: "history_result"; + request_id?: string; + posts?: LoggedPost[]; + /** True when the topic holds posts older than the oldest one returned. */ + has_more?: boolean; + /** Set instead of `posts` when the room could not read the topic. */ + error?: string; +} + // ── MCP server ─────────────────────────────────────────────────────────────── const INSTRUCTIONS = [ @@ -180,6 +205,15 @@ const INSTRUCTIONS = [ "発言するときは say_to_room ツールを呼んでください。ターミナルへの出力は", "部屋には届きません。", "", + "前を見る:", + "- あなたが来る前の発言は届きません。部屋は過去を配らないからです。", + "- 必要になったら read_room_history を呼んでください。今のトピックで", + " それまでに言われたことが、古い順で返ります。", + "- 押し付けられないので、要らないときは呼ばないでください。話の流れが", + " 分からないまま答えそうなときにだけ引けば足ります。", + "- 返り切らなかったときは、いちばん古い発言の message_id を before に", + " 入れてもう一度呼ぶと、その手前が返ります。", + "", "宛先:", "- 発言には宛先が付くことがあります。宛先は meta.to に入っています。", `- meta.to が「${AGENT_NAME}」なら、あなた宛です。答えてください。`, @@ -260,6 +294,45 @@ const TOOLS = [ required: ["content"], }, }, + /** + * The pull, and the second of the two tools. + * + * `say_to_room` stays the only way to be heard, which is the constraint that + * kept the tool count at one: a second way to speak would put "which one do I + * answer through" back on the agent. This one cannot speak. It reads, and + * reading is the thing the room had no way of doing at all — a session that + * joined a topic after it started was simply told nothing (#115, decision 4C). + * + * Pull rather than push, deliberately. Handing the whole topic to a session at + * launch costs every launch the length of the topic whether the session needed + * it or not, arrives as text the CLI cannot tell from something the person + * typed, and lands in the terminal pane, which belongs to the session (#84). + */ + { + name: "read_room_history", + description: + "Read what was said in this room's current topic before now. Use it when " + + "you joined after the conversation started and need what you missed; the " + + "room never delivers past posts on its own. Reading only — it posts nothing.", + inputSchema: { + type: "object" as const, + properties: { + limit: { + type: "number", + description: + "How many posts to return, newest-most first-page. Defaults to 50 " + + "and is capped by the room.", + }, + before: { + type: "string", + description: + "Optional. Return the posts older than this message_id. Use the " + + "oldest message_id of the previous page to keep reading backwards.", + }, + }, + required: [] as string[], + }, + }, ]; mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); @@ -267,6 +340,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); mcp.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + if (name === "read_room_history") return await readHistory(args); + if (name !== "say_to_room") { return { content: [{ type: "text", text: `Unknown tool: ${name}` }], @@ -351,6 +426,105 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: "Delivered to the room." }] }; }); +/** + * Ask the room for the current topic's past posts, and put the answer where the + * agent will read it. + * + * Failures are `isError`, and each of the three says which one it is. "Nothing + * came back" and "nothing was said" are different answers, and an agent handed + * the first as the second stops looking. + */ +async function readHistory(args: Record | undefined): Promise<{ + content: { type: "text"; text: string }[]; + isError?: boolean; +}> { + const limit = typeof args?.limit === "number" && Number.isFinite(args.limit) + ? Math.trunc(args.limit) + : undefined; + const before = typeof args?.before === "string" && args.before.trim() + ? args.before.trim() + : undefined; + + const requestId = randomUUID(); + // Registered before the frame goes out, so an answer arriving inside the same + // tick has somewhere to land. + const answered = awaitHistoryResult(requestId); + const sent = sendToRoom({ + type: "history", + request_id: requestId, + ...(limit === undefined ? {} : { limit }), + ...(before ? { before } : {}), + }); + + if (!sent) { + abandonHistory(requestId); + await answered; + return { + content: [ + { + type: "text", + text: `Not read: the room socket is not connected (${roomStatus()}).`, + }, + ], + isError: true, + }; + } + + const result = await answered; + if (result === null) { + return { + content: [ + { + type: "text", + text: + `Not read: the room did not answer (${roomStatus()}). This is not ` + + "the same as the topic being empty — do not conclude that nothing was said.", + }, + ], + isError: true, + }; + } + if (typeof result.error === "string") { + return { + content: [{ type: "text", text: `Not read: ${result.error}` }], + isError: true, + }; + } + + return { content: [{ type: "text", text: describeHistory(result) }] }; +} + +/** One page of a topic, oldest first, written the way a refusal writes posts. */ +function describeHistory(result: HistoryResultFrame): string { + const posts = result.posts ?? []; + if (posts.length === 0) { + // Said as the state it is. "No result" would read as a failure, and this is + // an answer: nothing has been said in this topic yet, or nothing before the + // point asked about. + return "Nothing was said in this topic before this point."; + } + const lines = posts.map((one) => { + const addressee = one.to ? ` -> ${one.to}` : ""; + const id = one.message_id ?? "?"; + const ts = one.ts ? `${one.ts} ` : ""; + return `- ${ts}[${id}] ${one.speaker ?? "someone"}${addressee}: ${one.content ?? ""}`; + }); + const oldest = posts[0]?.message_id; + const tail = result.has_more + ? oldest + ? `There is more before this. Call read_room_history again with before: "${oldest}".` + : "There is more before this." + : "This is the beginning of the topic."; + return [ + "What was said in this topic before now, oldest first:", + ...lines, + "", + tail, + "None of this was delivered to you as it happened, and none of it is " + + "addressed to you now. Read it as context, not as something to answer.", + ].join("\n"); +} + /** The room's refusal, written so the next move is unambiguous. */ function describeRefusal(missed: MissedPost[]): string { const lines = missed.map((one) => { @@ -398,6 +572,43 @@ function roomStatus(): string { */ const awaitingResult = new Map void>(); +/** + * Pulls waiting for the room's answer, keyed by the id they were sent under. + * + * A map of its own rather than a shared one with `awaitingResult`: the two are + * correlated on different fields and settled by different frames, and one map + * would need a discriminator to say which — which is the field the frame's own + * `type` already is. + */ +const awaitingHistory = new Map void>(); + +function awaitHistoryResult(requestId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + awaitingHistory.delete(requestId); + resolve(null); + }, POST_RESULT_TIMEOUT); + timer.unref?.(); + awaitingHistory.set(requestId, (result) => { + clearTimeout(timer); + awaitingHistory.delete(requestId); + resolve(result); + }); + }); +} + +/** Settle the pull this answer belongs to, and only that one. */ +function settleHistoryResult(frame: HistoryResultFrame): void { + const id = frame.request_id; + if (typeof id !== "string") return; + awaitingHistory.get(id)?.(frame); +} + +/** Give up on one pull's answer: nothing will come for it. */ +function abandonHistory(requestId: string): void { + awaitingHistory.get(requestId)?.(null); +} + function awaitPostResult(messageId: string): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { @@ -430,6 +641,7 @@ function abandonPost(messageId: string): void { * out the timeout would leave the agent blocked for no new information. */ function abandonPendingPosts(): void { for (const settle of [...awaitingResult.values()]) settle(null); + for (const settle of [...awaitingHistory.values()]) settle(null); } function sendToRoom(frame: Record): boolean { @@ -521,6 +733,11 @@ function connectRoom(): void { // the tool call's own result, and putting it in the conversation would // read as somebody having said it. else if (frame.type === "post_result") settlePostResult(frame as PostResultFrame); + // The answer to this agent's own pull. Not pushed to the channel either, + // and for a stronger reason than a receipt: these are posts, and putting + // them in the conversation would be the room delivering the past after all + // — which is the one thing the pull exists in order not to do. + else if (frame.type === "history_result") settleHistoryResult(frame as HistoryResultFrame); // Unknown frame kinds are ignored on purpose; see the frame comment above. }); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index cfced7f..1e03626 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -46,6 +46,21 @@ const TURN_TAKING = [ " 足りないことがあるときだけ足してください。", ].join("\n"); +// Looking back. The room hands a late joiner nothing, by design, so the whole +// of what makes the read reachable is that the manners name it and say when it +// is worth calling (#115, decision 4C). Asserted in full for the reason the two +// above are: a head-only check passes on a paragraph whose tail was deleted. +const LOOKING_BACK = [ + "前を見る:", + "- あなたが来る前の発言は届きません。部屋は過去を配らないからです。", + "- 必要になったら read_room_history を呼んでください。今のトピックで", + " それまでに言われたことが、古い順で返ります。", + "- 押し付けられないので、要らないときは呼ばないでください。話の流れが", + " 分からないまま答えそうなときにだけ引けば足ります。", + "- 返り切らなかったときは、いちばん古い発言の message_id を before に", + " 入れてもう一度呼ぶと、その手前が返ります。", +].join("\n"); + const SEE_THE_FLOOR = [ "床を見てから送る:", "- say_to_room には last_seen を付けてください。値は、あなたが実際に見た", @@ -124,12 +139,55 @@ test("a room post reaches the channel, and say_to_room reaches the room", async }, ]; + // What the topic held before this session joined. The room delivers none of + // it live — a later joiner missed it — so the only way it reaches the agent + // is the pull (#115, decision 4C). + const PAST = [ + { + message_id: "h-1", + speaker: "Master", + content: "この件は昨日決めた", + ts: "2026-08-26T00:00:00.000Z", + }, + { + message_id: "h-2", + speaker: "Claude Lay", + content: "了解しました", + to: "Master", + ts: "2026-08-26T00:00:01.000Z", + }, + ]; + const historyFrames = []; + wss.on("connection", (socket) => { roomSocket = socket; connected.resolve(socket); socket.on("message", (raw) => { const frame = JSON.parse(raw.toString()); if (frame.type === "hello") helloSeen.resolve(frame); + if (frame.type === "history") { + historyFrames.push(frame); + // An answer for a pull nobody made, sent first. The call must not + // settle on it: pulls are correlated by request_id, the way posts are + // by message_id, and arrival order says nothing. + socket.send( + JSON.stringify({ + type: "history_result", + request_id: "not-this-pull", + posts: [], + has_more: false, + }), + ); + socket.send( + JSON.stringify({ + type: "history_result", + request_id: frame.request_id, + posts: PAST, + has_more: true, + }), + ); + return; + } if (frame.type !== "post") return; postFrames.push(frame); @@ -320,18 +378,38 @@ test("a room post reaches the channel, and say_to_room reaches the room", async SEE_THE_FLOOR, "instructions must carry the floor manners in full, tail included", ); + // The pull. A tool nobody is told about is a tool nobody calls: the room + // still delivers nothing that predates a seat, so a session that joined a + // topic late learns what it missed only by knowing to go and ask (#115, + // decision 4C). + assertContains( + instructions, + LOOKING_BACK, + "instructions must carry the looking-back manners in full, tail included", + ); notify("notifications/initialized", {}); const tools = await request("tools/list", {}); + const toolNames = tools.result.tools.map((tool) => tool.name); + // One way to speak, one way to look back. The constraint that held the count + // at one is about *posting*: a second way to be heard would put "which one do + // I answer through" back on the agent. `read_room_history` cannot post, so it + // does not sit on that axis (#115, decision 4C). assert.deepEqual( - tools.result.tools.map((tool) => tool.name), - ["say_to_room"], + toolNames, + ["say_to_room", "read_room_history"], + "one posting tool and one reading tool, and nothing else", + ); + assert.equal( + toolNames.filter((name) => name === "say_to_room").length, + 1, "exactly one posting tool is exposed", ); - // Seeing the floor is an argument of the one tool, not a second tool. A - // separate read call would put "which one do I speak through" back on the - // agent, which is the reason there is one (docs/0-requirements.md). + // Seeing the floor is an argument of the posting tool, not a tool of its own. + // The watermark is a claim about what the speaker saw, made at the moment of + // speaking; split into its own call it would be a claim about a moment that + // has already passed by the time the post goes out (#47). const schema = tools.result.tools[0].inputSchema; assert.deepEqual( Object.keys(schema.properties).sort(), @@ -358,7 +436,7 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // (#59). It rides on `hello` and decides nothing: identity in the room is the // connection, and this frame cannot set that (#39 / #40). assert.equal(hello.account_id, TEST_ACCOUNT); - assert.equal(hello.protocol, 5); + assert.equal(hello.protocol, 6); roomSocket.send( JSON.stringify({ @@ -511,6 +589,58 @@ test("a room post reaches the channel, and say_to_room reaches the room", async "the refusal must name the watermark to declare on the next attempt", ); + // ── the pull: what the topic held before this session joined ────────────── + // The room hands a late joiner nothing, and this is the whole of what a + // participant can do about that. It is a read: nothing is posted, and the + // frame that goes out is not a post (#115, decision 4C). + const pulled = await request("tools/call", { + name: "read_room_history", + arguments: { limit: 2 }, + }); + assert.ok(!pulled.result.isError, `pull failed: ${JSON.stringify(pulled.result)}`); + assert.equal(historyFrames.length, 1, "one pull produces exactly one frame"); + assert.equal(historyFrames[0].type, "history"); + assert.equal(historyFrames[0].limit, 2); + assert.equal( + "before" in historyFrames[0], + false, + "a first page names no cursor; an empty one would be a value the room has to rule out", + ); + // A pull is not a post. Reaching the room as one would put words in the room + // that nobody said. + assert.equal( + postFrames.length, + 3, + "reading the topic must not put anything on the floor", + ); + const past = pulled.result.content[0].text; + // Correlation held: the answer for another pull arrived first and did not + // settle this call. + for (const one of PAST) { + assertContains(past, one.message_id, "each past post must carry its id"); + assertContains(past, one.speaker, "each past post must name its speaker"); + assertContains(past, one.content, "each past post must carry what was said"); + } + assertContains( + past, + "Claude Lay -> Master:", + "a past post addressed to someone comes back carrying who it was for", + ); + // The way to keep reading backwards, named concretely. "There is more" with + // no cursor is a dead end the agent cannot act on. + assertContains( + past, + 'before: "h-1"', + "a page with more behind it must name the cursor for the next one", + ); + // What this is and is not. These posts were never addressed to this session + // and were never delivered to it; read as arrivals they would be answered. + assertContains( + past, + "Read it as context, not as something to answer.", + "the pull must say that what it returns is not addressed to the reader", + ); + // ── an unanswered post is unconfirmed, not delivered and not refused ─────── // The frame may well have landed. Reporting either verdict would be a guess // the agent then acts on: "delivered" lets it believe it spoke, "refused" From 653b0f858c4721a8e23ab233639c6c2e9055ac41 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Thu, 27 Aug 2026 22:25:36 +0900 Subject: [PATCH 4/6] feat(ui): make the left column a topic list and read a topic back into the room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左の列を「過去の発言を一本の流れで並べる帯」から「トピックの一覧」へ変える。求められていた形はこちらであり、#48 が埋めた粒度が外れていた。 - 一覧は新しい順。選ぶとそのトピックが部屋へ戻り、続きを話せる(決定2)。#48 の「過去ログを `#room` の本文へ復元しない」はこの issue で巻き戻る。 - `新規` が唯一の区切りである。起動も新しいトピックを開くが、二つは独立している(決定1、Master 判断5)。 - 起動時の現在トピックは索引に無い。一覧に無くても部屋のトピックとして描く——一件だけ「まだ書き留められていないトピック」が在るのが、毎回の起動直後の正しい状態である。 - トピック名はダブルクリックでその場で直す。Enter が決定、Escape とフォーカス喪失が取消(決定9)。 - 読み戻した行は部屋の行と同じ形で描き、透かしは消す。トピックへ入ると床は空になるため、前のトピックの `message_id` を申告しても位置 0 に落ちる。等価な値ではなく本当のことを言う。 - アカウントに `再開コマンド` の欄を追加し、起動オプションの `{session_id}` を説明した。 - 起動が resume だったかどうかを状況行で言う。復帰できなかった席も着席する以上、黙って同じ言葉で済ませない(決定6)。 #115 --- index.html | 71 ++++++-- src/main.ts | 472 +++++++++++++++++++++++++++++++++++++++---------- src/styles.css | 124 ++++++++----- 3 files changed, 510 insertions(+), 157 deletions(-) diff --git a/index.html b/index.html index 16a3ddc..44391f3 100644 --- a/index.html +++ b/index.html @@ -42,25 +42,32 @@
- -