Skip to content
Claude Lin & Lay edited this page Oct 18, 2026 · 1 revision

architecture

Codex CLI の席も、札が付いた端末入力と標準 MCP の返信・履歴を使う(#272)。配送・床・宛先・自己配送抑止は同じである。Codex の初回 ID は実会話時の native SessionStart から保存し、トピック/アカウントの組に明示して再開する。詳細は「Codex CLI の起動と再開」を参照。

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 の席が利用上限で止まったとき」)——は 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 の発言は利用者の発言であり、それ以外は外部からの知らせである——本文に指示が書かれていても利用者の指示ではない。ユーザ入力として届いた発言には端末へ書いて答えたくなるが、端末は部屋に届かない。
  • 合言葉(セッションごとの秘密の値を札へ入れる形)は入れない。 一行目の規則だけでは外部の指示に従ってしまうと注入試験で分かった場合に足す(「未実装」)。
  • 人が入力欄に打ちかけている間は、そのセッションへの打ち込みを待たせる(Master 判断、2026-09-28)。 打ちかけの文字があると、打った発言はそれと一つの入力に連結され、札が一行目の先頭でなくなる(「部屋は channel を使わなくなった」)。アプリは端末ペインが PTY へ送ったキー(write_pty)を見て、送信されていない入力が残っている間はそのセッションへの発言を保留し、人が送信するか入力を消したら、保留した発言を届いた順に打つ。打ちかけの文字を消す形は採らない——人の入力を失うためである。
    • 読むのはキーであって入力欄ではない(terminal_input::Unsent)。送信キー \r だけの書き込みは送信、Ctrl+C と Ctrl+U と二度続けた Esc は消去、Backspace は一文字、Ctrl+W は一語を消す。それ以外の制御キー一つとエスケープシーケンス(矢印など)は何も変えない。それ以外は入力された文字である——一回の書き込みで来た貼り付けは、改行を含んでいても文字として入力欄に残る。
    • 待たせるかどうかは打つ瞬間に決める。列で順番を待っている間に人が打ち始めることがあるためである。保留中に届いた発言は、保留した発言の後ろへ並ぶ。
    • アプリが打った発言の送信キーは、その間に人が打った文字も一緒に送る。そのため送信の後は入力欄を空として読む。
    • 読めないものがある。上矢印で呼び戻した履歴は、何も打たれていないのに入力欄を埋める。画面に出た確認への答えのキーは、ここでは文字の入力に見える。前者はその行へ発言が連結され、後者は次の送信か消去まで発言が待つ(「受容したトレードオフ」)。
  • 許可の画面で止まっている Claude Code の席には打たない(#346)。 打った発言の最後の送信キーが許可の画面に届き、既定の「はい」を誰も選ばないまま選ぶためである。席の hook がどれかのエージェントの許可待ちを言っている間、その席への発言はアプリが預かり、待ちが終わってから届いた順に一度だけ打つ。決め方は「許可待ちの席には投稿を打ち込まない」。
  • 打ち方は端末ペイン自身の貼り付けと 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 を使わなくなった」)。
  • トピックの復帰で退けた「履歴を PTY へ流し込む力業」とは別のものである(下記「トピック」)。流すのは過去の発言ではなく、いま言われたことであり、一件ずつである。
  • アプリは端末へ書くが、端末の出力は読まない。 部屋に並ぶ発言の出所は変わらない(「診断面」)。端末ペインが送るキーは、打ちかけの有無のためにだけ読む。

