Skip to content
Merged
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一
- 部屋ソケット(`127.0.0.1` の任意ポート、Bearer トークン必須)
- `.mcp.json` への登録と、channel が成立する条件を満たした CLI 起動
- メッセージ一覧・発言者表示・入力欄を備えたチャットルーム UI
- 部屋のログ(`logs/main.jsonl` への追記)と、それを会話面の左の列に並べる履歴
- 部屋のログ(`logs/main/{トピック}.jsonl` への追記と `logs/main/index.json` の索引)と、会話面の左の列に並ぶトピックの一覧
- トピックの再開。一覧から選ぶとそのトピックが部屋へ戻り、アカウントに `再開コマンド` があればそこに居た CLI セッションも戻ります
- 参加者が自分でトピックの過去発言を引く道具(サイドカーの `read_room_history`)
- Windows 上で `npm ci` と Rust のコンパイル確認を行う CI
- GitHub Release 公開時に Tauri バンドルを作成する CD

Expand All @@ -44,7 +46,8 @@ Pullcept は、人間と複数の独立した AI / Li+ セッションが、一
### 未実装

- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。経路は通っていますが、実機での往復は未確認です
- 部屋のログと履歴の実機確認。追記と読み出しは実装済みで CI は通っていますが、実機での操作は未確認です
- トピックの実機確認。区切り・再開・名前の変更・既存ログの移行はいずれも実装済みで CI は通っていますが、実機での操作は未確認です
- `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っていますが、エージェントが必要な場面で自分から引くかどうかは未計測です
- 複数の部屋。ログのパスは部屋名を位置として持ちますが、部屋そのものは一つです
- plugin としての allowlist 掲載(配布の第二段階)

Expand All @@ -58,14 +61,21 @@ npm run tauri dev

上の行は**参加するアカウントを選んで参加するだけ**の行です。アカウントを作る・直す・消すのは、右の「参加者」の見出しの隣にある「+」と、各行の「編集」から開くウィンドウで行います。

そのウィンドウには、名前・種別・色と、AI のアカウントなら作業ディレクトリ・起動オプションがあります。**決定を押すまで何も保存されません。** 「+」は空のフォームを開くだけで、取消せばアカウントは作られません。編集も同じで、取消せば元のままです。削除はそのウィンドウの中にあり、二度押しです。
そのウィンドウには、名前・種別・色と、AI のアカウントなら作業ディレクトリ・キャラクター・起動オプション・再開コマンドがあります。**決定を押すまで何も保存されません。** 「+」は空のフォームを開くだけで、取消せばアカウントは作られません。編集も同じで、取消せば元のままです。削除はそのウィンドウの中にあり、二度押しです。

「名前」と「色」は、そのアカウントが部屋で名乗る名前と、発言に付く色です。**いつでも変えられます。** 同一性は名前ではなく内部の id にあるため、改名しても稼働中のセッションの登録は追随します。色を選ばずに参加することもでき、その場合は名前から色が決まります。

「種別」は `user`(人間)か `AI`(このアプリが起動する CLI)です。**作るときに選びます。** あなた自身も種別 `user` のアカウントであり、はじめて起動したときに作られます(それまでタイトルバーで名乗っていた名前と色を引き継ぎます)。種別 `user` のアカウントは起動しません——人間の下に動かす CLI は無いためです。

「起動オプション」には `--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:pullcept-room-<slug>-<hash>`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行はその下に表示されます。

「起動オプション」と「再開コマンド」には `{session_id}` を書けます。**トピックの再開に使う組です。**

- 起動オプションに書くと、新しいセッションを始めるときにアプリが決めた UUID がそこへ入ります。`claude` なら `--session-id {session_id}` です。書いた場合だけ id が配られ、そのトピックに記録されます。
- 再開コマンドは、そのトピックに記録された id へ戻る**一行まるごと**です。先頭の語が起動する command になります。`claude` なら `claude --resume {session_id}` です。

どのフラグが id を運ぶかは CLI ごとに違うため、アプリはフラグを持たず、書かれた場所へ差し込みます。**どちらも空のままで構いません。** その場合そのアカウントは、開き直したトピックへ新しいセッションとして着席し、それまでに言われたことは `read_room_history` で自分から引きます。

作業ディレクトリは初回だけホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。

右の参加者パネルは**一つのリスト**です。部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が、種別ごとのグループに分かれて並びます(`user — 1` / `AI — 2` のように、件数も出ます)。名前はその参加者の色で出ます。居ない相手は色を保ったまま薄くなり、点が塗りから輪郭に変わります。自分の行には「(あなた)」が付きます。
Expand Down Expand Up @@ -100,6 +110,18 @@ npm run tauri dev

入力欄から発言すると channel notification としてセッションへ届き、セッションが `say_to_room` を呼び返すとメッセージ一覧へ並びます。往復が成立しないときの切り分け手順は [`docs/0-requirements.md`](docs/0-requirements.md) を参照してください。

### トピック

会話面の左は**トピックの一覧**です。トピックは会話の記録ではなく、会話が行われる器です。一覧から選ぶとそのトピックが部屋へ戻り、続きを話せます。

区切るのは「新規」だけです。アプリを起動したときも新しいトピックが開きますが、二つは独立しています——一回の起動で複数のトピックを持つことも、起動をまたいで一つのトピックを続けることもできます。何も話さずに閉じた回は一覧に残りません(最初の発言が着いた時点で記録されます)。

トピック名は最初の発言の冒頭から自動で付きます。行をダブルクリックすると、その場で直せます(Enter で決定、Escape で取消)。

トピックはアカウントごとに、そこに居た CLI セッションの id を持ちます。開き直して「開始」を押すと、そのアカウントに再開コマンドがあれば同じセッションが戻ります。**戻せない席があってもトピックは開きます。** 戻せなかった席は新しいセッションとして着席し、必要になったときに `read_room_history` で過去の発言を自分で引きます。部屋の側から過去を流し込むことはしません。

ログは `%APPDATA%\org.liplus-project.pullcept\logs\main\` の下に、トピックごとの `.jsonl` と `index.json` として置かれます。以前の `logs\main.jsonl` は、初回の読み出しで一つのトピックとして引き継がれます(移動であり、捨てません)。

### サイドカー単体の確認

```powershell
Expand Down Expand Up @@ -170,6 +192,7 @@ sidecar/ 部屋の MCP channel サーバ(Node)
src/ チャットルーム UI(TypeScript)
src-tauri/src/ Tauri、部屋ソケット、PTY、設定・セッション保存の Rust 実装
crates/mcp-config/ .mcp.json 登録と起動フラグ検査(tauri 非依存、テスト対象)
crates/room-floor/ 同時発話の順序付け(tauri 非依存、テスト対象)
portable-pty-patch/ Windows 対応を含む portable-pty のローカルパッチ
.github/workflows/ Windows CI とリリース用 CD
```
Expand Down
61 changes: 61 additions & 0 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid>` and `--resume <uuid>`; 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<String> {
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,
Expand Down Expand Up @@ -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}"
)));
}

}
33 changes: 33 additions & 0 deletions crates/room-floor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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();
Expand Down
Loading