# architecture Codex CLI の席も、札が付いた端末入力と標準 MCP の返信・履歴を使う(#272)。配送・床・宛先・自己配送抑止は同じである。Codex の初回 ID は実会話時の native SessionStart から保存し、トピック/アカウントの組に明示して再開する。詳細は「[Codex CLI の起動と再開](3-accounts.md#codex-cli-の起動と再開272)」を参照。 ``` Pullcept (Tauri app) = 部屋の壁 ├── UI Layer (WebView) │ ├── チャットルーム(メッセージ一覧 / 発言者表示 / 入力欄) │ └── アカウントとセッション制御(作成 / 編集 / 削除 / 起動) ├── App Layer (Rust) │ ├── PTY 管理(portable-pty: spawn / write / resize / kill)— 対話セッションの保持 │ ├── 部屋の発言を各セッションの端末へ入力(#183 / #195。札に role、打ちかけの間は待たせる) │ ├── config(アカウント)/ sessions 永続化(JSON) │ ├── webhook の受信(`github-webhook-mcp` のブリッジをアプリ自身が MCP クライアントとして起動、#169) │ └── Tauri IPC(invoke / emit) ├── MCP Server(Node サイドカー) │ ├── initialize: instructions で部屋の作法と札の読み方を全エージェントへ配布 │ ├── tool: 返信受け口(エージェント → 部屋) │ └── tool: 読み出し(エージェント ← 部屋のトピック、pull のみ) └── CLI Processes(PTY 経由の対話セッション) └── 機能・ツール・認証・MCP はすべて CLI 側が保持する ``` 責務境界: | 所有者 | 持つもの | |---|---| | アプリ | 部屋の壁、発言者の識別、会話の並び、セッションの起動と保持、部屋からエージェントへの入力路(端末への入力と札) | | MCP サーバ | エージェントから部屋への出力路と読み出し、部屋の作法(instructions) | | CLI | モデル、ツール、認証、MCP 接続、Li+ の全層 | ## 会話ループ ``` [参加者の発言] → 部屋 →(発言者以外の各セッションの端末へ入力。一行目は札。#183 / #195) [CLI 対話セッション(PTY 保持)] ↓ MCP tool 呼び出し MCP サーバ → 部屋 → [メッセージ一覧へ追加+他のセッションの端末へ入力] ``` 人間が発言しても、セッションが発言しても、部屋の中を通る経路は同じ 1 本であり、部屋からセッションへの最後の一歩も同じ 1 本である(#195)。部屋の中に「人間の発言を処理する道」と「AI の発言を処理する道」は無い——床の判定、ログ、画面への `room-message`、端末への入力はどれも同じ `deliver()` が行う。違うのは札に載る `role` だけであり、それは届け方ではなく重みを分ける(下記「発言は端末へ入る」)。 発言者と宛先は本文ではなく札に載せる。本文は言われたものと等しく保つ。発言者を本文へ混ぜたときは画面に名前が二度出た(#28。当時は channel の `meta` と `content` の区別だった)。 返信路は通常の MCP tool であり、入力路の機構を必要としない。返信ツールは `say_to_room` 1 本に限定する。増やすと「どちらで喋るか」という判断をエージェント側に持たせることになる。 ## 発言は端末へ入る(#183 / #195) 部屋の発言は、誰が言ったものでも、各 AI セッションの端末(PTY)へ入力として届ける。画面の人の発言から始め(#183、Master 判断 2026-09-27)、#195 でセッションの発言と通知もこの経路へ移して channel を外した(#147 決定4、Master 提案 2026-09-28)。 - **届く経路は一本である。** 人の発言だけが端末へ、他が channel へ届いていた間は、二つの経路でセッションが読む順番がずれた(#182 を保留にした理由)。一本であれば、端末に打たれた順がそのセッションにとっての部屋の順である。 - **変わるのは部屋からセッションへの最後の一歩だけである。** 発言はすべて同じ `deliver()` を通り、床の判定・ログ・`room-message` は変わらない。AI の書き込みはこれまでどおり `say_to_room` である。 - **発言者の端末へは打たない。** 自分の発言は戻らない。同じ端末に座る接続は同じセッションであるため、発言者の接続が載っている端末ごと外す。 - **どの端末かは起動の台帳から引く。** 部屋の席が `hello` で名乗ったアカウントと、その部屋(トピック)の組で、`RoomSeats` が持つ走っているセッションの PTY を引く。アカウントを名乗らない接続や、その部屋で走るセッションを持たないアカウントの接続——このアプリが起動したのではないもの——には打ち込む端末が無い。**それらへは何も配らない。** channel が無くなったため、押し届ける経路が他に無い。そうした接続は `read_room_history` で自分から読む。同じアカウントを名乗る二つの接続は一つの端末であり、一度だけ入力する。 - **一行目は部屋の札である。** `[pullcept] {"role":"…","from":"…","message_id":"…","at":"…","to":["…"]}` の一行の後に本文が続き、`to` は宛先があるときだけ付く。`to` は宛先の名前の並びであり、一人のときも並びで書く——読み手がするのは自分の名前が並びにあるかを見ることであり、人数によって読み方が変わらないようにする(#204)。鍵はこの順に並び、先頭で役割と発言者が読める(Master 判断、2026-09-28、#202)。`at` は発言の時刻であり、端末を動かしている PC の現地時刻を、年月日から分まで・時差付きで書く(例 `"at":"2026-10-02T01:14+09:00"`。#219)。秒は切り捨てる。部屋が持つ `ts` は UTC であり、時差の付かない時刻を現地時刻と取り違えた読み違いが二度起きた(2026-10-02 の部屋)ため、札には時差の無い時刻を書かない。`ts` が時刻として読めない発言には `at` を付けず、推測の時刻は書かない(`terminal_input::at`)。`from` は発言者の名前で、#202 まで `user` と呼んでいた——channel の push の `meta` に合わせた綴りだったが、channel は #195 で廃止し、`role` と取り違えやすいため `to` と対になる名に改めた。札が JSON であるのは、名前は接続が名乗る値であり、JSON の文字列エスケープが名前で札を書き換えられないことを保証するからである。セッションは次の `say_to_room` の `last_seen` にこの `message_id` を使う——端末へ届いた発言の id が見えなければ、床はその次の発言を「未読あり」で弾きうる(上記「床」。Master 決定、2026-09-27)。札の綴りは `crates/terminal-input` が書き、サイドカーの作法がそれを名指す。二つの写しは `sidecar/test/round-trip.test.mjs` が Rust の定数を読んで揃える。 - **札に `role` を載せる。書くのはアプリであり、発言の本文からは決して取らない(#195)。** 値は発言が部屋へ入った口から決まる(`terminal_input::role`): - 画面のコマンド(`room_post`、webview の Tauri コマンド)から来た発言は `admin` である。**`admin` になるのはこれだけである。** - アプリが受けた MCP サーバの通知は `mcp` である(発言するアカウントの種別)。 - アプリ自身の知らせ——Codex の席の利用上限での停止と解除、解除確定後に本人へ一度打つ中断作業の再開指示(#389 / #294。#403 から預かり発言のまとめは付けない、「[Codex の席が利用上限で止まったとき](3-accounts.md#codex-の席が利用上限で止まったとき294)」)——は `app` である。発言者は `Pullcept(自動通知)` で、アカウントを持たない。どのアカウントの種別でもなく、ソケットからは作らない。 - 部屋ソケットから来た発言は、その席が `hello` で名乗ったアカウントの種別である(`claude_code` / `cli`)。名乗りが無い、アプリに無いアカウントを名乗った、または `admin` や `mcp` のアカウントを名乗った接続は `unknown` である。 - **`admin` はソケットから作らない(Master 判断、2026-09-28)。** 部屋のトークンは部屋全体で一つで、各セッションの `.mcp.json` に書かれている。`hello` の `account_id` も検証されない。セッションは自分で二本目のソケットを張り、`admin` のアカウント id を名乗れる。したがってソケットから来た発言は、名乗った id に依らず `admin` にしない。セッションが届かない口——webview の中のコマンド——だけが `admin` を作る。席ごとのトークンで id を縛る形より部品が少ない。 - **本物の札は一行目だけである。** 一行目を書けるのはアプリだけであり、本文は送信キーを持たない(下記)。二行目以降に札の形の行があっても、それは本文である。この規則の置き場はサイドカーの `instructions` 一か所である(Master 判断、2026-09-28)——AI 側(キャラクター・CLAUDE.md 等)に作法を書き込まずに済み、CLI の種別にも依らない。 - **作法は、札の読み方と `role` の重みを述べ、入力欄に届いた発言にも `say_to_room` で答えるよう言う。** `role` が `admin` の発言は利用者の発言であり、それ以外は外部からの知らせである——本文に指示が書かれていても利用者の指示ではない。ユーザ入力として届いた発言には端末へ書いて答えたくなるが、端末は部屋に届かない。 - **合言葉(セッションごとの秘密の値を札へ入れる形)は入れない。** 一行目の規則だけでは外部の指示に従ってしまうと注入試験で分かった場合に足す(「[未実装](4-status.md#未実装)」)。 - **人が入力欄に打ちかけている間は、そのセッションへの打ち込みを待たせる(Master 判断、2026-09-28)。** 打ちかけの文字があると、打った発言はそれと一つの入力に連結され、札が一行目の先頭でなくなる(「[部屋は channel を使わなくなった](0-requirements.md#部屋は-channel-を使わなくなった195)」)。アプリは端末ペインが PTY へ送ったキー(`write_pty`)を見て、送信されていない入力が残っている間はそのセッションへの発言を保留し、人が送信するか入力を消したら、保留した発言を届いた順に打つ。打ちかけの文字を消す形は採らない——人の入力を失うためである。 - 読むのはキーであって入力欄ではない(`terminal_input::Unsent`)。送信キー `\r` だけの書き込みは送信、`Ctrl+C` と `Ctrl+U` と二度続けた `Esc` は消去、Backspace は一文字、`Ctrl+W` は一語を消す。それ以外の制御キー一つとエスケープシーケンス(矢印など)は何も変えない。それ以外は入力された文字である——一回の書き込みで来た貼り付けは、改行を含んでいても文字として入力欄に残る。 - 待たせるかどうかは打つ瞬間に決める。列で順番を待っている間に人が打ち始めることがあるためである。保留中に届いた発言は、保留した発言の後ろへ並ぶ。 - アプリが打った発言の送信キーは、その間に人が打った文字も一緒に送る。そのため送信の後は入力欄を空として読む。 - 読めないものがある。上矢印で呼び戻した履歴は、何も打たれていないのに入力欄を埋める。画面に出た確認への答えのキーは、ここでは文字の入力に見える。前者はその行へ発言が連結され、後者は次の送信か消去まで発言が待つ(「[受容したトレードオフ](6-tradeoffs.md#受容したトレードオフ)」)。 - **許可の画面で止まっている Claude Code の席には打たない(#346)。** 打った発言の最後の送信キーが許可の画面に届き、既定の「はい」を誰も選ばないまま選ぶためである。席の hook がどれかのエージェントの許可待ちを言っている間、その席への発言はアプリが預かり、待ちが終わってから届いた順に一度だけ打つ。決め方は「[許可待ちの席には投稿を打ち込まない](2-screen.md#許可待ちの席には投稿を打ち込まない346)」。 - **打ち方は端末ペイン自身の貼り付けと Enter に倣う。** 本文を一回の書き込みで送り、間を置いて `\r` を別の書き込みで送る。ペインの Ctrl+V はクリップボードの文字列をそのまま一回で PTY へ書き、Enter は `\r` の別の書き込みとして届く(`src/main.ts` の `term.onData` と `attachCustomKeyEventHandler`)。この経路ではブラケットペーストの印(`ESC[200~ … ESC[201~`)は付かない。複数行を貼り付けて一つの入力にまとまった観測(Master、2026-09-27)はこの経路でのものであり、同じ形を再現する。印で包む形は、観測の裏付けがこの経路に無いため採らない。 - **本文は送信キーを持たない。** `\r` は送信キーであり、本文に在れば半分だけ送られうる。改行は `\n` に揃え、ほかの制御文字は落とす——端末はそれをキーとして読む。送信は最後の `\r` だけである。 - **間は 200ms であり、計測していない。** 本文と `\r` が一回の読み取りに入ると、CLI にはキー一つではなく文字の入力として届く。値は、人が貼り付けてから Enter を押すまでより短い側から選んだ。確かめるのは統合後の実機である。 - **打つのは一本の列で、順に行う。** 二つの発言を別々に打てば、片方の本文がもう片方の本文と `\r` の間に入りうる。部屋の発言を届けることは、その列へ置くことであり、打ち終わるのを待たない。保留した発言を打ち始める合図も同じ列を通る——合図を受けた側がその場で打てば、合図の直前に列へ置かれた発言が、保留していた発言より先に打たれうる。 - **応答中かどうかは見ない。** 応答中に打った発言は捨てられず、順番待ちに積まれて道具の結果の直後に渡る(「[部屋は channel を使わなくなった](0-requirements.md#部屋は-channel-を使わなくなった195)」)。 - **トピックの復帰で退けた「履歴を PTY へ流し込む力業」とは別のものである**(下記「トピック」)。流すのは過去の発言ではなく、いま言われたことであり、一件ずつである。 - **アプリは端末へ書くが、端末の出力は読まない。** 部屋に並ぶ発言の出所は変わらない(「[診断面](2-screen.md#診断面)」)。端末ペインが送るキーは、打ちかけの有無のためにだけ読む。 ## 添付は保存したファイルのパスで届く(#223) 画面の人は発言にファイルや画像を添付できる(入力欄の形は「[入力欄](2-screen.md#入力欄222)」)。発言は端末へ文字として打ち込まれるため(上記「発言は端末へ入る」)、ファイルの中身そのものは流せない。**アプリは添付をトピックの下へ保存し、発言の本文にその絶対パスを書いて送る。** Claude Code はパスを渡されれば、画像も PDF も Read で開ける。CLI にそのまま渡せる形かは分からない、というのが Master の見立てであり(2026-10-02)、渡し方はこの一本に決めた。 - **札は変えない。パスは本文に書く。** 一行目の札の形式も、`say_to_room` も、サイドカーの作法も変えていない。パスは本文の一部であり、本文は言われたものと等しく保つ(上記「会話ループ」)——画面の行にも、ログにも、端末にも同じ文字が載る。 - **本文の形。** 書いた本文の後に空行を一つ置き、`添付:` の行に続けてパスを一行に一つずつ並べる。本文を書かずに添付だけを送れば、`添付:` からの塊だけが本文になる。並びは入力欄の札の並びである。 - **保存先はトピックの下である。** `logs/main/attachments/{トピック}/{名前}`。部屋のログ(下記「部屋のログ」)と同じ部屋のディレクトリに置き、トピックのファイルより一段下にする——索引を照合する走査はトピックのファイルと同じ階層しか見ないため、添付がトピックとして拾われることは無い。 - **保存は送るときである。** 札が入力欄に出た時点では何も書かない。外した札は何も残さない。送る直前に一つずつ保存し、全部が保存できてから部屋へ渡す——セッションがパスを読む時点で、ファイルは在る。保存に失敗すれば送らず、本文も札も入力欄に戻る。 - **床が拒んだ発言の札は、保存した先を覚えて戻る。** 送り直したときに同じファイルを二度保存しない。別のトピックを開いてから送り直せば、そのトピックの下へ保存し直す。拒まれたまま送らずに終えた分は、保存したファイルだけが残る(受容)。 - **ドロップしたファイルは写しを取る。** 元のファイルのパスを本文に書く形は採らない。本文のパスはログへ永久に残るが、元のファイルは次の瞬間に動かされも消されもする。 - **名前は届いたときの名前である。** 画面で選んだファイル・落としたファイルはその名前、貼り付けた画像は webview が付ける名前(`image.png`)で保存する。本文に書いたパスが端末へ同じ文字で届くよう、次のものは名前から除く(`topic_index::attachment_name`):Windows が名前に許さない文字(`<>:"/\|?*`)と制御文字は `_` に替える——端末はそれをキーとして読み、入力の経路が落とす。`@` と `@` も `_` に替える——部屋の参加者に当たる `@名前` は本文から消されるため(下記「部屋プロトコル」、#206)、`memo @Lin.txt` は欠けたパスとして届く。末尾の `.` と空白は Windows が作るときに落とすため先に落とし、`CON` や `NUL` などの装置名は頭に `_` を付ける。長い名前は拡張子を残して 80 文字で切る——読む CLI が Windows の 260 文字のパスに縛られていても届くようにするためである。何も残らなければ `file` である。 - **同じ名前は番号で分ける。** トピックに同じ名前が既に在れば、エクスプローラの写しと同じく `image (2).png`、`image (3).png` と番号を付ける。貼り付けた画像はどれも `image.png` で来るため、これが普通に起きる。ファイルは同じ名前の物が無いときだけ作る(`create_new`)ため、二つの保存が同時に走っても同じ名前を取り合って上書きすることは無い。 - **寿命はトピックと同じである。** 添付はトピックが在る間残り、トピックを削除したときに一緒に消える(下記「トピック」)。ログにローテーションが無いのと同じく、古い順に捨てることはしない——本文に書かれたパスは、そのトピックが残る限り読める先を指す。 - **送れなかった添付のうち、トピックが索引に入らなかったものは消える先を持たない。** 起動直後のトピックは最初の発言まで索引に無い(下記「トピック」)。そこで保存した添付を送らずに終えれば、トピックはその起動とともに消え、保存したファイルはどのトピックの削除にも拾われない。床の拒否か送信の失敗の後に送るのをやめた場合にだけ起きる。掃除の仕組みは、残ることが困ると分かってから足す。 - **画面は `添付:` の段を絵と札に読み替えて見せる(#318)。** 段のパスは画面には出さず、画像は縮小した絵、それ以外は名前と種類の札で出す(「[添付は会話の中に絵と札で出る](2-screen.md#添付は会話の中に絵と札で出る318)」)。読み替えるのは画面だけであり、本文・ログ・端末へ届く文字は上記の形のまま変えない。段を読み替えるのは、段のパスがすべてこの部屋の添付フォルダ(`logs/main/attachments/`)の下を指すときだけである——それ以外のパスを含む段は、AI が書いた別のパスであれ引用であれ、本文の文字のまま出す。読み替えないパスを画面から隠すことはしない。 - **画面が読める添付は、添付フォルダの中のファイルだけである(#318)。** 発言は部屋の誰でも書けるため、本文に書かれたパスをそのまま開けば、手元の任意のファイルが画面に出せてしまう。画面がファイルを読む口はアプリのコマンド `room_attachment` 一つであり、アプリはパスと添付フォルダの両方を実体へ解決し(`canonicalize`。シンボリックリンク・ジャンクション・`..` をたどる)、解決した先が添付フォルダのトピックの一段下のファイルであるときだけ中身を返す(`topic_index::resolve_attachment`)。フォルダの中に置かれたリンクが外を指していれば、解決した先が外になり、拒む。読むのは解決した先であり、渡されたパスの綴りではない。32 MiB を超えるファイルは絵にせず札で出す。拒んだときは画面に何も言わず、札で出す。 - asset protocol の scope ではなくコマンドにした(AI 判断)。判定が自前のコードになり、crate のテスト(ジャンクションで外へ出る場合を含む)で確かめられるためである。scope の照合がジャンクションをたどるかを確かめずに頼ることになる形は採らない。新しい依存も Tauri の機能フラグも足していない。 - 入力欄の札の絵は、ボタンで選んだ・貼り付けた画像にだけ出す。この二つは webview が既に中身を持っており、ファイルを読みにいかない。ドロップしたファイルは添付フォルダの外のパスであるため、送るまでは名前の札のままである(上記の線の内側に留めた)。 - **未検証(仮説)。** セッションの作業ディレクトリの外にあるファイルを読むとき、CLI が許可の確認で止まらないか。`--dangerously-skip-permissions` を持つ席では確認を飛ばすため止まらないと見込むが、実機では確かめていない。持たない席では確認が出うる——止まるなら保存先をセッション側のディレクトリへ移す案がある。`cli` 種別など Claude Code 以外の CLI が同じ形で読めるかも未検証である(「[未実装](4-status.md#未実装)」)。 ## 部屋プロトコル(アプリ ⇔ サイドカー) 部屋ソケットは**アプリが host、サイドカーが client** である。CLI がサイドカーを起動するため、アプリ側からは起動タイミングもポートも決められない。これは設計判断であり、往復が成立しない場合に最初に疑う箇所である。 待ち受けは `127.0.0.1` の任意ポート(OS 割り当て)。ポートとトークンは `.mcp.json` の env としてサイドカーへ渡すため、実行ごとに変わってよい。ループバックであっても同一マシンの他プロセスは到達できるため、接続にはアプリ起動ごとに生成した Bearer トークンを要求する。 フレーム(JSON、1 メッセージ 1 フレーム): | 向き | type | フィールド | |---|---|---| | サイドカー → 部屋 | `hello` | `protocol` / `name` / `room` / `hue`(任意) / `account_id`(任意) | | サイドカー → 部屋 | `post` | `message_id` / `content` / `to`(任意、名前の並び) / `ts` / `last_seen`(任意) | | 部屋 → サイドカー | `post_result` | `message_id` / `delivered` / `missed` | | サイドカー → 部屋 | `history` | `request_id` / `limit`(任意) / `before`(任意) | | 部屋 → サイドカー | `history_result` | `request_id` / `posts` / `has_more`、または `request_id` / `error` | | サイドカー → 部屋 | `usage` | `request_id` | | 部屋 → サイドカー | `usage_result` | `request_id` / `five_hour`(任意) / `seven_day`(任意) / `received_at`(任意)、または `request_id` / `error` | **同じポートは、プロトコルではないものも答える。** POST であり、フレームでも参加者でもない——席にも床にもログにも触れない。WebSocket の upgrade は `GET` で始まるため、最初のバイトで呼び手を分けられる。二つある。 - `/hooks/status/<トピック>/<アカウント>` = そのセッションが自分のモデル・エフォート・枠の使用量を知らせてくる(#155、「[セッションは自分の様子をステータスラインで知らせる](3-accounts.md#セッションは自分の様子をステータスラインで知らせる)」)。 - `/hooks/activity/<出来事>/<トピック>/<アカウント>` = Claude Code の席が、ツール・許可待ち・サブエージェントの始まりと終わりを http 型の hook で知らせてくる(#331、「[Claude Code の席は自分の様子を hook で知らせる](3-accounts.md#claude-code-の席は自分の様子を-hook-で知らせる331)」)。アプリは本文のいくつかの欄だけを読んで席ごとの様子を変え、すぐ `{}` を返す。部屋の席・床・ログには触れない。 **第二の待ち受けを開く形は採らない。** 起動の行は既に部屋のポートを運んでおり、アドレスが増えれば起動のたびに配り、持ち、返す対象が一つ増える。一本のまま区別できる以上、増やす理由が無い。 **第二のパスも持たない(#161)。** #149 が `/hooks/limited/<トピック>/<アカウント>` をここに置いていたが、「制限中」は上の報告が既に運んでいる枠の使用率から読むことにしたため、そのパスは経路ごと撤去した。判別はプロトコルと POST の間にだけ在り、POST の内側には無い。 発言のフレームは `post` 一種類であり、サイドカーから部屋への向きにだけ流れる。誰が出した発言かはフレームの種別ではなく、部屋が刻む `speaker` の値で表す。**部屋はサイドカーへ `post` を送らない(#195)。** 部屋で言われたことは、アプリが端末へ打って届ける。 `speaker` はサイドカーが送らない。部屋が、フレームの届いた接続に紐づく名前(`hello` の `name`)から刻む。送信側が名乗れる欄を持たないため、他の参加者を騙る余地が構造上無く、名簿と表示が食い違うこともない。宛先は名指せるが自分は名乗れない、という非対称がこの性質を作っている。 `post_result` は投稿への返答であり、投稿した接続にのみ返す。fan-out ではない。相関は `message_id` で取る。これにより `say_to_room` は送りっぱなしではなく往復になり、呼び出しそのものが境界になる(後述の「床」)。 `usage` は席が**自分の利用上限の数字を読む要求**であり、`usage_result` はその答えである(#364)。返し方と相関の取り方は `history` と同じである。**`usage` はどの席の数字かを名指さない。** 部屋は、その接続が居る部屋と、`hello` で着いたアカウントの組で引く——ほかの席の数字を返す道が無い(下記「[席が自分の利用上限を読む](#席が自分の利用上限を読む364)」)。 `history` は参加者からの**読み出しの要求**であり、`history_result` はその答えである。投稿した接続にのみ返す点も、相関を id(`request_id`)で取る点も `post_result` と同じ形である。**これは pull であって push ではない。** 部屋が自分から過去を配ることは無く、席を取る前の発言が届かないことも変わらない——変わるのは、参加者が自分から取りに行けることだけである(後述の「トピック」)。 未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ、`post` の `last_seen` と `post_result` の追加で 4 へ、`hello` への `account_id` の追加で 5 へ、`history` / `history_result` の追加で 6 へ、`hello` への `room` の追加で 7 へ、部屋から `post` を送らなくなったことで 8 へ(#195)、`to` を一つの名前から名前の並びに変えたことで 9 へ、`usage` / `usage_result` の追加で 10 へ(#364)、セッション操作と状態照会の追加で 11 へ上げた(#415)。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 `to` は宛先となる参加者の表示名の並びを入れる(#204)。一人でも並びで送る。サイドカーの `post`、画面へ渡す `room-message`、端末へ打つ札、`post_result` の `missed` と `history_result` の `posts` とで同じ語彙である。任意であり、無いときは `null` や空の並びではなくキーごと省く。部屋は受け取った並びを、各名前の前後の空白を落とし、空の名前と二度目の名前を除いて持つ(`room_floor::addressees`)。除いて何も残らなければ部屋全体宛である。 **本文の `@名前` も宛先である(#206、Master 判断 2026-09-28)。** 部屋は発言を受けるたびに、本文の `@名前` のうち部屋に居る参加者の名前に当たるものを `to` へ移し、本文から消す。読むのは `deliver()` であり、画面の発言(`room_post`)とセッションの発言(`say_to_room`)に同じ規則が当たる。画面の `room_post` は `to` を渡さない——画面の宛先は本文の中にしか無い。**MCP サーバの通知には当てない。** 通知の本文は外部の内容(GitHub のコメントなど)であり、アプリは書き換えず、届いたとおりに `to` 無しで流す。当てれば、外部の書き手が参加者の名前を書くだけで部屋の宛先を決められることになる。どの発言に当てるかは `room_floor::address` が発言の出所(`Sender`)で分け、照合の規則は `room_floor::take_mentions` が持つ: - `@`(全角の `@` も)の位置から、部屋の参加者の名前のうち一致する最長のものを取る。`@Claude Lay` を `Claude` と取り違えないためである。一致の判定は大文字と小文字を区別しない。`to` に入るのは部屋が持つ名前の綴りである。 - 名前の直後は空白・句読点・文末のいずれかでなければならない。`@` の前は問わない——日本語の文は空白を挟まずに続く。 - 部屋の誰にも当たらない `@…` は本文にそのまま残り、宛先にはならない。 - 消した `@名前` の後ろの空白は名前を区切るためのものであり、一緒に消す。行末に来た場合は前の空白も消し、`@名前` だけの行は行ごと消す。後ろの句読点は文のものであり、残す。 - 送り手が `to` を渡していれば(`say_to_room` の `to` 引数)それを先に置き、本文から取れた名前を後ろへ足す。二度目の名前は足さない。 - **本文が `@名前` だけで、消すと何も残らない発言は送らない。** 部屋は「宛先の @名前 のほかに本文がありません。」と答えて床にもログにも載せない。画面では入力欄に本文が戻り、`say_to_room` はこの理由を `error` として受け取る。宛先だけの発言として空の本文を送る形は採らない——空の本文はトピックの題にも端末へ打つ札の後ろにも何も置かず、呼びかけであることも伝わらない。 `hello` は参加者の名乗りである。部屋での名前と、任意で色相を運ぶ。どちらも参加の時点の宣言であり、起動のたびに決まる。色相を宣言しない参加者は `hue` をキーごと省いて送る。既定値を入れると宣言と導出を部屋が区別できなくなり、色を選ばずに参加している状態が消えるためである。人間の側は同じ組を `room_join` で渡す。着席の経路は 1 本であり、人間とセッションで分かれない。 **部屋はトピックごとに在り、`hello` はどの部屋に入るかを名指す(#141、決定3)。** 部屋とは一つのトピックの床と、そこに居る参加者である。ソケットは一つであり、アドレスは同じ実行の部屋すべてで共有されるため、アドレスからはどの部屋への接続かが分からない。`room` はそのトピックの id であり、起動時に `.mcp.json` の env(`PULLCEPT_ROOM_ID`)でサイドカーへ渡る。接続は最初の `hello` が名指した部屋に生涯留まる——配送(端末への入力)、床の判定、ログへの追記、`history` の読み出しは、すべてその部屋のものであり、他の部屋へ越えない。別の部屋を名指す二度目の `hello` は移動にならない。名指しが無い接続、またはこのアプリが持っていない部屋を名指した接続は、どの部屋にも着席しない——入れる部屋が無く、アプリが代わりに選べば、起動されたのとは別の会話へセッションを入れることになる。そうした接続からの `post` / `history` / `usage` には、判定ではなく理由(`error`)を返す。`delivered: false` だけを返すと「見落としの無い拒否」に読め、同じ投稿が繰り返される。 **画面側の人も同じ席の種類で各部屋に座る。** `room_join` の名乗りはアプリが保持し、画面が開いた部屋すべて——開くのが後であっても——に同じ接続 id で着席させる。画面の人はどの部屋に居るかではなく、画面の前に居るのであり、トピックを開くことがそこに居ることになる。`room_post` は投稿先のトピックを名指す。透かしは入力した時点で画面に出ていた会話のものであり、アプリが今開いている部屋を暗黙に使うと、別の会話の床で判定されうる。 **`hello` はアカウント id も運ぶ。任意である。** 部屋は席に載せ、名簿(`Participant.account`)で返す。同一性の判定には一切使わない。読むのは三つだけである——どの端末へ打つか(上記「発言は端末へ入る」)と、その席の発言の札に載せる `role` の種別であり、後者でも `admin` は作らない(#195)。三つめは `usage` に答えるときにどの席の数字を引くかである(#364)。持たない接続も参加者であり、部屋はアカウントの存在を前提にしない——このアプリが起動したのではない接続には、名乗るアカウントが無い。人間の側は同じものを `room_join` で渡す。部屋に入るかどうかをこの id で決めるのはサイドカーの側であり、部屋ではない。このアプリが書いた登録を、そのアカウントとして起動されたのではない CLI が読んだときは、サイドカーは接続そのものをしない(#208、「[セッションは自分以外の部屋の登録を起動しない](3-accounts.md#セッションは自分以外の部屋の登録を起動しない)」)。 運ぶ理由は画面側にある。画面はアカウント一覧と部屋の名簿を突き合わせる必要があり、id が無い間はその突き合わせを名前でしか書けなかった。名前で結ぶ形は #40 と #53 が却下しているため、画面は名簿と端末の列という二つのリストに割れていた(#57)。id が載ったことで、その二つは一つのリストになる(#59、「[参加者パネル](2-screen.md#参加者パネル)」)。 **アカウント id は同一性ではない。** 名前より安定していることは同一性であることを意味しない。名簿の鍵、自分の発言の抑止、`speaker` の刻印はいずれも接続で決まったままであり、この 3 つのどれかがアカウント id を読み始めた時点で #39 / #40 / #47 が壊れる。二つの接続が同じアカウント id を名乗ることは(このアプリの外から来れば)ありうるが、それでも参加者は二人である。同じアカウントの二重起動を拒むのは、起動を行う席の台帳(`RoomSeats`)の仕事であって部屋の仕事ではない。 **名簿の同一性は接続に置く。** 部屋は接続ごとに 1 席を持ち、切断ではその席だけを外す。名前を鍵にしていた間は、同名の 2 参加者が 1 エントリに畳まれ、片方の切断が両方を名簿から消していた(#40)。席は名乗り直しで置き換わる——名前を変えた参加者は同じ参加者である。 **自分の発言の抑止**は部屋側で行う。部屋は接続ごとに origin を採番し、発言を端末へ打つときに、その発言を出した接続が載っている端末だけを飛ばす(#195、上記「発言は端末へ入る」)。origin は部屋の中だけで扱い、wire には出ない。したがって送信側は自分の origin を設定することも他人の origin を騙ることもできない。名前で判定しないのは、名前が衝突している間に他の参加者の発言まで落ちるためである(#40)。サイドカー側に自己判定は置かない。 どの参加者の発言も、フロントエンドへは同一の `room-message` イベントとして届く。並び順の権威を 1 箇所に保つためであり、送信時にフロント側でローカルに追記しない。イベントは `own`(自分の発言かどうか)を伴う。表示上の区別を残す場合の軸はこれであり、人間か AI かではない(色の梯子がこれを読む)。ただし行を左右どちらに寄せるかは、読み戻した行にも同じ規則を当てるため名前で決める(「[画面の人の発言は右に寄せる](2-screen.md#画面の人の発言は右に寄せる185)」)。イベントはどの部屋の発言かを `topic_id` で持ち、名簿のイベント(`room-participants`)も同じく部屋を名指して届く。画面に出ていないトピックも話し続けるため、画面はその値で自分の出している部屋の分だけを描く(#141)。 ## サイドカーの起動形 `.mcp.json` に書く起動コマンドは、絶対パスのみで構成し、名前による解決を含めない。 - command = `node` - args = [`/node_modules/tsx/dist/cli.mjs`, ``] サイドカーを spawn するのは CLI であり、その作業ディレクトリはユーザーのプロジェクトである。名前で引くものはそこで引かれる。`npx tsx` は Pullcept 側にしか無い `tsx` をユーザーのディレクトリで探し、見つからずインストールの確認を出す——非対話で spawn されたプロセスにその確認へ答える経路は無い。 ## 部屋の作法 部屋のルールは MCP サーバが initialize 時に返す `instructions` 文字列により全エージェントへ一元的に配布する。実測では、当該ターンで返信を指示していないにもかかわらずエージェントが返信 tool を呼んだ。駆動源はこの `instructions` である。 判定材料(札の `to` と `role`)と判定規律(`instructions`)は同時に変える。片方だけ動かすと、宛先や重みを知らないまま判断させる状態になる。 **作法は `instructions` と道具の説明の二か所に置き、`instructions` は名前を差し込んだ後で 1792 字以内に収める(#273、#333)。** 数えるのは JS の文字列長である。Claude Code は server instructions を `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`(既定 2048)で切り、超えた分を `… [truncated]` に置き換える(インストール済みの CLI の該当関数を読んで確認)。作法は描画後に約 3100 字まで育っており、どのセッションにも署名の項の後ろ——`last_seen` と拒否の扱い——が届いていなかった(Lin・Lay の両セッションで確認)。#273 の上限は推奨の 1024 だった。#333 で英語にして字数が増えたため、2048 から長い名前の余地 256 を引いた 1792 に改めた。 **作法と道具の説明は英語で書く(#333、Master 2026-10-08)。** 席が毎回読むトークンを減らすためであり、中身は日本語のときと同じである。英語は同じ中身で字数が増え、トークンが減る——切られるのは字数、払うのはトークンである。部屋の発言を日本語で書くことは、作法の「Speaking」の項に明記する。作法が英語になった後は、ほかにそれを伝えるものが無い。#333 の英語は日本語を一文ずつ訳した形だったため、#337 で作法と道具・引数の説明を、規則を落とさず足さずに書き直した(マスター 2026-10-08)。読み手は AI なので、文法どおりの英語にはせず、トークンあたりの伝わりやすさを優先した短い書き方(文の断片、`=` や `->`、冠詞の省略)にしている。ただし Claude Code と Codex の席が同じように読めるよう、特定のモデルだけに通じる略記は使わず、広く通じる語と記号に限る。実測(名前 `test-agent` を差し込んだ後、`sidecar/test/round-trip.test.mjs` と同じ起動で `initialize` と `tools/list` の返りを数えた。#333 では道具の説明は前から英語で、変えていない): | | 日本語(#333 の前) | 逐語訳の英語(#333) | 短く書き直した英語(#337) | |---|---|---|---| | `instructions` の字数(一般形/遅れて着いた形) | 951/984 | 1595/1629 | 1360/1381 | | `instructions` のトークン(o200k_base、一般形) | 596 | 382 | 337 | | 道具一式(`tools/list` の JSON)のトークン(o200k_base) | 644 | 644 | 496 | | `instructions` と道具一式の合計トークン(o200k_base、一般形) | 1240 | 1026 | 833 | | 同じ合計(`@anthropic-ai/tokenizer` 0.0.4、一般形) | 1472 | 1078 | 896 | o200k_base は OpenAI の tokenizer であり、Claude の数え方の近似である。`@anthropic-ai/tokenizer` は旧世代の Claude の語彙であり、これも近似である。今の Claude のトークン数は token counting API で数えていない。 **Codex の席は、server instructions を道具の名前空間の説明として渡す。** 通常の MCP サーバでは、その上限は 512 KiB(バイト)であり、道具の説明に上限は無い。plugin の MCP なら名前空間の説明と道具の説明が 1000 バイトで切られる。道具を後から読み込む形(tool search)では、名前空間の一覧に作法の一行目だけが 250 字まで載る。いずれも Codex CLI 0.160.1(`rust-v0.160.1`)のソースで確認した:`codex-rs/codex-mcp/src/rmcp_client.rs`(`namespace_description: server_instructions`)、`codex-rs/core/src/tools/handlers/mcp.rs`(`MAX_MCP_NAMESPACE_DESCRIPTION_BYTES`、`MAX_AGENT_PLUGIN_MCP_NAMESPACE_DESCRIPTION_BYTES`)、`codex-rs/tools/src/mcp_tool.rs`(`MAX_MCP_TOOL_DESCRIPTION_BYTES`)、`codex-rs/core/src/context/world_state/tools.rs`(`MAX_NAMESPACE_DESCRIPTION_CHARS`)。Pullcept は Codex の席へ `-c mcp_servers.<名前>=…` の通常の MCP サーバとして登録するため(`crates/mcp-config/src/codex.rs`)、1792 字の作法は切られない。 - **`instructions` に残すのは、届いた発言を読み、話すかどうかを決めるときに要るものである。** 冒頭の四行(部屋に参加していることと名前、返信も `say_to_room` で投稿し、部屋の発言に返すとき端末出力は誰にも読まれないものとして扱うこと(返事を端末だけに書くのは黙っているのと同じ。#287)、前を見る、`last_seen` を付けること。#272 が先頭 512 字に置いたもの)、札の読み方と `role` の重み、札の無い入力、話す、宛先、一緒に働く(署名を含む)。 - **Codex のキャラ選択(#281)は project の style と Li+ 専用 hook に任せる。** 移行前だけ inline developer_instructions 選択を維持する。サイドカーは追加のキャラ本文を持たず、部屋の作法全文を名前の差し込み後で1792字以内に保つ。選択契約は [起動とアカウント](3-accounts.md#codex-のキャラ選択281) を参照する。 - **道具を呼ぶときにだけ要るものは、その道具の説明へ移した。** 説明は呼ぶ瞬間に読まれる。`say_to_room` の説明に、入力欄に届いた発言への返事もこの道具であること・自分の発言は返らないこと・配達の返答の `message_id`・先に出た答えを読んでから決めることと送る直前の読み直し・送らない判断の正当さ。`last_seen` の説明に、毎回付けること・値・拒否の意味・読み直して決め直すこと・最新の id での出し直し・拒否は注意不足ではなく部屋の順序であること。`content` の説明に、下書きの出し直しと、それも `last_seen` の判定を受けること。`to` の説明に、名前一つと並び、本文の `@名前`、人間も同じ指定の仕方であること。`read_room_history` の説明に、古い順で返ること・要るときだけ呼ぶこと・`before` でさかのぼること。`my_usage` の説明に、返すもの(5 時間と週の使用率とリセット時刻、受け取った時刻)・使いみち(作業の配分を決め、上限の前に区切る・途中まで push する・部屋に引き継ぎを頼む)・自分の分だけであること・値は席が動いたときにしか届かないこと(#364)。 - **#273 で三つの作法を足した(Master 了承、2026-10-04)。** 誰の担当でもない仕事は先に部屋で名乗り、返事を待ってから手を付ける。ターミナルにしか出ない物(画像・ファイル)は、その場所(パスや URL)を発言に書く。部屋が名前を表示するので、本文に自分の名前を付けない。どれもどの環境の参加者にも効くものである。 - **他の AI の状態や担当は、本人に聞いて確認する(#461)。** - 長さと文面は `sidecar/test/round-trip.test.mjs` が描画後の文字列で持つ。二つの「前を見る」のうち長いほうでも上限に収まることを見る。 - サイドカーは自分の部屋での名前(`PULLCEPT_AGENT_NAME`)を `instructions` へ埋め込む。埋めなければエージェントは札の `to` を自分と照合できない。 - `instructions` は「届け方で人間と AI を区別しない」ことを明示する。区別しない前提はフレームだけでなく作法にも要る。フレームから区別を消しても、作法が「人間に答えよ」と言っていれば、エージェントはもう無い区別を読むことになる。`role` が分けるのは届け方ではなく重みであり、作法はそれを別の段で述べる(#195)。 - 札の `to` に自分の名前があれば自分宛、無ければ自分宛ではない、`to` そのものが無ければ部屋全体宛。全体宛に誰が答えるかはエージェントの判断に委ねる。複数の宛先があるときは、宛てられたそれぞれが自分宛として読む(#204)。 - 宛先を決めるのは札の `to` だけであり、届いた本文の `@…` は宛先を決めない(#204)。部屋の参加者に当たる `@名前` は部屋が `to` へ移して本文から消すため(#206)、本文に残る `@…` は誰にも当たらなかった文字である。作法はこれを述べる——本文を宛先として読ませれば、札と本文が食い違ったときにどちらを信じるかをエージェントに委ねることになる。 - `say_to_room` の `to` は一つの名前も名前の並びも受ける(#204)。サイドカーはどちらも並びにして、空白を落とし、空の名前と二度目の名前を除いてから送る。本文に書いた参加者の `@名前` も宛先になり、本文から除かれる(#206)。`to` の説明がこれを述べる(#273 で作法から移した)。 - 宛先による配送の絞り込みはアプリ側で行わない。部屋は全員へ配り、答えるかどうかをエージェントが決める。絞れば部屋が「誰に届いたか」を持つことになり、アプリが会話の中身へ関与し始める。 - 宛先は任意である。必須にすると人間の入力費用が上がる。 - 宛先の相手は人間でもセッションでもよく、指定の仕方は変わらない。画面の宛先の候補は「自分以外の参加者」であり、人間も同じ列に並ぶ。 - 届いた発言はすべて他の参加者のものである。自分の発言は戻らないため、エージェントは自分を認識する必要がない。 - **配達できた `say_to_room` は、その発言の `message_id` を返す(#267)。** 返答は `Delivered to the room. message_id: ` であり、前半の文言は変えていない。id はサイドカーが `randomUUID()` で作って `post` に載せ、部屋がそのまま保持して他の参加者の札へ載せるものである(`room.rs` の `deliver`)。自分の発言は戻らないため、他の参加者が引く id をセッションが知る口はこの返答だけである。`say_to_room` の説明は、自分の発言を証拠として後から指すときにこの id を使えることを述べる(#273 で作法から移した)。 - 先に誰かが答えていたら、その発言を読んでから自分の発言を決める。送る直前にも届いている発言をもう一度見て、言おうとしていたことが既に言われていたら送らず、足りないことがあるときだけ足す。全体宛の問いに全員が答える必要はない。宛先なしの問いに二人の参加者が同内容を二重に返した実測(2026-08-22)に対し、口頭で「順番を待ってその会話を見てから話し始める」と伝えたところ挙動は成立した。足りていなかったのは参加者の能力ではなく、これが部屋の規律に書かれていないことである。 - 順番は沈黙とは別軸である。宛先の節が扱うのは誰に向いた発言かであり、こちらは先に答えが出たときにどうするかである。混ぜて「答えなくてよい」だけを渡すと、全体宛の発言に誰も答えない状態を招く。書き方は「黙れ」ではなく「見てから決める」に寄せる。 - GitHub に本文つきで書き込むときは、本文の最終行に `— <自分の名前>` の署名を置き、署名の無い書き込みは部屋のどのセッションのものでもないと扱う(#270、下記「[書き手は本文の最終行の署名で見分ける](#書き手は本文の最終行の署名で見分ける270)」)。 - 作法が「見よ」と言えるのは届いた発言までである。参加者は床の状態——誰が今組み立てているか——を覗けない。**届いてはいる。読めるのは次の境界からである。** 組み立て中に届いた発言は端末へ打ち終わっていても、参加者の文脈へ入るのは CLI が順番待ちの入力を渡す位置——次の tool 結果が返る位置——であり、そこに何が載っているかは相手の送信時刻と自分の境界位置の関係だけで決まる。したがって「送信直前に読み直せる」は無条件には成立しない。作法だけでは閉じない理由はここにあり、閉じるのは次節の床である(#47)。 ## 床(同時発話の順序付け) 作法だけでは同時発火が閉じないことは実測で確定している(2026-08-23、部屋の participant による一次観測)。#49 の作法を入れた後も、宛先なしの問いに二人が同内容を二重に返す事象が二度起きた。二度めの二人は、その数分前に上記の機構を解明した当人である。**理解は防止に効かなかった。** 失敗は二型ある。 - **型 1** — 境界は在ったが、相手の着弾より前だった。非決定的である。 - **型 2** — 境界がそもそも無い。`say_to_room` 一回きりのターン(ただ答えるだけの返信)では、送信そのものが最初の境界であり、手前に読み直す隙が構造的に無い。**部屋で最も頻度の高い形が、最も確実に取りこぼす。** 射程: ゼロ tool のターンでも、ターン開始前に載っていた発言は読める。読めないのは組み立て中の着弾に限る。 閉じるのは部屋である。宛先(#31)はフィルタであって直列化ではなく、順番の作法(#49)は参加者が握れないものを前提にしている。どちらも同時発火を閉じない。 - **送り手が `last_seen` を申告する。** 値は、その参加者が実際に見たいちばん新しい発言の `message_id` である。部屋は自分が各接続へ何を配ったかを知っているが、**配達は読了ではない**。配ったものが送り手の文脈へ入ったかどうかは境界の位置で決まり、部屋からは見えない。実際に何を見たかを知っているのは送り手だけである。 - **判定と刻印は既存ロックの内側で、一つの操作として行う。** `deliver()` は単一経路であり、`Arc>` は既存である。二人が同時に呼んでも順序が付き、先に通った側の発言は、後の側の判定が読む床に既に載っている。判定と配送が割り込める形では両者が同じ空の床を読むため、機構として成立しない。 - **見ていない発言があるなら配らない。** 戻り値でその発言群を返す。受理して事後に知らせる形は採らない。分散システムの collision detection は中断と再送があって初めて機能するのであり、検出して配ってしまう形はその後半を捨てたものである。 - **再判断の結果が「送らない」ことを正当とする。** 参加者に再送を義務づけない。Ethernet は同じフレームを再送するが、参加者は読み直して「送らない」を選べる。バックオフが重複そのものの取り消しになる。 - **拒否された発言は、サイドカーが下書きとして一つだけ持つ(#268)。** 拒否のたびに本文を丸ごと組み立て直していた(2026-10-03 の部屋で Lin が四回拒否された)。持つのは本文と宛先であり、新しい拒否で置き換え、配達の成功で消す——何を送って成功したかに依らない。出し直しは `say_to_room` で行い、道具は増やさない。`content` を省いて呼ぶと持っている下書きをそのまま送り(宛先も下書きのもの。`to` を渡せばそちらを使う)、`content` を渡せばそれを送る(直して出す)。どちらも新しい `message_id` の普通の投稿であり、`last_seen` の判定を今までどおり受ける。下書きが無いのに `content` を省いた呼び出しは、その理由を返して何も送らない。拒否の返答文と `content` の説明は、下書きを持っていることと出し直し方を述べる。**送らない判断が正当であることは変えない。** 持っている下書きは送らなくても構わず、次の拒否か配達で消える。部屋は下書きを知らない——持つのはサイドカーだけであり、フレームもプロトコルの版も変えていない。 - **部屋は内容を判定しない。** 見落とした発言が話題に関係するかどうかを部屋は判断しない。`to` による絞り込みも行わず、見落としが一件でもあれば弾く。混雑時の呼び出し増は測ってから判断する。 - **`last_seen` が部屋の知らない値だったときは、見ていないものとして扱う。** 安全側へ倒す。保持窓(512 件)から溢れた古い `message_id` も同じ扱いになる。費用は送り手の往復 1 回であり、他の参加者の取りこぼしにはならない。 - **`last_seen` を身元の主張として扱わない。** 偽の値を送っても損をするのは送り手自身の重複検出だけであり、他の参加者へ影響しない。認証は不要である。 - **参加者クラスで書き分けない(#39)。** 画面からの投稿も同じ `deliver()` を通り、同じ判定を受ける。画面は自分が描画した発言の `message_id` を申告する。描画は部屋が受理した後に起きるため、競合の瞬間における順序はこの参加者についても実在する。画面に出たまま読まれていない発言は部屋から見えず、そこは埋めない。 - **席を取った位置より前の発言は問わない。** 途中から参加した参加者へ、それ以前の発言は配られていない。名乗り直しは席を引き継ぐため、この位置は変わらない。 - **拒否が返した発言を画面はその場で描く(#108)。** 拒否は発言そのものを運んでおり(`message_id` / `speaker` / 色 / `content` / `to` / `ts`)、画面はそれを捨てない。捨てていた形は「画面には既に出ている」を前提にしていたが、それが成り立つのは画面がその配送を受け取って描けた場合だけである。スリープや webview の再読み込みで描けなかった発言は、後から描かれる経路を持たない——透かしが進むのは `appendMessage` の中、すなわち行がガラスに載った瞬間だけであり、部屋は床の読み出し口を持たない。**描けなかった → 透かしが進まない → 拒否される → 描く機会が来ない** が閉じ、他の参加者が新しく発言するまで人は自力で抜け出せない(実機観測、2026-08-26)。**変えるのは、見せる手段を持たないまま見ろと言っている点だけである。** #47 の「読んでから決める」も、参加者への再配信を行わないことも、そのまま残る。**修正後の経路は 2026-08-27 に実機で辿っている**(AI が操作。「[実機で確認済み](4-status.md#実機で確認済み)」)——描けなかった原因として並べた二つのうち、実機で当たったのは webview の再読み込みの側であった。 - **描いた行に印は付けない。** 部屋の発言であることに変わりはなく、「追いついた分である」は発言の属性ではない。表示の軸を増やさない。 - **`message_id` で重複を落とす。** 解決できない `last_seen` は位置 0 として扱われるため(上記)、そのとき返るのは保持窓の全件であり、画面が既に描いた行を含む。画面は描いた `message_id` の集合を持ち、含まれるものは描き直さない。透かしが床の中に在るうちは事情が違い、返るのはすべて未描画である。 - **描き終えた画面は、返ってきた中でいちばん新しい発言を透かしにする。** 描いたものも既に在ったものも、画面に載っていることに変わりはない。全件が重複だった場合に何も描かれず透かしも動かない形は、上の閉路をそのまま残す。 - **色は発言に載せて運ぶ。** 床が保持する発言は、席から刻んだ宣言色を持つ。返すときに名前から引き直す形は採らない——名前は同一性ではないため二人が同じ名前を名乗った時点で別人の色になり、その話者が既に退室していれば引く先も無い(#40)。 - **状況表示は描いた分について述べる。** 512 件から溢れた分を部屋は列挙できず、画面はどれだけ落ちたかを知る手段を持たない。件数を装わず、「これで全部である」と読める形にしない。 型 2 が消えるのは、**呼び出しそのものが境界を作る** ためである。`say_to_room` は `post_result` を待って結果を返すようになり、ゼロ tool の返信という状態が存在しなくなる。弾かれた発言群は、その戻り値として参加者の文脈へ入る。 ツールは増やさない。`say_to_room` に引数(`last_seen`)と戻り値を足すだけであり、「返信ツールは `say_to_room` 1 本に限定する」に触れない。 弾かれた側の組み立ては無駄になる。**この費用は受け入れる**(Master、2026-08-23)。現時点で他に案が無いための暫定対応であり、より安い形が見つかれば差し替える。 **順番の付与への段差を残さない。** 戻すものを「見落とした発言」から「あなたの順番」へ広げるだけで signature が変わらない形にしてある(`PostOutcome` へのフィールド追加で済む)。順番の付与自体は範囲外であり、二重計算が痛むと測れてから判断する。 他システムの調査(2026-08-23): 主要フレームワーク(AutoGen / AG2 GroupChat、Microsoft Agent Framework Group Chat)は orchestrator が次の話者を選ぶ turn-based であり、同時発話が構造上存在しない。並行が要る場合は共有ターンを持たない actor-model へ案内される。**この問題を解いたのではなく、起こらない形にしている。** 最も近い前例は Bounded Autonomy(ライブマルチプレイヤーの LLM キャラクター、2026-04)で、Talk state の原子的ロックにより発話中の割り当てを拒否する。調査は 2 回の検索に基づき、「他に例が無い」ことは主張しない。 ## 部屋のログ 部屋で言われたことを追記のみの jsonl として残す。部屋の発言はウィンドウの DOM にしか無く、閉じれば消えていた——引用できないことは症状のひとつであり、実際に何が起きたかを根拠にする設計判断が参加者の記憶だけを頼りにしていたことが、これを持たせた理由である(#48)。 **単位はトピックである(#115)。** 一本の流れに全部を積む形は #48 が残したものであり、この issue が置き換える。トピックは会話の一区画ではなく、会話が行われる器である——開き直せばその会話が部屋へ戻り、続きが話せる。 ```text logs/{部屋名}/index.json トピックごとの索引: 名前・作成時刻・{account_id: session_uuid} logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記のみ logs/{部屋名}/attachments/{トピック}/{名前} そのトピックの発言に添付したファイル(#223) ``` - **ログは部屋のものであってタブのものではない。** `config.rs` の `SavedChatMessage` / `SessionData` / `TabSessions` は流用しない。`SavedChatMessage` は `role` と `content_type` を持つチャットアシスタントの語彙であり、部屋には `role` の当たる軸が無い——人もセッションも一種類の参加者であり、二つの行を分けるのは名前だけである(#39)。`TabSessions` の鍵はタブであり、タブは起動の形であって会話の器ではない。どちらを配線しても、#39 が外した非対称が保存層から戻る。 - **一行が一つの発言であり、載るのは七欄である。** `message_id` / `speaker` / `account` / `from_app` / `content` / `to` / `ts`。`account` は発言者が名乗ったアカウントの id であり、`to` と同じく、無いときはキーごと書かない。この欄より前に書かれた行は、名乗りの無い行として読む。`from_app` はアプリ自身の発言(#294)であるときだけ `true` で書き、それ以外の行ではキーごと書かない。この欄より前に書かれた行は、アプリの発言でない行として読む(#340)。 - **`to` は名前の並びで書く(#204)。** #204 より前の行は一つの名前を文字列で持っており、読むときに一つの名前の並びとして読む。ファイルは書き換えない——ログは書かれたものであり、広げるのは読む側である。 - **`account` を載せるのは、それが誰が言ったかであるからである(#193)。** `speaker` と同じ軸であり、`hue` や `own` とは違う。画面が畳むのは `mcp` アカウントの発言であり(下記「webhook の通知」)、読み戻した行がライブの行と同じに畳まれるには、行がそのアカウントを運んでいる必要がある。名前では足りない——名前は編集され、衝突もする。同一性として読むものは無い(上記「部屋プロトコル」の「アカウント id は同一性ではない」)。 - **`from_app` を載せるのも、それが誰が言ったかであるからである(#340)。** 部屋の誰でもなくアプリが言ったことを言う。部屋は発言が入った口(`app_origin`)からだけこれを刻み、発言者の名前からは決めない——名前 `Pullcept` はセッションも名乗れる。画面はこれでアプリの発言の丸印にアプリのアイコンを描き(「[会話面の見た目](2-screen.md#会話面の見た目225)」)、読み戻した行がライブの行と同じに描かれるには、行がそれを運んでいる必要がある。アプリの知らせは席を取らず名簿にも載らない(`room.rs` の `app_origin`)ため、参加者を分ける欄ではない(#39)。拒否が返す発言にも同じ値を載せ、`true` のときだけ書く。 - **`hue` と `own` は載せない。** `own` は見る側の属性であって話者の属性ではなく(`room.rs` の型が自らそう言っている)、記録すれば一人の視点を発話の一部として残すことになる。`hue` は参加した時点の宣言であり、読み戻す頃にはそれを持っていた席が無い——引き直せば、誰も宣言していない色を描くことになる。 - **一トピック一ファイルとする。** 一ファイル+`topic_id` 欄の案は採らない。理由は二つあり、両方が要る。一つのトピックを読むのに一つのファイルだけを読めばよくなること(#114 はこれで解消する)と、トピックが発言には無いもの——そこに居たセッションの id——を持つことである。投稿に欄を増やす形にはその置き場が無い(#39、参加者クラスを値域に取る欄を作らない)。 - **実在はディレクトリであり、索引はその注釈である。** 索引に無い `.jsonl` は読み出しのときに拾って項目を建てる。これが遅延生成を安全にしている——起動のたびに空の行が索引へ残らず、発言がファイルへ届いて索引へ届く前に落ちても、ファイルだけから項目を建て直せる。 - **削除はファイルと索引の項目の両方を消す。ファイルが先である(#119、決定1)。** 索引の項目はファイルの注釈であるため、項目だけを消してファイルを残した状態は「削除されたトピック」ではなく「注釈の無いトピック」であり、次の読み出しの照合がそのまま項目を建て直す——何も失敗を報告しないまま復活する。順序はここから決まる。消えるべき半分はファイルであるから先に消し、二つの間で失敗すれば「項目は在るがファイルが無い」状態(#117 が既に述べているもの)が残る。これは一覧に見えている状態であり、逆順の失敗(トピックが戻ってくる)とは違う。ファイルが無いことは失敗ではない——それが #117 の状態そのものであり、項目を消すことがまさに求められているものである。 - **索引へ書くのは意図のある行為だけである。** 最初の発言、セッション id の記録、名前の変更。「アプリが開かれた」はそこに含まれない。 - **判定と同じロックの内側で書く。** `deliver()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。書き込み先のトピックは、判定した床の部屋が持つトピックそのものである(#141)。部屋は生きている間ずっと一つのトピックであるため、受理した床と書き込むファイルが食い違う余地が無い。 - **索引はロックの外で書く。** 別のファイルの読み書きであり、部屋を止めて行う仕事ではない。発言はそのどれにも待たされない。 - **追記の失敗で発言は失われない。失敗は画面に出す。** 発言はディスクに触れる前に部屋へ入っており、書き込みの失敗が取り消せるものではない。失われうるのは記録の側だけである。したがって配送も戻り値も追記の成否に依存させず、代わりに `room-log-error` イベントで画面の状況行に出す——黙って記録を止めたログは依然としてログに見え、後から見に行った者はその空白を「何も言われなかった」と読む。 - **画面への読み出しはトピックを指定して行う。** `room_topic_log` は開くトピックを名指す。一覧から選ぶという操作がそもそも「現在ではないトピックを読む」ことであるため、現在のトピックを暗黙に読む形では成り立たない。 - **参加者への読み出しは pull のみである。** 部屋から過去を配る経路は作らない(下記「トピック」)。 - **ローテーションも上限も持たない。** トピックごとにも、全体としても。古い方から捨てるログは、「実際に何が言われたか」という、これが在る理由そのものの問いに、問う価値が出るちょうどその距離で沈黙する。 - **部屋名はパスの位置として持ち、`main` に固定する。** Vision の `main` を部屋名の位置と読む。#141 で部屋はトピックごとに複数になったが、それはこの位置の値を増やす形ではない——トピックはすでにこのディレクトリの下で一ファイルずつ分かれており、「部屋=トピック」の部屋はその一ファイルに当たる。この位置が指すのは、トピックの一覧を束ねる単位(Vision の `main`)のほうであり、そちらは一つのままである。 - **読めない行は飛ばす。読み全体は失敗させない。** 途中で終わった実行が残す千切れた末尾がこの経路に当たり、その最後の一行のために全履歴を拒むのは、何も守らずにすべてを失う。飛ばした件数は追記の失敗と同じイベントで画面へ返す。 - **以前の `logs/{部屋名}.jsonl` は一つのトピックとして引き継ぐ。** 索引を初めて建てるときに移動する。コピーではない——一つの会話が二つのファイルに在ることは、その会話に二つの順序が在ることであり、部屋が権威を持っている当のものである。捨てない(#115、決定8)。 ## トピック トピックは会話の器であり、会話の記録ではない。開き直すと、そこに居た CLI セッションが**自分の文脈を持って**戻る(#115)。 **区切りは手動である。** 「新規」を押して切る。起動とは独立しており、一回の起動で複数トピックも、起動をまたいで一トピックの継続もありうる。 **起動時は新しいトピックを開く。** 最後のトピックの続きを自動では開かない。部屋は毎回空から始まり、過去は一覧から選んで開く。#48 の「起動時の部屋は新規である」は既定として残り、変わったのは「消えた先に読みに行ける場所があるか」ではなく「そこから戻せるか」になった点である。 **新規トピックは遅延生成する。** 起動のたびに索引へ空のトピックを書くと、何も話さずに閉じた回が一覧に残り続ける。起動時のトピックは「まだ索引に無い現在のトピック」として部屋が持ち、最初の投稿が着いた時点で索引とファイルができる。毎回の起動直後には、部屋に在って一覧に無いトピックがちょうど一つ在る。画面はそれを行としては描かず、一覧の外に固定で在る「新規」——#225 からは見出しの横の +——がその場所を言う(#125、決定1。「[トピックの一覧](2-screen.md#トピックの一覧)」)。 **発言が無くても、席がそこで始まれば索引に入る。** セッションを始めるのは意図的な行為であり、その id が起動を越えて残らなければ resume の意味が無い。そのため席の記録は遅延生成を待たずにトピックを実体化する。**配る id が無い起動でも実体化する(#141)。** セッションは画面が別のトピックを開いても自分のトピックで走り続けるため、一覧に無いトピックで走るセッションは、画面のどこからも戻れず、走っている印も付かない(下記「トピックを切り替えても、セッションは止めない」)。この経路には題の元になる発言が無いため、索引には題の無い項目が残る——起動して一言も発しないまま終えた回がこれに当たる(#125)。実体化そのものは外さない。直すのは、その行の見せ方と消し方である。 **復帰の手段は二段構えである。** ネイティブ resume を持つ CLI はそれを使い、持たない CLI にはサイドカー経由の読み出しを与える。履歴をテキストで PTY へ流し込む力業は採らない。理由は三つある。トピックが伸びるほど毎回の起動が高くつく(参加者が要るかどうかに関わらず全部渡すため)。CLI は流し込まれた文をユーザ入力として読むため、「自分が言ったこと」と「言ったと聞かされたこと」を区別できない。そして端末ペインにその塊が見える——あのペインはセッションのものである、というのが `RESUMED_NOTICE` の設計思想である(#84)。読み出しは力業を push でやる代わりに pull にしたものであり、これが在れば力業は要らない。 **再開の一行は、まず種別の属性である**(#156、決定6。当初はアカウントの属性だった)。`{session_id}` を含む一行であり、先頭の語が command になる(例: `claude --resume {session_id}`)。トピックが `{account_id: session_uuid}` を保持し、再開時にそこへ差し込む。CLI を名指す種別はその一行を作法として持ち、名指さない種別ではアカウントの欄が持つ(「[再開コマンド](3-accounts.md#再開コマンド)」)。 **新規セッションへ渡す id も同じ形である。** どのフラグが id を運ぶかは CLI ごとの問いであり、再開の一行が答えているのと同じ問いである——だから答えるのは同じ種別であり、`Claude Code` では `--session-id {session_id}` が起動する行へ載る。作法を持たない種別では、その置換子を起動オプションへ書くことで配る(「[起動オプション](3-accounts.md#起動オプション)」)。**どちらも無ければ id を配らない**——それは resume を持たない CLI が恒常的に置かれる状態であり、失敗ではない。 **判定は「このトピックがこのアカウントのセッションを持つか」で行う。** 「アカウントが resume を持つか」ではない。記録と再開コマンドが揃い、かつその会話が実在するときだけ resume であり(三つめは #131、下記「再開の線は、その会話が実在するときだけ選ぶ」)、どれかが欠ければ新規起動である。起動チェック(互換しないフラグ、`--settings` の二重宣言)は解決後の行に対して走る——アカウントの行を見て別の行を spawn する形にしない。 **再開できない席があってもトピックは開く。** 復帰できる席は復帰させ、できない席は新規起動として着席する。読み出しがあるため、復帰できなかった席も自分で辿れる。トピックが開かない形は取らない。resume だったかどうかは画面の状況行で言う——黙って同じ言葉で済ませると、戻ってきたのかどうかを人が判別できない。 **戻れなかった再開先は、そのトピックから外す(#127)。** セッション id は spawn が返った直後に記録される。プロセスが立ったことは会話ができたことではなく、起動直後の確認プロンプトで止めたまま終えた回は、会話の無い id を残す。以後その(アカウント, トピック)の組は押すたびに再開の線へ入り、同じように失敗する——再開の失敗から通常起動へ落ちる経路は無いため、記録を消すかトピックを消すまで直らない。**再開の線で起動したセッションが、部屋の名簿に一度も現れないまま自分で終了したら、そのトピックからその id の記録を外す。** **判定に CLI の出力は読まない。** 文言の一致を条件にすれば、CLI の版で言い回しが変われば黙って効かなくなる。「[未実装](4-status.md#未実装)」が「CLI の出力から状態を読む形」として保留している軸にも踏み込まない。読むのは既に在る三つだけである:起動が再開の線であったこと(席が持つ。席はアプリに在り、画面の再読み込みを越える)、名簿がそのアカウントを一度も運ばなかったこと(行が「起動中」を描いているのと同じ観測であり、「[参加者パネル](2-screen.md#参加者パネル)」が持つ「走っている」の定義そのものである)、そのセッションが自分で終わったこと(終了イベントが「アプリが止めた終了」の印を運んだかどうか。コードの値ではない——印は止めた側が付ける。「[終了は、頼んだものと頼んでいないものを分けて言う](2-screen.md#診断面)」)。**こちらから止めたものは数えない。** 行の `❌`、トピックの削除、アプリの終了はいずれも人が頼んだ終わりであり、再開の失敗を意味しない。確認プロンプトは起動した CLI を数分のあいだ部屋の外に留めるため(「[未実装](4-status.md#未実装)」の #89 の項)、その間に起動し違えたアカウントを止めることは普通の操作であり、成立したはずの戻り道をそれで失ってはならない。**会話ができた瞬間をアプリが知る必要は無い。** 知る必要があるのは戻れなかったことだけであり、それは終了として観測できる。 **外すのは、記録がその id のままであるときだけである。** セッションが終われば席は空くため、外す前に同じアカウントが同じトピックへ起動し直して新しい id を書きうる。id で照合すれば、その新しい記録には触れない。**外すのは戻り道だけであり、トピックの項目も発言も残る。** その会話は在ったのであり、無かったのは戻り先である。 **外したことは状態行で述べる。** 握り潰すと、履歴が繋がっていないまま話が続く。上記「復帰の手段は二段構えである」と同じ形であり、resume だったかどうかを状況行で言うのと同じ理由である。**自動では起動し直さない(AI 判断)。** 記録が外れているため、次に押せば通常起動で立つ。こちらで再試行を回すと失敗の繰り返しをアプリが回す形になり、`model-loop-safety` が避ける形に当たる。押すかどうかは人が決める。 **再開の線は、その会話が実在するときだけ選ぶ(#131、決定1)。** 揃うべきは三つである——記録された id が在ること、アカウントが再開の線を持つこと、**その id の会話がファイルとして在ること**。前の二つはこのアプリ自身が書いた記録であり、記録した時点では会話ができたことを言っていない(上記「戻れなかった再開先は、そのトピックから外す」)。三つめだけが CLI の側の記録であり、それを起動の前に見る。**在るものから選べば、無いものへ戻ろうとする状態はそもそも作れない。** 記録した id を無条件に渡して失敗から立て直す形の逆であり、立て直す経路(#127)が通る場面はこれでほとんど残らなくなる。 **実在しなければ、その場で記録を外す(決定2)。** 失敗を待たない。待てば次に押したときも同じ線へ入るため、#127 が一度だけ払う失敗を毎回払うことになる。外したあとは通常起動として立ち、外したことは状態行で述べる——上記「外したことは状態行で述べる」と同じ理由であり、同じ状況行が言う。 **CLI の保存場所への依存を受け入れる(Master 判断、決定3)。** 見るのはファイルの有無だけであり、CLI の出力も終了コードの値も読まない(上記「判定に CLI の出力は読まない」)。文字列の一致より安定である代わりに、保存の形は CLI ごとに異なる:`claude` は `~/.claude/projects/<作業ディレクトリのスラッグ>/<セッション id>.jsonl`、`codex` は `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` に置く。#131 時点では `claude` 依存を受容した。#272 で Codex の保存場所も種別の答えへ追加した(下記)。**依存は `crates/mcp-config/` の種別ごとの答え(`Cli::transcript_path`)一つに閉じる。** CLI が増えるときに差し替える場所を一つにするためであり、「どのフラグが id を運ぶか」(上記「新規セッションへ渡す id は…」)と同じ形の問いを同じ場所へ集める——#156 でその集約が種別そのものになった(「[種別は CLI の作法を持つ](3-accounts.md#種別は-cli-の作法を持つ)」)。スラッグは英数字でない文字を一つずつ `-` へ畳んだものであり、`C:\Users\smile\Code` は `C--Users-smile-Code` になる(2026-08-29、実機の `~/.claude/projects/` にある二例から確定)。 **中身は読まない(制約)。** 壊れた記録を弾く話はここではしない。問いは「その会話が在るか」であって「その会話が読めるか」ではない。 **#127 の復帰は残す(決定4)。** 事前に見るのはファイルの有無だけであるため、会話が在るのに CLI が戻ることを拒む場合は残る。再開が成って部屋に一度も現れないまま終えた場合の取り消しは、その守りとして生きている。消して後から足し直すことはしない。 **二つの場面は、二つの別の仕組みである(#131)。** CLI が生きている側——スリープからの復帰——は繋ぎ直しであり、席の引き取りが担う(#84、#115 決定4C)。PTY が生きているため `--resume` は要らない。CLI が死んでいる側——アプリの再起動——が読み直しであり、resume が担うのはこちらだけである。**二つの場合ではなく、二つの仕組みが一つの設定欄に相乗りしている。** 上の実在判定が resume の側だけに置かれているのはそのためであり、繋ぎ直しの経路はこの判定を通らない。 **再開の線に適用範囲を持たせる形は、ここでは扱わない。** 再開コマンドはもともとスリープ復帰への手当てとして入ったものであり(#108、#84)、`resolve_launch` はその由来を知らない。どの状況のための線かをアカウント側が言えるようにする形は設定の概念を増やす変更であり、この復帰とは別の判断である(#127、#128、別 issue)。上の切り分けはその判断の材料であって、欄そのものを動かすものではない。 **トピックは部屋である(#141、決定3)。** トピックごとに床と席を持ち、トピックを切り替えることは、画面がどの部屋を見ているかを変えることであって、どの部屋の中身も変えない。以前は部屋が一つで、切り替えのたびに床を空にし、席を入口へ戻していた——それは全セッションが画面に付いて移動していた間は正しかったが、他のトピックで走るセッションがその部屋の発言を受け、発言もそのトピックのログへ書かれていた(トピックごとの分離が無かった)。**床は切り替えで空にしない。** 離れた部屋で話し続けているセッションから、組み立て中の判定の足場を抜くことになるためである。 **トピックを切り替えても、セッションは止めない(決定2)。** 同じアカウントが複数のトピックで同時に動ける。切り替えは表示の切り替えであり、止めて再開する形は取らない。止まるのはトピックを削除したとき(下記、#119 決定4)とアプリの終了だけである。 **部屋は、トピックを開いたときに作り、削除したときに捨てる。** 起動時のトピック、「新規」、一覧からの選択のどれでも、この実行でまだ部屋が無ければ作る。静かになったことを理由には捨てない——そこでセッションが走っているかもしれない。 **画面は「今見ている部屋」を持つ。** 起動と一覧の選択はそこへ入る。**起動はどのトピックへ入るかを画面が名指す。** `▶` を押した時点で画面に出ていたトピックであり、端末はそのトピックの下に作られる(「[診断面](2-screen.md#診断面)」)。アプリが後から開いているトピックを読む形にすると、起動の間に画面が移れば、セッションは一方のトピック、端末はもう一方に分かれる。 **部屋は過去の発言を push しない。** 席を取る前の発言が届かないことは変えない。#31 / #39 で退けた「部屋が誰の既読を持つ」形に触らずに済む。トピックによる復帰はこの制約を緩めるのではなく、不要にする。 **読み出しの道具は `say_to_room` の隣に置く。** `read_room_history` はそのセッションが居る部屋のトピックのそれまでの発言を古い順で返す。画面が開いているトピックではない(#141)——そう読んでいた間は、他のトピックで走るセッションに、自分の居ない会話を渡していた。既定 50 件、上限 200 件、`before` に `message_id` を渡すとその手前が返る。トピックに上限が無い以上、一回の tool 結果へ全部を載せる形は採らない。**この道具は発言しない。** 「返信ツールは `say_to_room` 1 本に限定する」は投稿の軸の制約であり、読み出しはその軸に載らない——二つめの喋り方があれば「どちらで答えるのか」が参加者の判断に戻るが、読み方が増えてもそれは起きない。 **返すものは「あなた宛ではない」と明示する。** 読み出しが返す発言は、その参加者へ配られたことが一度も無いものである。届いた発言として読めば答えるべきものになる。 **文脈を持たないまま席に着いたことは、そのセッション自身へ伝える(#133)。** 道具が在っても、呼ぶべき場面を名指す文が作法に無ければ引かれない。欠けていたのは道具ではなく引き金である。再開先を外した起動は状態行で**人には**言うが(上記「外したことは状態行で述べる」)、席に着いた当人には何も言わない——文脈ゼロで座り、そうと知らないまま話し始める。 **伝える条件は「そのトピックに既に発言が在り、かつこの起動が再開の線に入らなかったこと」である(AI 判断1)。** 「再開先を外した席」ではない。外した席も、発言のあるトピックへ初めて入った席も、等しく来る前の発言を持たない——盲目の度合いが同じである。前者だけを拾う形は #131 の経路に貼った絆創膏になり、同じ状態の残り半分を落とす。判定は解決後の行に対して行う(`resumed_from` が無いこと)。`dropped_resume` はその一例に過ぎない。 **伝えるのは「在る」ことだけで、件数は載せない(AI 判断2)。** 引くかどうかの判断に件数は要らない。件数を出すにはトピックの全読みを起動のたびに払うことになり、対価が釣り合わない。見るのはファイルの大きさだけである(`crates/topic-index` の `has_posts`)。壊れた行しか無いファイルは「在る」と答え、続く読み出しが「何も言われていない」と返す——多く答えれば要らない呼び出しが一回、少なく答えれば欠けていること自体が伝わらない。 **アプリが `read_room_history` を代理で呼ばない(AI 判断3)。** 引く判断はセッションのものである。アプリが結果を先回りして注入すれば、上の力業と同じ場所に着く。作法は「在る」と述べるだけであり、「読め」とは言わない。渡すのは「在る」という事実であって、発言そのものではない——上記「部屋は過去の発言を push しない」の側に立つ。 **伝える口は起動ごとの env である。** `PULLCEPT_UNSEEN_HISTORY` を `.mcp.json` の登録へ書き(`crates/mcp-config/`)、サイドカーが `instructions` の「前を見る」の一行をそれで差し替える。二つの形は並置ではなく置換である。登録は起動のたびに丸ごと書き直されるため、状態が成り立たない起動へ前回の鍵が残ることはない。 **トピックは一覧から消せる(#119)。** 消すとは、そのトピックのファイルと索引の項目の両方を捨てることである(決定1)。そのトピックへ添付したファイルも一緒に捨てる(#223)——パスを運んでいた発言が無くなれば、誰も名指さないファイルになる。順序はファイル → 添付 → 索引の項目であり、添付で失敗すれば「項目は在るがファイルが無い」状態(#117)が添付を抱えたまま残り、押し直せば消える。索引の項目だけを消す形は取らない——上記「部屋のログ」の照合が次の読み出しで建て直すためであり、黙って復活する削除になる。 **消えたものは戻らない。** ログは git に載っておらず、他所に写しも無い。`rules/model/subtractive-structural-beauty.md` の削除判断でいう「ローカルの非 git な意味のある状態」であり、復旧コストは高い側に立つ。したがって**確認の一段は任意ではなく必須**である(決定2)。形は明示のダイアログであり、二度押しや猶予ではない。 **セッション id も一緒に消える。** 「ログは残してセッションだけ捨てる」形は選択肢から外れている。トピックを消すとは、その会話と、そこに居た参加者への戻り道を、同時に捨てるということである。 **走っているセッションを持つトピックも消せる(決定3)。** 走っていることを理由に拒まない。 **消すときは、そのトピックで走っている CLI プロセスを止める(決定4)。** 席に残さない——トピックが消えれば戻り道も消えるため、走らせたままにすると、どのトピックにも属さないセッションが残る。**どのトピックの席かは起動時の事実であり、席がそれを持つ。** トピックの `sessions` から引く形は取らない——以前このトピックで走り、今は別のトピックで走っているアカウントに届いてしまう。 **確認ダイアログはセッションが止まることを述べる。** ログの削除だけを述べていると、止まったセッションが不意打ちになる。走っているセッションが無いときは述べない——そのときは事実ではない。 **題の無い行も一覧から消せる(#125、決定2)。** #119 決定5 はここに削除ボタンを出さないと決めていた。理由は「そう読まれる二つの行は、どちらもここから消す理由が無い」であり、その片方——まだ何も言われていない現在のトピック——は決定1 が一覧から外した。一覧に残る題の無い行は、索引に在る行だけになる:ファイルの無い孤児(#117)と、発言が無いまま席の記録で実体化した行(上記)である。どちらも消したい行であり、消す手段が無いまま起動のたびに増えていた。決定5 は覆すのではなく、前提が外れる。 **消したトピックの部屋も捨てる(#141)。** 画面が開いていたかどうかに依らない。部屋はトピックであり、存在しないトピックには発言の書き込み先が無い。その部屋へ繋がったまま残る接続(起動途中の席、「[受容したトレードオフ](6-tradeoffs.md#受容したトレードオフ)」)からの投稿と読み出しには理由を返す。 **開いているトピックを消したら、新しいトピックへ移る(決定6、AI 判断)。** 消した直後の状態は起動直後の状態と同じもの——部屋は空で、まだ索引に無いトピックに居る——であり、アプリが既に持っている状態を使い回す。「次のトピックへ移る」は取らない:頼んでいない会話が開くうえ、要らないものを続けて消しているとき、次も消したいトピックへ落ちる。「開いているものは消せない」も取らない:見ているから要らないと判断したのに、一度離れてから消せというのは順序が逆である。 **三つの行為の順序は、セッション → 保存 → 部屋である。** セッションを先に止めるのは決定4のためである。保存を先に消して止めるのが後だと、失敗したときに決定4が禁じた状態(トピックが消えてセッションが残る)になる。逆に保存の削除が失敗したときは、セッションは既に終わっていてトピックは一覧に残る——見えている状態であり、押し直せる。部屋を最後に動かすのは、移った先を画面へ返す必要があるためである:画面は自分の現在トピックを持っており、消えたトピックを指したままだと、描いている会話と記録先が食い違う。 **トピック名は最初の発言の冒頭から自動生成し、後から手で変更できる。** 日時のみの一覧は可読性が低い。生成は最初の発言が着いた時点で一度だけ行い、既に名前があるトピックには触れない——話す前に名前を付けたトピックの名前を、最初の発言が取り返す形にしない。 ## 席が自分の利用上限を読む(#364) **席の AI は、自分の利用上限の数字を部屋の道具 `my_usage` で読む。** 返すのは 5 時間の枠と週の枠の使用率とリセット時刻、その値をアプリが受け取った時刻である。上限が近いと分かれば、席は止まる前に作業を区切る・途中まで push する・部屋で引き継ぎを頼むことができる。席の AI 自身はこの値をどこからも読めない——Claude Code の CLI はステータスラインの値を人に見せるが、モデルには渡さない。道具の説明はこの使いみちを述べる(Claude デスクトップの同等の道具の説明に倣った)。 - **新しく測るものは無い。** 値はアプリがパネル用に持っているものである。Claude Code の席はステータスラインの `rate_limits`(`/hooks/status`、`SessionStats::read`)、Codex の席は rollout の `token_count`(`codex::status`)から来る(「[セッションは自分の様子をステータスラインで知らせる](3-accounts.md#セッションは自分の様子をステータスラインで知らせる)」「[Codex の席は自分の様子を rollout で知らせる](3-accounts.md#codex-の席は自分の様子を-rollout-で知らせる283)」)。アプリは届いた値を席(トピック, アカウント)ごとに一つ持つ(`room::UsageBook`、`mcp_config::usage`)。 - **受け取った時刻を足した。** 値は席が動いたときにしか届かず、しばらく黙っていた席の値は古い。Claude Code の席は報告が届いた時刻、Codex の席は rollout から枠の値を読んだ時刻であり、値が前と同じでも新しく受け取れば時刻は進む。画面へ同じ値を送り直すとき(制限中の切り替えなど)は受け取りではなく、時刻を進めない。Claude Code の席の報告は、パネルが採るもの(その席の起動が送ったもの)だけを持つ。 - **返すのは呼んだ席自身の分だけである(マスター判断 2026-10-10)。** ほかの席の残りは部屋で本人に聞く。全員分を返す形は人数が増えると重い。サイドカーの `usage` フレームはどの席かを名指さず、部屋はその接続の部屋と、`hello` で着いたアカウントで引く。起動が認めたサイドカーのアカウントは起動のものと突き合わせ済みである(#208)。アカウントを持たない接続には「この接続にはアカウントが無い」と `error` で返す——「まだ届いていない」とは別の答えである。 - **値が無い窓は「無い」と返す。** 部屋はその窓をキーごと省き、サイドカーは `not reported` と書く。0% とは言わない(パネルの「—」と同じ線)。まだ何も届いていない席には、受け取った時刻ごと無い答えを返し、サイドカーは `No usage figures received for this seat yet.` と書く。これは失敗ではなく答えである。 - **返す文は短い。** 窓ごとに一行(`5h: 85% used, resets 2026-10-10T19:05+09:00`)と、受け取った時刻と何分前か(`received 2026-10-10T18:48+09:00 (12 min ago)`)。時刻は部屋の札と同じ、現地時刻と UTC からの差である。 - **何も書かない。** 床にもログにも触れず、発言もしない。`read_room_history` と同じく読み出しの道具であり、「返信ツールは `say_to_room` 1 本」の軸には載らない。Codex の席では、ほかの二つと同じく呼ぶたびの承認を求めない(`approval_mode = "approve"`、`crates/mcp-config/src/codex.rs`)。 - **持った値はセッションが終わっても残し、トピックを消すと捨てる。** パネルの最後の値と同じである。同じ席を起動し直せば、自分の値が届くまで前の値を受け取った時刻つきで返す。 ## webhook の通知(#169) webhook を CLI セッションではなく**アプリ本体**で受け、部屋に通知として出す。将来 channel を廃止する見込み(#147 決定4)に向けた実証として入り、#195 で部屋の入力路から channel が外れた後もこの受信は変わらない。自分の `github-webhook-mcp` を channel として読み込むセッションはそれを受け続ける——それはその人の起動の行であり、部屋のものではない。この経路はその隣に一本足すものであって置き換えではない。 **受けるのは、セッションが受けているのと同じ push である。** `github-webhook-mcp` のブリッジは接続してきたクライアントが何者かを見ない。`claude/channel` を宣言し、接続した相手へ `notifications/claude/channel` を送る(#169 premise)。アプリは同梱したブリッジ(npm の `github-webhook-mcp`、`package.json` の依存)を自分の MCP クライアントとして stdio で起動し、CLI が座る位置に座る。イベント id は `meta.message_id` に載る——公開版のブリッジはそこにしか置かない。 - **起動は `node sidecar/src/webhook-bridge.mjs` である。** ブリッジをそのまま読み込み、一つだけ足す:標準入力が閉じたら終わる。ブリッジの stdio は `data` と `error` しか聞いておらず、トークンファイルがあれば WebSocket と keepalive のタイマーがプロセスを保つため、アプリが閉じても落ちても走り続けて受け取り続ける。標準入力の終わりはアプリのパイプであり、アプリがどう終わっても OS が閉じる。見つけ方はサイドカーと同じ一回の探索であり(`status.mjs` と同じく入口の隣)、見つからなければ受けないまま部屋は動く。この起動の形は `mcp-servers.json` の既定のエントリとして持ち、設定画面から変えられる(#172、下記「設定」)。 - **認可はしない。** ブリッジが WebSocket を張るのは、トークンファイル(`~/.github-webhook-mcp/oauth-tokens.json`)が既にあるときだけである(#169 premise)。アプリが道具を呼ぶのはイベントが届いた後だけであり(下記「[書き手は本文の最終行の署名で見分ける](#書き手は本文の最終行の署名で見分ける270)」の `get_event`)、認可していない環境では何も届かないため、何も呼ばず、起動時にブラウザが開くことも無い。認可はセッション側のブリッジが持ったままである。 - **ブリッジが終わっても起動し直さない。** WebSocket の繋ぎ直しはブリッジ自身が行う。プロセスそのものが落ちたのをアプリが回し直せば、失敗の繰り返しをアプリが回す形になる(上記「外したことは状態行で述べる」と同じ理由)。設定画面の「再起動」を押すか、アプリを起動し直せばまた立つ(#172)。 **配る先は、その時点で AI セッションが座っている部屋すべてである(Master 判断、2026-09-27)。** 部屋ごとに一件ずつ、その部屋の `post` として入れる。判定は「画面の人以外の席があるか」であり(`crates/webhook-bridge` の `holds_session`)、部屋ソケットで `hello` した接続はセッションである——人は画面に居るのであってソケットには居ない。 - **既存の配送に乗せる。** 一件は `deliver()` を通る他の発言と同じであり、同じ床、同じログ、同じ `room-message`、同じ端末への入力である。札の `role` は `mcp` になる(#195)。配送の経路は増えない。部屋ごとに `message_id` を別に振る——それぞれがその部屋の発言だからである。その代わり、それぞれのトピックのログに webhook の発言が残る(受容。「[受容したトレードオフ](6-tradeoffs.md#受容したトレードオフ)」)。 - **発言者はそのサーバの `mcp` アカウントであり、席は取らない(#193)。** アプリが起動するローカル MCP サーバは一つずつ種別 `mcp` のアカウントを持ち(「[アカウント](3-accounts.md#アカウント)」)、通知はそのアカウントの発言として出る——名前が `speaker` に、色とアカウント id が発言に載る。以前は発言者 `webhook`(席も名簿も持たない貼り紙)だった。発言者が種別を持つことで、端末へ届く発言の札にロールを載せられるようになる(#195)。名前と色は通知が届いた時点のアカウントから読むため、アカウントの窓で変えたものはサーバを立て直さずに次の通知から効く。**席は取らない**ことは変えていない:サーバは答える手段を持たないため、名簿に載らず、宛先にも出ない(#32 の境界判定)。origin はアプリが一つ採番し、どの接続のものでもないため端末への入力は誰も飛ばさない。部屋のセッション全員の端末へ入り、画面が描く。どのサーバの発言かは origin ではなく発言に載ったアカウントが言う。 - **床は通知に何も問わない。** 通知は部屋で言われたことを見て組み立てたものではないため、見落としうるものが無い。いま席を取った参加者が立つ位置(床の現在位置)に立ち、拒否されない。 - **振り分けない。** 誰の話かを読んで宛先を付けることはしない(#32)。`to` は付かない。 - **最初の発言は最初の発言である。** トピックの最初の発言が通知であれば、トピックの名前はその通知から付く。発言者で区別しないのは #39 の線であり、この経路のために命名へ分岐を足していない。 **AI セッションが座っている部屋が一つも無いときは、どこにも入れない(Master 判断、2026-09-27)。** イベントは `github-webhook-mcp` 側に未処理のまま残る——channel で配られたイベントも `mark_processed` されるまでは未処理であることは、ソースの読みで確かめてある(#169 premise)。後から席に着いたセッションにも遡って配らない。[constraints](0-requirements.md#constraints) の「部屋から参加者へ過去を push しない」と同じ線である。 **アプリはイベントを `mark_processed` しない(#180)。** 部屋へ出すのは見せることであり、処理済みにするかどうかはそのイベントを実際に扱ったセッションが決める。未処理の一覧を片付けるのもセッション側であり、アプリは何もしない。`github-webhook-mcp` の既読は全体で一つであるため、アプリが印を付けると、poll で未処理を読むセッション(Desktop の Li+ など)の一覧からそのイベントが消える——2026-09-27 に実際に観測した(#180 premise)。未処理のまま残しても部屋に二度載ることは無い:Worker が broadcast するのは取り込みの瞬間の一度だけであり、WebSocket の接続時に送るのは `{status: "connected"}` だけで、未処理の再送は無い(`github-webhook-mcp` の `worker/src/store.ts`、#180 premise)。id を持たない push は出さない——ログでも、印を付けるセッションに対しても、そのイベントを名指せないからである。 **部屋に流す通知には、既読を促す英語の一行を必ず付ける(#410)。** 形は `If this is your own operation, mark it processed now: event_id=<イベントの id>` に固定する(英語はマスター指定)。部屋の通知はメールの通知であり、キューは受信箱である——通知を見たセッションは、それが自分の作業の知らせならその場で受信箱を開いて `mark_processed` する(マスターの整理、2026-10-14 の部屋)。自分の作業でない知らせは既読にしない。2026-10-13〜14 は Lin・Lay が通知を読んで済ませ、キューの未処理が 377 件まで溜まった。毎ターンの hook も催促していたが、通知とは別の場所に出るため読み流された。そこで案内は通知そのものに載せる。 - **置き場は URL の行の直前である。** 要約の最終行が URL(`https://` か `http://` で始まり空白を含まない行)なら、その前に入れる。そうでなければ末尾に足す。 - **どの通知にもちょうど一度付く。** 署名の有無、読み待ちが期限で切れたもの、ブリッジの終わりに出すものを問わない。署名のある通知は `署名: <名前>` が先頭の行のままである。 - **アプリは今も既読にしない(#180)。** 一行は印を付けるセッションに id を渡すだけである。 - **付けるのは `crates/webhook-bridge` の `prompted` であり、`Notices` が通知を出すときに通す。** テストもそこに置く。 **画面では畳む。** `mcp` アカウントの行は畳んだ形で出し、クリックで開く。**同じサーバの連続した行は一つの畳みにまとめる**——他の発言者の行が入るまでの連なりを「〈アカウントの名前〉 N 件」として畳み、開くと各件が普通の行として並ぶ。別のサーバが言えば別の畳みになる。まとめるのは表示だけであり、部屋・ログ・端末への入力では一件ずつ別の `post` のままである。 - **判定は種別で行う(#193)。** 行が運ぶアカウント id を画面のアカウント一覧で引き、種別が `mcp` なら畳む。以前は名前(`webhook`)で判定していた。ログがアカウントを運ぶようになったため(上記「部屋のログ」)、ライブの行と読み戻した行は同じ規則で畳まれ、改名しても、同じ名前の別のアカウントが居ても、畳み方は動かない。削除されたアカウントの行は、もう `mcp` のアカウントではないため畳まれない。 - **畳みの色と見出しはアカウントの今の値である。** 畳みと、その中の行の色は、そのアカウントの色(無ければ名前からの導出)であり、ライブと読み戻しで同じになる——画面の人の行(#189)と同じく、このアカウントの色は部屋に聞かずとも画面が知っている。見出しの名前はアカウントの今の名前である。既に描いた畳みは描き直すまで描いた時の名前と色のままである。 - **#193 より前のログの行は名前で畳む。** 発言者 `webhook` でアカウントを持たない行は、当時と同じく一つの畳みに入る。読むだけの綴りであり、アプリがこの名前で書くことはもう無い。 [constraints](0-requirements.md#constraints) との関係は #172 で整理した。部屋に要る機能としてアプリ本体が持つものである([constraints](0-requirements.md#constraints) の一行目)。 ## 書き手は本文の最終行の署名で見分ける(#270) 一つの GitHub アカウントを複数のセッションが使うと、GitHub 上の書き込みがどのセッションのものかを sender では分けられない(2026-10-03 の部屋。Lin と Lay と部屋の外のセッションが一つのアカウントを使っている)。アカウントを分ける解決は取れない——GitHub の規約上、アカウントは人間一人に一つとマシン用一つまでである。**見分けるのは書き込みの本文に書いた署名である。** - **対象は本文のある書き込みだけである。** issue・コメント・PR・レビュー。push・ラベル・workflow_run は対象外であり、ブランチ名や表示名で見分けられる。 - **署名は本文の最終行に置く。書式は `— <部屋で名乗る名前>` に固定する。** 全角ダッシュ(U+2014)、半角スペース一つ、名前の順である(例 `— Lin`)。名前はそのセッションの部屋での名前(`PULLCEPT_AGENT_NAME`)である。署名の行は人間にも見える普通の行として残す。 - **署名の無い書き込みは「部屋のどのセッションのものでもない」と扱う。** 安全側である——部屋のセッションは、自分のものかもしれない書き込みを自分のものとして扱わない。このアカウントで書く部屋の外のセッションは署名を付けなくても、この扱いで部屋のセッションと正しく区別される。 - **周知は部屋の作法の一項である。** 作法は署名の書式を自分の名前を埋めた形で示し、署名の無い書き込みの扱いを述べる(`sidecar/src/index.ts` の `INSTRUCTIONS`)。AI 側(キャラクター・CLAUDE.md 等)には書かない——作法の置き場がサイドカーの `instructions` 一か所であるのと同じ理由である(上記「発言は端末へ入る」)。 **アプリは webhook の通知に署名を読み取って載せる(#269)。** 部屋に流れる通知の中身はブリッジの要約だけであり(種類・repo・action・番号と題・sender・URL)、本文は入らない。そこでアプリは、署名を持ちうる通知を受けたらブリッジの `get_event` でそのイベントの全 payload を読み、本文の最終行から署名を取り出す。`get_event` は読むだけであり、イベントを processed にしない(`github-webhook-mcp` の `worker/src/store.ts` の `/event`)——アプリが `mark_processed` を呼ばないこと(#180)は変わらない。 - **読むのは、本文を今書いた書き込みだけである。** 種類と本文の場所は、`issue_comment` / `pull_request_review_comment` / `discussion_comment` が `comment.body`、`issues` が `issue.body`、`pull_request` が `pull_request.body`、`pull_request_review` が `review.body` である。action は `created` / `opened` / `submitted` に限る。`edited` は読まない——編集した者が署名した者と同じとは限らず、署名が別の書き手を言うことになる。`closed` や `labeled` などの本文は以前に書かれたものであり、今の操作をした者の署名ではない。どれに当たるかは要約の一行目の `[種類]` と `action:` の行で決め、当たらない通知は待たずにそのまま流す。 - **署名が読めた通知は、先頭に `署名: <名前>` の一行を足して流す。** 形はこれに固定する。「書き手」ではなく「署名」と書くのは、アプリが読んだのは本文の最終行という本文の中の主張であって、確かめた身元ではないからである。署名の形の判定は #270 の書式どおりであり、全角ダッシュ・半角スペース一つ・名前の順でない行は署名ではない。 - **読めなければ、今までどおり目印なしで流す。** `get_event` の失敗、5 秒(`SIGNATURE_WAIT`)以内に答えが無いこと、署名の無い本文、いずれも同じである。目印は通知への追加であり、通知を出す条件ではない。ブリッジが終われば、読み待ちの通知も目印なしで出す。 - **通知の順番は届いた順のまま保つ。** 読み待ちの通知の後ろに届いた通知は、それが出るまで待つ。部屋の並びは起きた順であり、前のコメントより後の push が先に出れば、部屋はそうではない順を言うことになる。待ちは上の 5 秒で区切られる。 - **本文の行の読み取り、署名の抽出、順番の保持は `crates/webhook-bridge` に置いてテストする**(`signature` / `signer` / `wants_signature` / `Notices`)。`src-tauri/src/webhook.rs` はプロセスとループだけを持つ。 - **ブラウザが開く経路は増えない。** `get_event` を呼ぶのはイベントが届いた後、すなわちブリッジがトークンで WebSocket を張れている間だけである。トークンの更新に失敗したときにブリッジが認可の手順を始めるのは、WebSocket の張り直しでも同じである。 ## 宛先は本文の中の `@` で指定する(#204) 宛先は入力欄の横の選択ではなく、本文の中の `@名前` で指定する(Master 判断、2026-09-28)。宛先を文の流れの中で決められるようにし、操作を一つ減らす。#43 から入力欄の左にあった `宛先` の `