添付は保存したファイルのパスで届く(#223)

画面の人は発言にファイルや画像を添付できる(入力欄の形は「入力欄」)。発言は端末へ文字として打ち込まれるため(上記「発言は端末へ入る」)、ファイルの中身そのものは流せない。アプリは添付をトピックの下へ保存し、発言の本文にその絶対パスを書いて送る。 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)。 段のパスは画面には出さず、画像は縮小した絵、それ以外は名前と種類の札で出す(「添付は会話の中に絵と札で出る」)。読み替えるのは画面だけであり、本文・ログ・端末へ届く文字は上記の形のまま変えない。段を読み替えるのは、段のパスがすべてこの部屋の添付フォルダ(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 が同じ形で読めるかも未検証である(「未実装」)。

部屋プロトコル(アプリ ⇔ サイドカー)

部屋ソケットはアプリが 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、「セッションは自分の様子をステータスラインで知らせる」)。
  • /hooks/activity/<出来事>/<トピック>/<アカウント> = Claude Code の席が、ツール・許可待ち・サブエージェントの始まりと終わりを http 型の hook で知らせてくる(#331、「Claude Code の席は自分の様子を hook で知らせる」)。アプリは本文のいくつかの欄だけを読んで席ごとの様子を変え、すぐ {} を返す。部屋の席・床・ログには触れない。

第二の待ち受けを開く形は採らない。 起動の行は既に部屋のポートを運んでおり、アドレスが増えれば起動のたびに配り、持ち、返す対象が一つ増える。一本のまま区別できる以上、増やす理由が無い。

第二のパスも持たない(#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 で着いたアカウントの組で引く——ほかの席の数字を返す道が無い(下記「席が自分の利用上限を読む」)。

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、「セッションは自分以外の部屋の登録を起動しない」)。

運ぶ理由は画面側にある。画面はアカウント一覧と部屋の名簿を突き合わせる必要があり、id が無い間はその突き合わせを名前でしか書けなかった。名前で結ぶ形は #40 と #53 が却下しているため、画面は名簿と端末の列という二つのリストに割れていた(#57)。id が載ったことで、その二つは一つのリストになる(#59、「参加者パネル」)。

アカウント 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 かではない(色の梯子がこれを読む)。ただし行を左右どちらに寄せるかは、読み戻した行にも同じ規則を当てるため名前で決める(「画面の人の発言は右に寄せる」)。イベントはどの部屋の発言かを topic_id で持ち、名簿のイベント(room-participants)も同じく部屋を名指して届く。画面に出ていないトピックも話し続けるため、画面はその値で自分の出している部屋の分だけを描く(#141)。

サイドカーの起動形

.mcp.json に書く起動コマンドは、絶対パスのみで構成し、名前による解決を含めない。

  • command = node
  • args = [<pullcept>/node_modules/tsx/dist/cli.mjs, <sidecar entry>]

サイドカーを 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字以内に保つ。選択契約は 起動とアカウント を参照する。

  • 道具を呼ぶときにだけ要るものは、その道具の説明へ移した。 説明は呼ぶ瞬間に読まれる。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> であり、前半の文言は変えていない。id はサイドカーが randomUUID() で作って post に載せ、部屋がそのまま保持して他の参加者の札へ載せるものである(room.rs の deliver)。自分の発言は戻らないため、他の参加者が引く id をセッションが知る口はこの返答だけである。say_to_room の説明は、自分の発言を証拠として後から指すときにこの id を使えることを述べる(#273 で作法から移した)。

  • 先に誰かが答えていたら、その発言を読んでから自分の発言を決める。送る直前にも届いている発言をもう一度見て、言おうとしていたことが既に言われていたら送らず、足りないことがあるときだけ足す。全体宛の問いに全員が答える必要はない。宛先なしの問いに二人の参加者が同内容を二重に返した実測(2026-08-22)に対し、口頭で「順番を待ってその会話を見てから話し始める」と伝えたところ挙動は成立した。足りていなかったのは参加者の能力ではなく、これが部屋の規律に書かれていないことである。

  • 順番は沈黙とは別軸である。宛先の節が扱うのは誰に向いた発言かであり、こちらは先に答えが出たときにどうするかである。混ぜて「答えなくてよい」だけを渡すと、全体宛の発言に誰も答えない状態を招く。書き方は「黙れ」ではなく「見てから決める」に寄せる。

  • GitHub に本文つきで書き込むときは、本文の最終行に — <自分の名前> の署名を置き、署名の無い書き込みは部屋のどのセッションのものでもないと扱う(#270、下記「書き手は本文の最終行の署名で見分ける」)。

  • 作法が「見よ」と言えるのは届いた発言までである。参加者は床の状態——誰が今組み立てているか——を覗けない。届いてはいる。読めるのは次の境界からである。 組み立て中に届いた発言は端末へ打ち終わっていても、参加者の文脈へ入るのは CLI が順番待ちの入力を渡す位置——次の tool 結果が返る位置——であり、そこに何が載っているかは相手の送信時刻と自分の境界位置の関係だけで決まる。したがって「送信直前に読み直せる」は無条件には成立しない。作法だけでは閉じない理由はここにあり、閉じるのは次節の床である(#47)。

床(同時発話の順序付け)

作法だけでは同時発火が閉じないことは実測で確定している(2026-08-23、部屋の participant による一次観測)。#49 の作法を入れた後も、宛先なしの問いに二人が同内容を二重に返す事象が二度起きた。二度めの二人は、その数分前に上記の機構を解明した当人である。理解は防止に効かなかった。

失敗は二型ある。

  • 型 1 — 境界は在ったが、相手の着弾より前だった。非決定的である。
  • 型 2 — 境界がそもそも無い。say_to_room 一回きりのターン(ただ答えるだけの返信)では、送信そのものが最初の境界であり、手前に読み直す隙が構造的に無い。部屋で最も頻度の高い形が、最も確実に取りこぼす。

射程: ゼロ tool のターンでも、ターン開始前に載っていた発言は読める。読めないのは組み立て中の着弾に限る。

閉じるのは部屋である。宛先(#31)はフィルタであって直列化ではなく、順番の作法(#49)は参加者が握れないものを前提にしている。どちらも同時発火を閉じない。

  • 送り手が last_seen を申告する。 値は、その参加者が実際に見たいちばん新しい発言の message_id である。部屋は自分が各接続へ何を配ったかを知っているが、配達は読了ではない。配ったものが送り手の文脈へ入ったかどうかは境界の位置で決まり、部屋からは見えない。実際に何を見たかを知っているのは送り手だけである。
  • 判定と刻印は既存ロックの内側で、一つの操作として行う。 deliver() は単一経路であり、Arc<Mutex<RoomInner>> は既存である。二人が同時に呼んでも順序が付き、先に通った側の発言は、後の側の判定が読む床に既に載っている。判定と配送が割り込める形では両者が同じ空の床を読むため、機構として成立しない。
  • 見ていない発言があるなら配らない。 戻り値でその発言群を返す。受理して事後に知らせる形は採らない。分散システムの 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 が操作。「実機で確認済み」)——描けなかった原因として並べた二つのうち、実機で当たったのは 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 が置き換える。トピックは会話の一区画ではなく、会話が行われる器である——開き直せばその会話が部屋へ戻り、続きが話せる。

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 はセッションも名乗れる。画面はこれでアプリの発言の丸印にアプリのアイコンを描き(「会話面の見た目」)、読み戻した行がライブの行と同じに描かれるには、行がそれを運んでいる必要がある。アプリの知らせは席を取らず名簿にも載らない(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。「トピックの一覧」)。

発言が無くても、席がそこで始まれば索引に入る。 セッションを始めるのは意図的な行為であり、その 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 を名指す種別はその一行を作法として持ち、名指さない種別ではアカウントの欄が持つ(「再開コマンド」)。

新規セッションへ渡す id も同じ形である。 どのフラグが id を運ぶかは CLI ごとの問いであり、再開の一行が答えているのと同じ問いである——だから答えるのは同じ種別であり、Claude Code では --session-id {session_id} が起動する行へ載る。作法を持たない種別では、その置換子を起動オプションへ書くことで配る(「起動オプション」)。どちらも無ければ id を配らない——それは resume を持たない CLI が恒常的に置かれる状態であり、失敗ではない。

判定は「このトピックがこのアカウントのセッションを持つか」で行う。 「アカウントが resume を持つか」ではない。記録と再開コマンドが揃い、かつその会話が実在するときだけ resume であり(三つめは #131、下記「再開の線は、その会話が実在するときだけ選ぶ」)、どれかが欠ければ新規起動である。起動チェック(互換しないフラグ、--settings の二重宣言)は解決後の行に対して走る——アカウントの行を見て別の行を spawn する形にしない。

再開できない席があってもトピックは開く。 復帰できる席は復帰させ、できない席は新規起動として着席する。読み出しがあるため、復帰できなかった席も自分で辿れる。トピックが開かない形は取らない。resume だったかどうかは画面の状況行で言う——黙って同じ言葉で済ませると、戻ってきたのかどうかを人が判別できない。

戻れなかった再開先は、そのトピックから外す(#127)。 セッション id は spawn が返った直後に記録される。プロセスが立ったことは会話ができたことではなく、起動直後の確認プロンプトで止めたまま終えた回は、会話の無い id を残す。以後その(アカウント, トピック)の組は押すたびに再開の線へ入り、同じように失敗する——再開の失敗から通常起動へ落ちる経路は無いため、記録を消すかトピックを消すまで直らない。再開の線で起動したセッションが、部屋の名簿に一度も現れないまま自分で終了したら、そのトピックからその id の記録を外す。

判定に CLI の出力は読まない。 文言の一致を条件にすれば、CLI の版で言い回しが変われば黙って効かなくなる。「未実装」が「CLI の出力から状態を読む形」として保留している軸にも踏み込まない。読むのは既に在る三つだけである:起動が再開の線であったこと(席が持つ。席はアプリに在り、画面の再読み込みを越える)、名簿がそのアカウントを一度も運ばなかったこと(行が「起動中」を描いているのと同じ観測であり、「参加者パネル」が持つ「走っている」の定義そのものである)、そのセッションが自分で終わったこと(終了イベントが「アプリが止めた終了」の印を運んだかどうか。コードの値ではない——印は止めた側が付ける。「終了は、頼んだものと頼んでいないものを分けて言う」)。こちらから止めたものは数えない。 行の ❌、トピックの削除、アプリの終了はいずれも人が頼んだ終わりであり、再開の失敗を意味しない。確認プロンプトは起動した CLI を数分のあいだ部屋の外に留めるため(「未実装」の #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 の作法を持つ」)。スラッグは英数字でない文字を一つずつ - へ畳んだものであり、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)とアプリの終了だけである。

部屋は、トピックを開いたときに作り、削除したときに捨てる。 起動時のトピック、「新規」、一覧からの選択のどれでも、この実行でまだ部屋が無ければ作る。静かになったことを理由には捨てない——そこでセッションが走っているかもしれない。

画面は「今見ている部屋」を持つ。 起動と一覧の選択はそこへ入る。起動はどのトピックへ入るかを画面が名指す。 ▶ を押した時点で画面に出ていたトピックであり、端末はそのトピックの下に作られる(「診断面」)。アプリが後から開いているトピックを読む形にすると、起動の間に画面が移れば、セッションは一方のトピック、端末はもう一方に分かれる。

部屋は過去の発言を 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、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)から来る(「セッションは自分の様子をステータスラインで知らせる」「Codex の席は自分の様子を rollout で知らせる」)。アプリは届いた値を席(トピック, アカウント)ごとに一つ持つ(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)。アプリが道具を呼ぶのはイベントが届いた後だけであり(下記「書き手は本文の最終行の署名で見分ける」の 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 の発言が残る(受容。「受容したトレードオフ」)。
  • 発言者はそのサーバの mcp アカウントであり、席は取らない(#193)。 アプリが起動するローカル MCP サーバは一つずつ種別 mcp のアカウントを持ち(「アカウント」)、通知はそのアカウントの発言として出る——名前が speaker に、色とアカウント id が発言に載る。以前は発言者 webhook(席も名簿も持たない貼り紙)だった。発言者が種別を持つことで、端末へ届く発言の札にロールを載せられるようになる(#195)。名前と色は通知が届いた時点のアカウントから読むため、アカウントの窓で変えたものはサーバを立て直さずに次の通知から効く。席は取らないことは変えていない:サーバは答える手段を持たないため、名簿に載らず、宛先にも出ない(#32 の境界判定)。origin はアプリが一つ採番し、どの接続のものでもないため端末への入力は誰も飛ばさない。部屋のセッション全員の端末へ入り、画面が描く。どのサーバの発言かは origin ではなく発言に載ったアカウントが言う。
  • 床は通知に何も問わない。 通知は部屋で言われたことを見て組み立てたものではないため、見落としうるものが無い。いま席を取った参加者が立つ位置(床の現在位置)に立ち、拒否されない。
  • 振り分けない。 誰の話かを読んで宛先を付けることはしない(#32)。to は付かない。
  • 最初の発言は最初の発言である。 トピックの最初の発言が通知であれば、トピックの名前はその通知から付く。発言者で区別しないのは #39 の線であり、この経路のために命名へ分岐を足していない。

AI セッションが座っている部屋が一つも無いときは、どこにも入れない(Master 判断、2026-09-27)。 イベントは github-webhook-mcp 側に未処理のまま残る——channel で配られたイベントも mark_processed されるまでは未処理であることは、ソースの読みで確かめてある(#169 premise)。後から席に着いたセッションにも遡って配らない。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 との関係は #172 で整理した。部屋に要る機能としてアプリ本体が持つものである(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 から入力欄の左にあった 宛先 の <select> は撤去した。

  • @ で候補を出して選ぶ。 入力欄で @ を打つと、部屋に居る参加者の候補が入力欄の上に出る。@ の後に打った文字で、名前の前方一致で絞る(大文字と小文字は区別しない)。↑ ↓ で移り、Enter か Tab で選び、Esc で閉じる。クリックでも選べる。Shift+Enter は候補が出ていても改行のままである。IME が変換している間のキーは変換のものであり、候補は取らない。
  • @ は本文の先頭か空白の後のものだけである。 単語の途中の @(a@b)では出さない。全角の @ も受ける——かな入力の IME が同じキーで打つ文字だからである。@ の後に改行が入れば、その @ の候補は閉じる。Esc で閉じた @ は、その後に打ち続けても開き直さない。別の @ を打てば開く。
  • 候補の出所は部屋の名簿だけである(「参加者パネル」)。画面の人自身は出ず、同じ名前は一つにまとめる。to が運ぶのは名前であり、同じ名前の二人は一つの宛先で両方が宛てられるからである。開いている候補は名簿に追随する。
  • 候補は入力の補助である。 選ぶと本文に @名前 が入るだけで、参加者には結び付けない。宛先を決めるのは送った本文の @名前 であり、部屋が送信の時点で部屋の名前と照合して to へ移す(#206。上記「部屋プロトコル」)。手で打った @名前 も、候補から選んだ @名前 も同じに扱われる。#204 では選んだ時点で結び付け、本文からは宛先を読まなかったが、実機で (1) 手で打った @名前 が宛先にならない、(2) @名前 が本文と to の両方に出る、の二点が気になったため改めた(Master 判断、2026-09-28)。画面は照合をしない。規則は部屋の一か所にあり、画面の発言とセッションの発言に同じものが当たる。
  • 宛先は複数にできる。 書いた順に並び、同じ名前は一度だけ送る。本文に参加者の @名前 が一つも無ければ部屋全体宛であり、以前の「全体」と同じである。
  • 本文から @名前 は消える。 宛先は札の to と行の見出しの → 名前 が運び(上記「部屋の作法」)、本文には残さない。部屋の誰にも当たらない @… は本文に残る。
  • ロールでのメンション(@claude_code など)は持たない(Master 判断、2026-09-28)。要るとわかってから足す。
  • 送れなかった本文は、書いたとおりに戻る。 床の拒否でもコマンドの失敗でも、入力欄へ戻すのは @名前 を消す前の本文である。宛先は本文の中にあるため、それで宛先も戻る。
  • 行の見出しは宛先を並べて出す。 → Claude Lay、Claude Lin の形であり、色は発言者の色のままである。
  • 候補の一覧は操作であり、会話の文字サイズには従わない。 UI の大きさ(#66)に従う(「会話面の文字サイズ」)。

設定(#172)

アプリは設定メニューを持つ。タイトルバーの「設定」が同一 webview 内のダイアログを開く(アカウントのモーダルと同じ理由。「アカウントの窓は同一 webview 内のモーダルである」)。左に項目の一覧、右にその項目を置く Claude Desktop の設定の形である。

項目は「表示」の一つである(#194)。 画面全体の見え方の設定を置く場所であり、寸法の三軸——会話の文字サイズ、端末の文字サイズ、UI の大きさ——を並べる(Master 判断、2026-09-28。下記「表示」)。#172 で置いたローカル MCP サーバは、各サーバの mcp アカウントの窓へ移した(#193。下記「ローカル MCP サーバ」)。項目が空だった間にその移り先を言っていた一行は、項目が入ったことで外した。

表示(#194)

三つの寸法を一つの面に並べる。 会話の文字サイズ(#60、「会話面の文字サイズ」)、端末の文字サイズ(#68、「診断面」)、UI の大きさ(#66)である。三つは独立した軸のままであり、互いに相対で乗らない。三つの操作を一か所に置くだけで、軸を束ねない。

画面上の操作はここだけである(#209、Master 判断、2026-09-28)。 #194 はタイトルバーの会話の文字サイズのピッカーと端末面のヘッダの端末の文字サイズのピッカーを残し、設定メニューと同じ値を指させていた。同じ値を変える操作が画面に二つ並ぶ形であり、#209 でヘッダの二つを外した。値・保存先(localStorage)・梯子は変わらず、既に保存された値はそのまま効く。会話の文字サイズの Ctrl + = / - / 0 は残す。画面上の要素ではなく、webview 自身のズームを止める役目も持つためである(「会話面の文字サイズ」)。キーで変えた値は設定メニューのピッカーにも出る。

変えた時点で効く。 ダイアログに決定と取消は無い。「閉じる」だけを持つ。

UI の大きさは、会話面と端末を除いた画面の寸法である(#66)。 参加者パネル、トピックの一覧、タイトルバー、入力欄の操作(送信 と @ で開く宛先の候補)、状態行、端末面のヘッダ、アカウントの窓や設定メニューなどのダイアログがこれに従う。会話面の本文(発言者名・時刻・行の最大幅を含む)と入力欄の本文、端末の文字は従わない。

  • アプリ側で持つ。webview 自身のズームは使わない。 webview のズームは画面を丸ごと巻き込み、会話面と端末も動かす。それは三軸の独立を壊す(#66 の未決の一つ目)。
  • 仕組みは :root の文字サイズである。 会話面と端末を除く画面は rem で書かれているため、:root に倍率を一つ載せれば(--ui-scale と、それを掛けた font-size)、個々の面を名指さずに全体が動く。会話面は自分の寸法をこの倍率で割り戻して持つ(--room-font-size は calc(<段>rem / var(--ui-scale)))ため、UI の倍率がいくつでも会話の文字の実寸は会話の段だけで決まる。端末は px で寸法を持ち、:root の文字サイズが届かない。上から配って打ち消す形ではあるが、打ち消すのは会話面の一箇所であり、そこは既に寸法の置き場所として決まっている要素(#room と入力欄の本文 #input(#354 まではテキストエリア)、#260 からは丸印のために #participants も)である。
  • 端末の桁数と行数は、UI の大きさでも動きうる。 文字は動かないが、ヘッダやパネルが大きくなれば端末の枠が縮む。枠の寸法の変化は端末の枠の ResizeObserver が拾い、fit して見えている端末のセッションへ伝える。枠の寸法が他の理由で変わったとき(窓のリサイズ、パネルの開閉)と同じ経路である。
  • 段は離散である。 80% から 150% までの梯子(0.8 / 0.9 / 1 / 1.1 / 1.2 / 1.3 / 1.4 / 1.5)で、両端が上下限である。既定より上に段を多く取る。#66 が扱うのは「画面が遠い、字が小さい」であり、使われるのは上方向である。上端を 150% で止めるのはパネルが一緒に大きくなるためである——パネルは rem で幅を持ち、会話面はその間の残りを取る。150% で両方のパネルを開けば、900px の窓では会話面がほぼ残らない(2026-09-28、AI が模した画面で計測)。それより上は、主に会話面を失う手段になる。
  • 表示は既定に対する百分率である。 会話の文字サイズと同じ理由である。
  • 保持は localStorage(pullcept.ui-scale)である。 会話と端末の二軸と同じく、画面の設定であって参加者の属性ではない。梯子に無い値が保存されていた場合は既定へ戻す(#60 と同じ理由)。
  • 既定は 100% である。 :root の文字サイズは 100%、つまりブラウザ既定の 16px になり、これまで何も書いていなかったときと同じ寸法である。設定していない画面は変わらない。

ローカル MCP サーバ

アプリ本体が起動するローカル MCP サーバは、一つずつが種別 mcp のアカウントである(#193)。 その編集・状態・ログは、設定メニューではなく、そのアカウントの窓(参加者パネルの行のメニューの「編集」)にある。窓の「基本」の項目は他のアカウントと同じ名前と色であり、「サーバ」の項目がサーバの節——実行状態と再起動、コマンド・引数・環境変数、保存、ログ、設定ファイルのパスと「設定ファイルを開く」——である(項目の分け方は #304。「アカウントの窓は項目を横に並べる」)。一覧はパネルの MCP の見出しが担う(「参加者パネル」)。

  • 保存先は mcp-servers.json のまま残す(Master 判断、2026-09-28)。 何を走らせるか(command / args / env)はこのファイルのエントリが持ち、誰として話すか(id・名前・色)は config.json のアカウントが持つ。アカウントはエントリの名前を server として持ち、一つのアカウントが一つのエントリに対応する。どちらのファイルも、もう一方の半分の写しを持たない。
  • エントリにアカウントが無ければ、config を読むときに作る(mcp_servers::migrate_accounts)。 既存の mcp-servers.json からの移行はこれであり、後から手でファイルへ足したサーバも次に読んだときにアカウントになる。作るのは id(mcp-<エントリの名前>)・エントリの名前をそのまま使った名前・宣言の無い色だけであり、既に在るアカウントの名前と色には触れない。id をエントリの名前から取るのは、アカウントが保存される前にサーバが話しても、画面が後で並べるのと同じアカウントとして話すためである。
  • 作るときにだけ種別 MCP サーバ を選べる(#200、Master 要望 2026-09-28)。 「+」の窓で種別に MCP サーバ を選ぶと、窓の横のメニューに「サーバ」の項目が出て、そこにサーバの欄(コマンド・引数・環境変数)が出る。まだサーバが無いため、実行状態・再起動・保存・ログは出さない。決定で、アプリが mcp-servers.json に新しいエントリを書き、アカウントを作って保存し、そのサーバを起動する。エントリの書き込みを先にし、アカウントの保存を起動より先にする——起動したサーバの最初の発言が、窓で決めた名前と色で出るためである。欄の読み方と拒否は保存と同じであり(下記)、拒否されれば何も書かずに窓が理由を出す。起動に失敗してもアカウントは作られ、その窓が状態とログと「起動」を持つ。
  • エントリの名前はアカウントの名前から決める(#200、mcp_servers::new_entry_name)。 名前の空白は - にする(空白だけの名前は mcp-server)。使われている名前には付けず、-2・-3 と数え上げる。使われているとは、ファイルに在るエントリの名前、エントリの消えた後も mcp アカウントが server として持っている名前(その名前で新しいエントリを書けば、古いアカウントとして話してしまう)、そしてアカウントに与える id(mcp-<エントリの名前>)が既にどれかのアカウントの id であること、の三つである。id をエントリの名前から取るのは、config が読むときに作るアカウントと同じ形にするためである。
  • 種別は作った後には変えられない。 窓で作ったものも、ファイルに手で足したエントリにアプリが作ったものも同じである。既に在るアカウントの窓では、MCP サーバ は mcp アカウントの窓にだけ出て、そこでは種別を選べない。他の種別からこれになることも、これから他の種別になることも無い。
  • 削除はエントリを外し、サーバを止める(#200)。 画面から作れる以上、画面から消せる。削除の二度押しは他の種別と同じであり、一度目の押下で、ファイルからそのサーバを外して動いていれば止めることを言う。二度目で、アプリがファイルからそのエントリだけを外し(他のエントリと他のキーは手で書かれたまま残す、mcp_servers::remove_server)、走っているサーバを止めて、そのサーバについて持っていた状態とログを捨てる。その後にアカウントを消す。順序はエントリが先である——ファイルに残ったエントリは、次に config を読んだときにアカウントを作り直される。ファイルを書けなければアカウントも消さずに理由を出す。ファイルが無いときは書き出さない。#193 の間は、ファイルがそのサーバを持っている間の削除を断り、先にファイルから手で消すよう求めていた。
  • ファイルを手で書き換える経路は今までどおり残る。 手で消したエントリのアカウントは、そのサーバが部屋で言ったことの話し手として残り、窓はそう言う。そのアカウントの削除はファイルに何もしない(外すものが無い)。
  • 決定はサーバの欄も保存する。 欄を書き換えて保存しないまま決定すれば、欄も保存してから閉じる——窓が閉じたことで書いたものを失わない。保存できない欄であれば閉じずに理由を出す。保存と再起動は #172 のとおり押した時点でファイルとサーバに効き、取消は最後の保存より前へは戻さない。

以下はサーバの節について #172 で決めたことであり、置き場が窓へ移っても変わらない。アプリ本体が自分のクライアントとして起動する MCP サーバの設定である(Master 判断、2026-09-27)。セッションが読み込む MCP サーバ(.mcp.json など)はここに出ない。形は Claude Desktop の「ローカル MCP サーバー」画面に倣う——実行状態、コマンド・引数・環境変数の表示と編集、ログ、設定ファイルへのリンクである。

  • 設定は app_data_dir()/mcp-servers.json に持つ。 config.json とは別のファイルであり、形は Desktop の claude_desktop_config.json の mcpServers と同じ(名前 → command / args / env)である。config.json はアカウントを保存するたびに丸ごと書き直されるが、こちらは画面のリンクから開いて手で書き換えるファイルだからである。「設定ファイルを開く」はこのファイルを OS の既定のアプリで開く。
  • ファイルが無いときは、同梱のブリッジ一件を既定として書き出す。 名前は github-webhook-mcp、command は node、args はサイドカーの探索が見つけた webhook-bridge.mjs の絶対パス、env は空である。環境変数を足さないため、ブリッジは自分の既定(WEBHOOK_WORKER_URL が無ければ https://github-webhook.smgjp.com)で動き、挙動は #172 より前と変わらない。探索がスクリプトを見つけられなければファイルは書かない——在りもしないパスを一度書けば、以後それはその人が選んだ値として読まれる。
  • 起動はアプリの起動時に、ファイルに並ぶすべてのサーバについて行う。 各サーバは上記「webhook の通知」の受信ループが読む——アプリが持つクライアントは一つであり、channel push を部屋へ出す(mark_processed はしない。#180)。ファイルに別のサーバを足せば、それも同じように扱われる。プロセスはシェルを挟まずに command と args から立て(.cmd のようなスクリプトは拡張子まで書く)、env はアプリ自身の環境に足す。置き換えはしない(Desktop と同じ)。
  • 編集は保存し、再起動で効く。 「保存」はファイルの当該エントリの command / args / env だけを書き換え、他の欄・他のエントリ・他のキーは手で書かれたまま残す(crates/mcp-servers の set_server)。走っているサーバは保存では止めない。ファイルの中身が走っているサーバの起動元と違う間は、画面がそう言う。「再起動」はそのサーバの今の実行を終わらせ、ファイルから読み直して立てる——保存していない欄の中身では立てない。保存していない変更がある間に押せば、先に保存するよう言う。ファイルから消えたサーバの行では同じボタンが「停止」になり、止めるだけで立て直さない。
  • 自動では立て直さない。 終わったサーバは、再起動を押すかアプリを起動し直すまで終わったままである。上記「webhook の通知」の「ブリッジが終わっても起動し直さない」と同じ理由である。
  • 欄はテキストであり、読むのはアプリの側である。 引数は一行一つであり、行を分割しないため空白を含むパスもそのまま一つになる。環境変数は 名前=値 を一行ずつ書き、最初の = で分ける。値を丸ごと囲む一対の引用符は外す(#165 と同じ)。= の無い行・名前の無い行・名前に空白を含む行・同じ名前の二度書きは、行番号を付けて保存を拒む。読み方は crates/mcp-servers に一つだけ置き、画面は描かれたものを出して打たれたものを返すだけである(parse_launch_options と同じ理由)。
  • 環境変数の値は書かれたまま保存する。 Desktop が自分の設定ファイルでそうしているのと同じ形である。アカウントの環境変数(#163)は DPAPI で封じるが、あちらはトークンを載せる前提の欄であり、こちらの既定の用途は接続先の URL である。
  • ログは、そのサーバの標準エラーをアプリが保持して出す。 一行ごとに届いた時刻を付け、サーバごとに直近 1000 行を持つ。再起動をまたいで残す——前の実行が何を言って終わったかが、次の実行が立った後も読める。アプリ自身がそのサーバについて言うこと(起動の一行、どの部屋にも出せなかったイベント、終わった理由)も同じログへ入る。同じ行はこれまでどおりアプリの標準エラーにも出る。
  • 状態は、起動中(initialize の答え待ち)・実行中・終了(実行中だった後にプロセスが終わった)・失敗(実行中に至らなかった)・停止(ファイルから消えて止めた)である。 この実行で一度も立てていないサーバは「未起動」と出る。

MCP サーバの実装方式

参照実装は TypeScript / Node、本リポジトリは Rust + TypeScript であるため二択となる。

  • (a) Rust へ移植しアプリ本体へ内蔵する。プロセスは 1 つで済むが、参照実装は書き直しとなり、channel のプレビュー仕様が動くたびに Rust 側の改修が要る。
  • (b) Node のサイドカーとして同梱する(採用)。 実証済みの実装をほぼそのまま使え、仕様追随が軽い。プロセスは増える。

疎通検証自体を Node の最小サーバで通しており、実測の裏付けがある側を採る。

再利用範囲

PTY 層と config 層は liplus-desktop から移植済みであり、premise により必要であるため再利用する。

liplus-desktop の stream_parser.rs および spawn_stream_pty / spawn_stream_pipe は引き継がない。「CLI 出力をパースして channel push を検出する」という、本設計とは逆向きのモデルに基づくためである。本設計では CLI の出力パースを会話の情報源としない。

ニックネームと元の名前(#383)

この文書の「宛先の名前」「@名前」は、設定された nickname(前後空白を除く)を意味する。未設定時だけ name にフォールバックする。設定済みの元の名前を別名として照合しない。宛先候補は名簿だけから作り、同じ呼称の候補は一つにまとめ「同名の全参加者宛て」と明示する。従来の衝突契約を維持し、一つの呼称はその呼称の全参加者を宛てる。一人を選んだり、別の呼称へ自動的に変えたりしない。名簿にない @文字列は本文に残り、明示 to の不在呼称は誰も指名しない。

名簿の name・投稿の speaker・札の from は宛先呼称である。任意の full_name は投稿時の元の名前のスナップショットで、AI の認識用であり宛先に使わない。チャット欄の話し手は新着・履歴とも投稿時の speaker だけを表示し、元の名前を併記しない(#391)。端末の札・MCP の履歴と未読の返却は full_name を保持する。to は従来どおり呼称文字列を保存し、後の改名で古い投稿の宛先や話し手を変更しない。既存ログに full_name がなくても読め、名前を一度だけ表示する。

稼働席の改名は全 topic の名簿へ反映し、接続と floor の既読位置は維持する。再接続は保存設定の呼称を採用する。sidecar は identity フレームで現在の呼称と元の名前を受け、initialize は標準 MCP SDK の処理で起動時の呼称・元の名前を示す。tools/list・各 tool 結果に現在の自己情報を出す。宛先指定のある投稿では、各端末への札の you / your_full_name は受け手の現在の対応を示し、起動時の自己呼称を更新する。部屋全体への投稿(to 欠落または空)では両項目を省く。from / full_name は話し手の対応であり、you は受け手である。制限中に保持する投稿では話し手の両表記を保持済み speaker 文字列にまとめる。追加情報は任意項目で、未知フィールド・フレームを無視する既存互換方針を使う。

投稿結果の利用量フィードバック(#385)

say_to_room の返り値には自己情報に続いて現在時刻(現地時刻と UTC 差)、本人の5時間・週間枠の使用率とリセット時刻、コンテキスト使用率を添える。my_usage と同じ読み出し・整形を使い、各値の受信時刻と経過時間を示して最後の報告と明記する。窓・リセット・context の未取得は not reported、読み出し不調は unknown とする。context は有効な Claude status 報告か Codex の token_count の info から記録し、画面への再送では受信時刻を進めない。context は新しい起動時に捨て、前のセッションの値を引き継がない。枠の値は従来どおり受信時刻つきで保持する。

投稿結果の判定後に利用量を読み、追加の待ちは最大1.5秒とする。成功・拒否・失敗・未確認の結果と last_seen、保持する下書きは変えない。利用量の取得失敗はその追記だけに表し、投稿の isError を変えない。読み出しは本人の接続だけを対象とし、本文・床・ログには追加しない。現在時刻は各呼び出しの返却時に計算し直す。

AI からのセッション操作(#415)

認証済みの稼働中アカウントの参加者は、自分と他の席の開始・終了・再起動を依頼できる。呼び出しごとの人間の確認は挟まず、人間の画面の開始・終了は引き続き使える。部屋トークンによる接続認証に加え、アプリが各起動へ環境変数で渡した操作 capability を WebSocket の handshake で照合する。Claude/generic CLI も対象で、hello の account/topic 自己申告だけでは操作権限や依頼者帰属を認めない。capability の値は MCP 登録、argv、一覧、ログに載せず、CLI の子プロセスへの継承と handshake にだけ用いる。外部クライアントの接続だけ、アカウント無し、終了済みの起動の接続からは受け付けない。admin(人間)とローカル MCP サーバーは起動対象にできない。

  1. list_account_sessions で設定済み account_id、開いている topic_id、現在の pty_id を読む。一覧はコマンドや環境変数を返さない。
  2. control_account_session に自分で決めた一意の operation_id と対象を渡す。開始は action: "start", mode: "fresh" または mode: "resume" を明示する。終了は action: "stop"、再起動は action: "restart" と現在の expected_pty_id を渡し、mode は渡さない。
  3. account_session_status に同じ operation_id を渡して確認する。新しい席や別の参加者も照会できる。

例(ID は一覧の実値に置き換える):

{"operation_id":"restart-luna-20261015-01","action":"restart","account_id":"account-id","topic_id":"topic-id","expected_pty_id":"current-pty-id"}

再起動は現在の会話 ID とトピックの再開記録・transcript を停止前に照合し、既存の再開処理を使う。未取得 ID、失われた transcript、再開コマンド無しは失敗とし、対象を止めない。停止後に再開条件が変わったり起動が失敗した場合は失敗理由を残す。新規会話への自動置換はしない。画面からの従来の開始にある再開不能時の動作は変えない。

アプリは受付を session-operations.json(app data)へ保存してから、接続タスクと独立した worker で実行する。同一 ID・同一依頼・同一依頼者の再試行は同じ記録を返し、二度実行しない。同じ ID を他の内容/依頼者に使うと拒否する。別 ID の競合は、UI 起動と共有する lifecycle lock と現在 PTY の照合で処理する。自己再起動の MCP の応答は自分の終了に間に合わないことがある。通信断や15秒の待ち切れは未確認であり、実行中止を意味しない。新しい ID で送り直さず、同じ ID で照会/再試行する。

状態は accepted → running → process_started / stopped / failed。開始成功はプロセスを作ったという意味で、process_running と room_connected を別に確認する。接続は同じアカウント名ではなく起動世代と接続で照合する。stopped は Pullcept の PTY 所有の解放と終了要求を示し、OS 上のプロセス消滅の検証ではない。再起動成功だけで Li+ 版や hook 読込を反映済みとは扱わず、新しい席本人が確認する。

実行結果は、成功または失敗のアプリ通知1通として依頼者の部屋に残す。本文は「誰が・誰に・何をして・どうなったか」を名前で書く(#421)。例:「Lin が Lay を再起動しました(成功)」。依頼者と対象が同じアカウント(名前ではなくアカウント ID で判定)なら対象を「自分」と書く。例:「Lin が自分を再起動しました(成功)」(#424)。失敗したときだけ「理由: …」を一行足し、末尾に operation_id: … を残す(依頼した席は自分の操作の結果かをこれで照合する)。名前は依頼者の名前と、対象アカウントの呼び名(ニックネーム、無ければ名前)であり、対象が設定に無ければ「不明なアカウント」と書く。UUID(topic・account・PTY)と Rust の Some(…) / None の表記は本文に出さない。照合用の ID は操作記録(session-operations.json)と session_status(MCP の account_session_status)の照会で見る。本文の組み立ては mcp_config::session_control::notice_text が持ち、「(返信不要)」はアプリ通知の共通処理が付ける。受付時には部屋へ投稿せず、受付の永続記録と accepted/running の状態照会は維持する(#417)。画面にも失敗を出す。MCP から作った端末も既存の seat 読み直しで採用する。採用前の出力や旧端末の scrollback は再構築しない(画面再読込時と同じ制約)。

アプリ終了時と次回起動時に未完了の accepted / running を interrupted にし、自動再実行しない。強制終了時は最後に保存できた状態から判断する。完了保存が失敗した場合は画面のログエラーを確認し、実際の席を一覧で照会する。記録は保持し続けるため、app data の削除は重複排除の履歴も消す。自動の異常検出・相互停止規約・Li+ 更新・版固定はこの機能に含めない。

wire protocol 11 は session_list、session_status(operation_id)、session_control(operation)を送り、session_result(request_id と result または error)を返す。操作IDは永続の重複排除、request ID は接続上の応答照合に用いる。

Clone this wiki locally