diff --git a/docs/design/agmsgd-rfc.ja.md b/docs/design/agmsgd-rfc.ja.md new file mode 100644 index 000000000..7cdb4545b --- /dev/null +++ b/docs/design/agmsgd-rfc.ja.md @@ -0,0 +1,240 @@ +# RFC: agmsgd — 常駐配達デーモン + +これは RFC ---- 設計への意見を求める文書であって、決定の記録ではない。決まったことは従来どおり ADR に落ちる。**変えるのがまだ安いうちに形を変えられるよう**、実装の前に公開する。 + +前提は terminal driver リリースが着地していること: join した全エージェントのペインに名前が付き、その名前に対して `peek`(切り替えずに画面を見る)と`poke`(ペインに1行打つ)が効く。このリリースが入っていれば、この note の前提は全部揃っている。 + +## これで何が良くなるか + +大きな変更なので、利点を最初に置く。どれも今日は手に入らないもの: + +1. **メッセージが消えなくなる。**エージェントが実際に取りに行くまで、何も既読にならない。いまは壊れた・残留した watcher が誰も見ていないメッセージを消費できる。この変更の後、その壊れ方は構造的に不可能になり、取られなかったメッセージは「届いたが未読」として見える形で残る。 +2. **通知がエージェントのコンテキストを食わなくなる。**いまの monitor モードは全文をセッションに押し込む — エージェントが今それを必要としていようがいまいが、コンテキスト窓を消費する。変更後は1行の通知(送り主 + 件名)だけが届き、全文にコンテキストを使うタイミングはエージェント自身が決める。 +3. **Codex が起動ラッパー無しで動く。**Codex へのリアルタイム配達にラッパー起動が不要になる — これまで出荷した Codex バグの最大の発生源であり、ラッパーが原理的に使えなかった Codex デスクトップアプリへの道も開く。 +4. **プロセスの群れが1本になる。**忙しい実機で数えたら agmsg のバックグラウンドプロセスはおよそ15本あった(watcher、チームごとの同期、Codex の補助)。これが監督された1本のデーモンになる。配達がおかしいとき、見る場所がデーモン1つになる。 +5. **ツールをまたいで1つの名前、グループ宛の1通。**エージェントは Claude Code で動いていても Codex で動いていても1つの identity を保ち、1通のメッセージがタグ(グループ)を宛先にでき、受信者ごとの状態が独立に追跡される(今日はどちらも不可能)。 +6. **リモート操作のための土台。**`despawn` を安全に実行するのと同じ仕組みが、将来スマホからペインを peek することを可能にする — 場当たりのテキスト解釈ではなく、明示的な認可モデル付きで。 + +```mermaid +flowchart LR + subgraph today["いま: 関心ごとに1プロセス"] + w1[watcher x セッション数] + s1[同期 x チーム数] + c1[bridge + server + 補助 x Codex セッション数] + end + subgraph after["提案"] + d[agmsgd - デーモン1本] + end + today -->|畳む| after +``` + +## なぜ今か + +きっかけは実際に出たバグだ。ユーザーが実際に踏んだ問題のいくつかは、メッセージングではなくプロセスの群れの管理から来ていた: 残留 watcher が誰も持っていない役のメッセージを消費し続けた; Codex セッションが再起動まで黙って受信しなくなった; 配達設定の無いプロジェクトと意図して "off"にしたプロジェクトの区別が付かなかった。それぞれに個別の修正を当てたが、このクラスは何度でも戻ってくる。監督された常駐プロセス1本で、クラスごと消える。 + +## 変更の要約 + +**1つの常駐デーモン `agmsgd`** がメッセージストアを見張り、各エージェントに名前付きペイン経由で知らせる。あなたに見える変化: + +- **monitor モード**: セッションごとの watcher プロセスが消える。セッションの中から見た配達は近い形だが、届くのは1行の通知になり、やっているのはデーモンになる。 +- **Codex**: 起動ラッパーが不要になる(Codex の節で詳述)。 +- **送信**: 変わらない。`send` はデーモンの有無に関係なくメッセージ DB に直接書く。デーモン無しで失うのは自動配達だけ。 + +agmsg を日常的に使っているなら — 特に monitor モード、複数マシンのチーム、Codex — いまのコメントは後のバグ報告より価値がある。 + +## 提案 + +### デーモンは1つ + +`agmsgd` は単一の常駐プロセス(Node 22 以上)。仕事は: + +- メッセージ DB を見張り、配達ループ(次節)を回す +- 他マシンとの同期をチームごとのループとして**デーモン内で**回す —プロセスは増えず、あるチームの認証切れが他チームの同期を止めない +- 制御メッセージ(`despawn` の裏にある種類)を、受信側の LLM にやらせる代わりに自分で実行する +- インストール時に launchd / systemd に登録でき、再起動しても生きる —推奨だが**任意**: 無くても自動配達以外は全部変わらず動く + +### 配達ループを具体的に + +09:00、bob が作業中に alice が bob へメッセージを送ったとする。デーモンは3ステップを回す。以降の文中で参照するので名前を付けている: + +1. **oracle** — *「bob に、まだ知らされていないものはあるか?」*ストアへのただの読み取り: bob 宛で「noticed」の記録が無いメッセージは? → ある。いま届いた1通、仮に m17。oracle は何も変更しない — 仕事を見つけるだけ。bob が登録済みでプロセスが生きているかもここで確認し、いなければループはここで終わり、m17 はただ待つ。 +2. **gate** — *「いま bob に割り込んでいいか?」*ツールごとの規則。**v1 の答えは常に yes** — Claude Code も Codex も。割り込みの実体は1行の打鍵で、bob が作業中なら CLI 自身の入力キューが行を持っていて turn の終わりに出す。gate が名前付きのステップとして在るのは、将来のツールが「X の間は駄目」のような規則を必要とするかもしれないからで — そしていまの Codex bridge が実質、900行の複雑な gate だからだ。poke はその gate を不要にする。**残すのは席で、複雑さではない。** +3. **notify** — bob のペインに1行打ち、(m17, bob) の「noticed」を記録する。打鍵に失敗したら(ペイン消滅、ターミナル死)何も記録せず、次の周回で再試行する。 + +```mermaid +sequenceDiagram + participant alice + participant DB as メッセージ DB + participant d as agmsgd + participant bob as bob のペイン + alice->>DB: send (m17 を書く) + d->>DB: oracle: bob に未 noticed は? + DB-->>d: m17 + d->>d: gate: 割り込んでいい? (v1: yes) + d->>bob: notify - alice: リリースゲート、/agmsg で確認 + d->>DB: receipt (m17, bob): noticed + bob->>DB: (bob が選んだとき) inbox 取得 + DB-->>bob: 全文、receipt: read +``` + +ループに**入っていない**ものに注目してほしい: 読むこと。m17 を既読にするのは bob 自身の inbox 取得だけだ。いまは表示した watcher が同じ手で既読も打つ — 壊れた watcher が誰も見ていないメッセージを消費できたのは、まさにそのせいだ。 + +### メッセージに件名が付く — そして検索が強くなる + +なぜ全文ではなく1行の通知か。**押し込まれたテキストは、使われたコンテキストだから**だ。作業中のエージェントに長文が3通押し込まれたら、どれが重要か判断する前に3通ぶんのトークンを払っている。1行通知はこれを逆転する: エージェントは1通につき1行を見て、何をいつ取るかを選ぶ。 + +通知の1行が意味を運べるように、メッセージに任意のフィールドを2つ足す: + +- **subject** — 1行の件名。送り主が付ける: `send --subject "リリースゲート" "<長い本文...>"` +- **summary** — 長い本文向けの数文の要約。これも送り主が付ける。 + +デーモンが受信者のペインに打つ行はこうなる: + + [agmsg] alice: リリースゲート (ほか未読2件) — /agmsg で確認 + +件名が無ければ本文の先頭行で代用する。古い版からのメッセージも、件名を付けない送り主も、そのまま動く。 + +「いま取りに行かない」という選択が成立するのは、後から見つけるのが安いときだけだ。だから検索をこの変更と同時に出す。モデルは Gmail で慣れているあれ — 演算子 + 語を、subject / summary / 本文にかける。例: + + search 'from:alice subject:release after:2026-08-20 "drift"' + search 'team:agmsg to:#devs before:2026-09-01' + +送り主・宛先(タグ含む)・期間・自由語、すべて組み合わせ可。subject と summary は、生の本文よりましな検索対象を与えるために存在する。 + +### 誰が通知するか: セッションごとに経路は1本 + +配達のたびに、デーモンは受信者に届ける方法を1つだけ選ぶ。基準はそのセッションが実際にどこで動いているか: + +1. デーモンが操作できるペイン管理(tmux、herdr、agmsg デスクトップアプリ)の中で動いている? → **名前付きペインに poke**。あなたが手で打つのと同じように、通知の1行をそのエージェントの端末に打ち込む。エージェントの種類によらず同じ1本道。 +2. ペイン管理が無い? → ツール自身が持つ受け口に落ちる: Claude Code は動作中のセッションが聞いているチャネルを持つ; Codex は hook のタイミングで受け取る(Codex の節を参照)。 +3. 両方使える — たとえば tmux のペインを herdr が管理している? → **あなたが実際に見ている方**だけを使う。同じメッセージが2経路から二度届くことはない。 + +セッションの居場所は配達のたびに調べ直す。前に記録した環境変数は入れ子の構成では古くなって嘘をつくからだ。 + +### デーモンはどうやって各エージェントの居場所を知るか + +デーモンは見つけられるセッションにしか通知できない。だから各セッションが自分をデーモンに登録する:「team X の *bob* は私です — session id はこれ、process id はこれ、このターミナルのこのペイン、このプロジェクト」。(pub/sub で言えばこの登録が subscribe 側。送る側に登録は一切無い。)登録は**ペインに名前を付けるのと同じステップ**で作られる: + +- **Claude Code**: 登録はセッション開始時(SessionStart hook)。役割の claim(join / actas)で登録がその名前に向き、drop かセッション終了で消える。 +- **Codex**: 起動時に発火する hook が無い(Codex の SessionStart は最初の user turn で発火する)ので、登録はエージェント自身が実行する join / actas コマンドが作り、Stop / PostToolUse hook の発火ごとに更新される。 +- **古い登録は残れない**: 登録は必ず process id を持ち、デーモンは配達前に生存を確認し、死んだ登録は回収される。「残留 watcher が誰も持っていない役のメッセージを食べる」バグはこれで恒久的に退役する。 +- **デーモンが動いていなければ?** 登録の呼び出しは静かに何もしない。ほかは今までどおり進む。 + +### 購読が「役を明示する手順」を置き換える + +セッションがどこに居るかを登録すれば、それが誰かも同時に決まる。だから今日ある別ステップは要らなくなる。 + +いまセッションは2つのことをしている。`join` が名前をチームに置き、`actas ` が「このセッションはその名前だ」と宣言して、排他ロックを取り、受信を絞る。2つ目は明示で、再起動のたびに繰り返す必要があり、間違えやすい ---- そして**間違いが無言**だ。役を宣言しなかったセッションは、そのプロジェクトに登録された**あらゆる役宛て**を受け取る。名前の引数なしで起動した watcher も同じことをする。どちらも、見えていないはずのセッションがメッセージを既読にしているのを誰かが気づくまで、正常な構成と見分けが付かない。この失敗は複数の番号で報告されて直っている(#62、#300、#982)。そのたびに新しい形で再発したのは、根っこの取り決め ---- 「**誰であるかを、毎回、別に言わなければならない**」---- が残っていたからだ。 + +agmsgd では登録が既に identity を運んでいる。セッションはデーモンに「私は team X の *bob*、このプロセス、このペイン」と伝えている。だから: + +- **セッションは起動時に名前を購読する。**材料は既に手元にある(動いているプロジェクト、そのセッションに記録された役、spawn 時に渡された名前)。`join` が名前を作り、`spawn` が渡し、再起動は導出し直す。**打ち直すものは何も無い。** +- **購読した名前は既定でロックされる。**購読を持つことが役を持つことになる ---- 2つが食い違いようがない。**別の場所で宣言する余地が無くなる**から。 +- **`actas` は日常ではなく例外になる**: セッションを意図的に別の役へ切り替えるときだけ。全セッションが毎回思い出さなければならない手順ではなくなる ---- 忘れられる余地を作っていたのは、まさにそこだった。 + +ロックが何であるかは正確にしておく: これは**ローカル**なものだ。同じマシンの2つのセッションが両方 `bob` として応答することを防ぐのであって、「誰が `bob` として振る舞ってよいか」をどこかで主張する権限ではない。この節は**その範囲を変えない** ---- 変えるのは、ロックをいつ取るか(購読と同時に、自動で)であって、何を守るかではない。 + +### 1つの名前で全ツール、1通で複数の受信者 + +直接目に見える変化が2つ。それぞれ1つのストレージ変更が支える: + +**エージェントは (team, name)。それだけ。**いまストアは登録を (team, name, *ツール種別*) でキーしているので、Claude Code 上と Codex 上の「同じエージェント」が別の登録になる。ユーザーから報告された重複登録の混乱の根本原因であり、leave / despawn に所有権チェックを足せない理由でもある。変更後、`bob` は `bob` だ — 今日どのツールで動いていようと。そして「誰が bob を消してよいか」に、規則を取り付けられる単一の答えがようやくできる。 + +**グループ宛、受信者ごとの追跡付き。**メッセージはタグ — たとえば`#devs` — を宛先にでき、そのタグを持つ全メンバーが自分宛の通知と自分の noticed/read 状態を持つ。具体的には、配達の印がメッセージ行から降りて(メッセージ, 受信者) ごとの1行になる: + + いま: messages: id | from | to | body | created_at | notified_at | read_at + 提案: messages: id | from | to | subject | summary | body | created_at + receipts: message_id | team | recipient | noticed_at | read_at + +時系列を1本: + + 09:00 alice が「リリースゲート」を #devs に送る → メッセージ m17。receipt はまだ無い + 09:00 デーモンが bob と carol に通知 → receipts (m17,bob) (m17,carol): noticed + 09:03 bob が inbox を取得 → receipt (m17,bob): read + ... carol は取得しない → (m17,carol) は noticed のまま: 見える + +1人宛は、同じ仕組みの受信者1人ケースにすぎない。 + +### 土台づくり: LLM が解釈しない制御チャネル + +トラフィックの中には会話ではなく命令のものがある。今日の例は `despawn` (エージェントを畳む): メッセージとして届き、**受信側の LLM が読んで従うことが期待される** — つまり安全性がモデルのテキスト解釈に依存している。agmsgd では命令メッセージはデーモンが種類を見て機械的に実行し、LLM には一切届かない。 + +v1 で出す命令は意図的に1つだけ — despawn、terminal driver 経由(graceful な畳みが tmux 専用という今の非対称もこれで直る)。この節が在る理由はその先にある。**命令の種類を増やす前に、この実行基盤の形に意見が欲しい**: + +- **リモート peek / poke** — 別マシンやスマホから「bob の画面を見せて」「carol のペインにこれを打って」が同期経由で届き、ローカルのデーモンが実行する +- **認可モデル** — 命令を増やす前に「誰がどの命令を誰に発行できるか」に明示的で点検可能な答えが要る +- pause / handoff のような候補は、その門の後ろにだけ続く + +「デーモンが命令を実行する」は不安に聞こえ得るので、セキュリティの姿勢を平文で書いておく: + +- **命令は型付きで、自由文ではない。**命令は kind + スキーマ付きの構造化引数だ。デーモンはメッセージ本文に書かれたものを実行することは決してなく、どこにもシェルは介在しない。 +- **既定は全部拒否。**v1 が受け付ける命令は despawn だけで、条件も今日動いているのと同じ(同じチーム内から、同じマシン上で)。それ以外の種類はすべて拒否される。 +- **リモートは既定 off。**他マシンとの同期越しに届いた命令は、受信側のマシンでそのチーム・その命令種別を明示的に有効化していない限り無視される — チームごと、種類ごと。 +- **全部ログに残る。**受理も拒否も、誰が何を要求したかのローカルな記録を残す。履歴は監査できる。 +- 許可リストのモデル(「この送り主はこの種類をこの宛先に発行できる」)は、2つ目の命令種別が存在する前に独立の ADR として出す — **その ADR こそ意見が欲しいものだ。** + +### 配達モードは自動で選ばれる + +join 時の4択(monitor / turn / both / off)は消える。デーモンがそのセッションの環境で使える最良のモードを選ぶ。明示設定は引き続き勝つ。副作用として、「設定ファイルが無い」と「意図して off」が同じ状態でなくなる。 + +### 他マシンとの同期はどうなるか + +いま、チームを他マシンと同期するには**チームごとに1本の常駐プロセス**が要る。必要になったときに起動され、pidfile で追跡される。この形は watcher と同じ問題を抱えている: プロセスが監督の外にあり、その存在を示す記録が「2度目の起動が上書きできるファイル」1つしか無い。**この note を書いている最中に実機で見つけたもの**: あるチームにエンジンが**2本**動いていて、起動は8分違い、pidfile は後から起きた方しか指していなかった。先に起きた方は同期を続け、そのチームのログに書き続けていて、しかも `stop` も `restart` も status も届かない ---- 3つとも pidfile を見るからだ(#1103)。 + +agmsgd では、同期は**デーモン内のチームごとのループ**になる: + +- **別プロセスが無い。**孤児になるものが無く、見失うものが無く、「動いているものの唯一の記録」としての pidfile も無い。動いているのはデーモンが動いていると言うものだけだ。 +- **失敗はチーム単位のまま。**あるチームの認証切れやサーバー到達不能は、そのチームのループだけを止める ---- プロセス分離で狙っていた性質は、そのまま保つ。 +- **聞く場所が1つ。**全チームのサイクル数・最後の成功・現在のエラーが、デーモンへの1回の status で返る。チームごとに pidfile とログを突き合わせる必要が無くなる。 + +**変わらないもの**も書いておく(逆に思われやすいので): **同期は決して通知しない。**その仕事はメッセージをこのマシンに持ってくることで、行を書いてそこで終わる。エージェントに知らせるかどうかは、このマシンの配達ループの判断で、ローカルに送られたメッセージと**同じ noticed の帳簿**を使う。同期は写す、デーモンが知らせる。ここを分けておくことが、「他マシンから届いた」と「隣で送られた」をエージェントから見て同一に振る舞わせている。 + +サブコマンドは動き続ける: チームの同期を開始・停止するのは引き続き頼めることで、宛先がプロセスの起動・kill ではなくデーモン内のループになるだけだ。 + +### Codex には具体的に何が起きるか + +いま Codex へのリアルタイム配達は、補助プロセス(「bridge」)が届くようにラッパースクリプト経由で起動することを要求している。このラッパーが、これまで出荷した Codex 関連バグの最大の発生源で(残留する補助プロセス、respawn ループ、起動時の競合)、しかも自分で起動を管理する Codex デスクトップアプリでは原理的に使えない。 + +この提案では、Codex への配達は公式の仕組み2つだけになる: hook(turn の終わり、および turn 中の各 tool call 後)と、管理されたペインで動いているときは他のエージェントと同じ poke。**どちらも Codex の起動方法に触れない。**残る唯一の穴は、素のターミナル(tmux も herdr も無し)で idle している Codex セッション — hook は発火せず、poke するペインも無い。その場合に限り旧 bridge を opt-in の fallback として残す。それすらも、Codex 自身の shared app-server daemon(最近の版で追加)が「普通に起動したセッションに外から届く」ことを確認できたら引退させる見込みだ。 + +**Codex デスクトップアプリ**は独立した環境として扱う。sandbox の制約でアプリ内のエージェントはメッセージ DB に直接書けないため、アプリ内からの送信は小さな受け渡しを通る: エージェントがリクエストファイルを書き、デーモンがそれを DB に適用し、**デーモンの確認が返って初めて「送れた」と報告する** — 確認が無ければ、送れたふりをせずエラーとして失敗する。(いま同じ状況は無言で失敗する。そちらの方が悪い。)アプリについては未測の事実がいくつか残っており、判明し次第この note を更新する。 + +## 互換性と移行 + +意図は「**今日打っているコマンドの意味は何も変わらない**」で、見える差分はすべて摩擦の除去であること。具体的に: + +| いまやっていること | 変更後 | +|---|---| +| `send` / `inbox` / `history` | 同じコマンド、同じ挙動(+ subject/summary オプションと検索) | +| join 時に配達モードの4択に答える | 4択は消える。自動判定、明示設定は勝つ | +| `ps` に watcher プロセスが見える | `agmsgd` 1本 | +| monitor のために Codex をラッパーで起動 | Codex を普通に起動 | +| `despawn` は宛先 LLM の協力頼み | デーモンが実行。同じコマンド、確実に | +| 配達の不調をプロセス探しでデバッグ | デーモンに聞く: status 1発で登録・直近の配達・同期状態が並ぶ | + +何がどこで動くか: + +| できること | デーモンあり | デーモンなし | +|---|---|---| +| send / inbox / history / 検索 | ○ | ○ | +| 自動配達(通知) | ○ | ×(手動確認か turn 終わりの hook) | +| 他マシンとの同期 | 常時・監督付き | 手動 pull は可 | +| despawn / 将来の制御命令 | ○ | × | +| 依存 | Node 22+ | bash + sqlite のみ | + +移行の事実: + +- チームストア・登録・設定はその場で移行する。規則は実装に付く。旧来の単一受信者カラムは機械的に receipts 表へ運ばれる。 +- 素のメッセージングの bash + sqlite 依存線は意図して保つ: Node を動かせないマシンでも送受信はできる。 +- Codex bridge は初日に消さない: 既定 off の opt-in になり、カバーされない1ケース(素のターミナルで idle の Codex)専用に残る。 + +## 聞きたいこと(コメントが一番効くところ) + +どれもまだ安く変えられる決定だ。「うちは実際に X してる」の1行で足りる。 + +1. **配達モードの自動選択。**デーモンは環境(ペイン管理はあるか、どのツールか)を見てモードを選ぶ。あなたの構成で、その推測が外れる —自明な選択とは別のモードが欲しい — ケースはあるか? +2. **noticed は read ではない。**変更後、1行通知として表示されたメッセージは既読にならない。既読になるのは inbox 取得だけ。「watcher が表示した = 既読」を前提にしたスクリプトや習慣はあるか? +3. **(team, name) で identity は1つ。**「同じ」エージェント名を2つのツールで動かすと、2つの登録ではなく1つの identity になる。同名・別ツールを意図して分けて運用している人はいるか? +4. **検索。**実際に何を探したいか — 送り主、日付、語、チーム? どこまで遡る? Gmail 風の演算子(from: / after: / subject:)で足りるか? +5. **制御命令。**despawn の先、自分のマシンでリモート peek / poke を有効にするとしたら何が条件か — チームごとの opt-in、送り主の許可リスト、監査ログ、それ以外? +6. **デーモン無しの fallback。**デーモン無しでも使える形(手動の inbox 確認、自動配達なし)を保つ計画だ。実際にその形で運用するか、それとも簡素化してデーモン必須にすべきか? diff --git a/docs/design/agmsgd-rfc.md b/docs/design/agmsgd-rfc.md new file mode 100644 index 000000000..80b0de9ff --- /dev/null +++ b/docs/design/agmsgd-rfc.md @@ -0,0 +1,240 @@ +# RFC: agmsgd — a resident delivery daemon + +This is an RFC: a request for comments on a design, not a decision record. Decisions land as ADRs as usual once settled — this is published first, so the shape can change while changing it is still cheap. + +It assumes the terminal driver release has landed: every joined agent's pane has a name, and two commands work against that name — `peek` (look at an agent's screen without switching to it) and `poke` (type a line into its pane). If you have that release, you have everything this note builds on. + +## What this buys you + +This is a large change, so the benefits come first. Each of these is a thing you cannot have today: + +1. **Messages stop getting lost.** Nothing is ever marked read until the agent actually fetches it. Today a broken or leftover watcher can consume messages that no one ever saw; after this change that failure is structurally impossible, and an untaken message stays visibly "delivered but unread" instead of vanishing. +2. **Notifications stop eating your agent's context.** Today, monitor mode pushes the full message text into the session — spending the agent's context window whether it wanted the message now or not. After this change the agent gets a one-line notification (sender + subject) and decides for itself when to spend the context on the full text. +3. **Codex works without the launch wrapper.** Real-time delivery to Codex no longer requires starting it through a wrapper — the single largest source of Codex bugs we have shipped — and a path opens for the Codex desktop app, which the wrapper could never support. +4. **One process instead of a fleet.** On one busy machine we counted roughly fifteen agmsg background processes (watchers, per-team sync, Codex helpers). They become one supervised daemon. When delivery misbehaves there is a single process to inspect. +5. **One agent name across tools, and messages to groups.** Your agent keeps one identity whether it runs on Claude Code or Codex, and one message can address a group tag with each recipient's state tracked separately (today: impossible on both counts). +6. **Groundwork for remote control.** The same machinery that executes `despawn` safely is what will later let you peek at a pane from your phone — with an explicit authorization model, not ad-hoc parsing. + +```mermaid +flowchart LR + subgraph today["today: one process per concern"] + w1[watcher x N sessions] + s1[sync x N teams] + c1[bridge + server + helper x N codex] + end + subgraph after["proposed"] + d[agmsgd - one daemon] + end + today -->|fold| after +``` + +## Why now + +The trigger is concrete bugs. Several problems users actually hit came from managing that fleet of processes, not from messaging itself: a leftover watcher kept consuming messages for a role nobody held anymore; Codex sessions silently stopped receiving until relaunched; a project with no delivery settings was indistinguishable from one deliberately set to "off". Each got a point fix; the class keeps coming back. One supervised resident process removes the class. + +## Summary of the change + +**One resident daemon, `agmsgd`**, watches the message store and notifies each agent through its named pane. What changes for you: + +- **Monitor mode**: the per-session watcher process disappears. Delivery looks similar from inside the session — but it is a one-line notification, and the daemon is doing it. +- **Codex**: no more launch wrapper (details in the Codex section). +- **Sending**: unchanged. `send` writes straight to the message database, daemon or no daemon. Without the daemon you lose automatic delivery, not messaging. + +If you use agmsg daily — especially monitor mode, multi-machine teams, or Codex — your comments now are worth more than your bug reports later. + +## Proposal + +### One daemon + +`agmsgd` is a single resident process (Node 22 or newer). It: + +- watches the message database and runs the delivery loop (next section); +- runs multi-machine sync as one loop per team **inside the daemon** — no extra processes, and one team's expired credentials do not stop another team's sync; +- executes control messages (the kind behind `despawn`) itself instead of asking the receiving agent's LLM to act on them; +- can be registered with launchd / systemd at install time so it survives reboots — recommended, but **optional**: without it, everything except automatic delivery works unchanged. + +### The delivery loop, concretely + +Suppose `alice` sends `bob` a message at 09:00 while bob is in the middle of a task. The daemon runs three steps, named because the rest of the note refers to them: + +1. **oracle** — *"does bob have anything it has not been told about?"* A plain read against the store: any message addressed to bob with no "noticed" record? Yes — the new one, call it m17. The oracle changes nothing; it only finds work. It also checks bob is registered and its process is alive — if not, the loop ends here and m17 simply waits. +2. **gate** — *"may I interrupt bob right now?"* A per-tool rule. **In v1 the answer is always yes**, for Claude Code and Codex alike, because the interruption is one typed line: if bob is mid-task, its CLI's own input queue holds the line and surfaces it when the turn ends. The gate exists as a named step because some future tool may need a rule like "not while X" — and because today's Codex bridge is, in effect, 900 lines of complicated gate. Poking makes that gate unnecessary; we keep the slot, not the complexity. +3. **notify** — type one line into bob's pane and record "noticed" for (m17, bob). If the typing fails — pane gone, terminal died — nothing is recorded and the next pass retries. + +```mermaid +sequenceDiagram + participant alice + participant DB as message DB + participant d as agmsgd + participant bob as bob's pane + alice->>DB: send (writes m17) + d->>DB: oracle: anything un-noticed for bob? + DB-->>d: m17 + d->>d: gate: may I interrupt? (v1: yes) + d->>bob: notify - alice: release gate, run /agmsg + d->>DB: receipt (m17, bob): noticed + bob->>DB: (when bob chooses) inbox fetch + DB-->>bob: full text, receipt: read +``` + +Note what is *not* in the loop: reading. Only bob's own inbox fetch marks m17 read. Today the watcher that displays a message also marks it read in the same breath — which is exactly how a broken watcher could consume messages nobody ever saw. + +### Messages get a subject line — and search to match + +Why one line instead of the full text? Because pushed text is spent context. An agent mid-task that receives three long messages has paid tokens for all three before deciding any of them mattered. A one-line notification inverts that: the agent sees one line per message and chooses what to fetch, when. + +For that line to carry meaning, a message gains two optional fields: + +- **subject** — one line, set by the sender: `send --subject "release gate" ""` +- **summary** — a few sentences for long bodies, also from the sender. + +The line the daemon types into the recipient's pane looks like: + + [agmsg] alice: release gate (2 more unread) — run /agmsg to read + +No subject? The first line of the body stands in, so old messages and lazy senders keep working unchanged. + +Choosing *not* to fetch immediately is only viable if finding things later is cheap, so search ships alongside this. The model is the one you already know from Gmail: operators plus words, over subject, summary, and body, for example + + search 'from:alice subject:release after:2026-08-20 "drift"' + search 'team:agmsg to:#devs before:2026-09-01' + +— sender, recipient (including tags), date range, and free words, combinable. Subject and summary exist precisely to give that search something better to match than raw bodies. + +### Who notifies: exactly one channel per session + +For each delivery the daemon picks exactly one way to reach the recipient, based on where that session actually runs: + +1. Session runs under a pane manager the daemon can control — tmux, herdr, or the agmsg desktop app? The daemon **pokes the named pane**: it types the notification line into the agent's terminal, the same way you would. One mechanism, identical for every agent type. +2. No pane manager? Fall back to what the tool itself offers: Claude Code has a channel a running session listens on; Codex takes deliveries at its hook points (see the Codex section). +3. Both would work at once — say, herdr managing panes that also live in tmux? The daemon uses the one **you are actually looking at**, and only that one. A message never arrives twice through two channels. + +The daemon re-checks where a session runs at delivery time instead of trusting environment variables recorded earlier — in nested setups those go stale and lie. + +### How the daemon knows where each agent is + +The daemon can only notify a session it can find, so each session registers itself: "team X's agent *bob* is me — this session id, this process id, this pane of this terminal, this project." (In pub/sub terms this registration is the subscribe side; sending needs no registration at all.) It is created by the same step that names the pane: + +- **Claude Code**: registration happens at session start (a SessionStart hook). Claiming a role — join or actas — points it at that name; dropping the role or ending the session removes it. +- **Codex**: Codex has no hook that fires at launch (its SessionStart fires on the first user turn), so the registration is created by the join / actas command the agent itself runs, and refreshed each time a Stop or PostToolUse hook fires. +- **Stale registrations cannot linger**: each carries a process id, the daemon checks the process is alive before delivering, and dead entries are collected. This permanently retires the "leftover watcher eats messages for a role nobody holds" bug. +- **No daemon running?** The registration call quietly does nothing, and everything else proceeds as today. + +### Subscription replaces explicit role-claiming + +Registering where a session is also settles *who* it is — which makes the separate step that exists today unnecessary. + +Right now a session does two things. `join` puts a name in the team; `actas ` then declares "I am that name in this session", which takes an exclusivity lock and narrows what this session receives. The second step is explicit, repeated after every restart, and easy to get wrong — and getting it wrong is silent. A session that never claims a role receives *everything* addressed to any role registered for that project; a watcher started without the name argument does the same. Both look identical to a working setup until someone notices messages being marked read by a session that was never meant to see them. That failure has been reported and fixed under several numbers (#62, #300, #982) and re-appeared each time in a new shape, because the underlying arrangement — "you also have to say who you are, separately, every time" — stayed. + +Under agmsgd the registration already carries the identity: a session tells the daemon "I am team X's *bob*, this process, this pane". So: + +- **A session subscribes to a name at startup**, from what it already knows (the project it runs in, the role recorded for it, or the name it was spawned with). `join` establishes the name; `spawn` passes it; a restart re-derives it. Nothing has to be typed again. +- **The subscribed name is locked by default.** Holding the subscription *is* holding the role — the two cannot drift apart, because there is no longer a second place to state it. +- **`actas` becomes the exception, not the routine**: switching a session to a different role deliberately. It stops being a step every session must remember, which is what made forgetting it possible. + +Worth being precise about what the lock is: it is **local**. It keeps two sessions on the same machine from both answering as `bob`; it is not an authority claim about who may act as `bob` anywhere. Nothing in this section changes that scope — it changes when the lock is acquired (with the subscription, automatically) rather than what it protects. + +### One name, many tools; one message, many recipients + +Two changes you will notice directly, enabled by one storage change each: + +**Your agent is (team, name), nothing more.** Today the store keys a registration by (team, name, *tool type*), so "the same agent" on Claude Code and on Codex are two different registrations. That is the root cause of duplicate-registration confusion users have reported, and the reason ownership checks on leave / despawn are hard to add. After the change, `bob` is `bob` — whichever tool it happens to be running on today — and "who may remove bob" finally has a single answer to attach rules to. + +**Messages to groups, with per-recipient tracking.** A message can address a tag — say `#devs` — and every member holding that tag gets its own notification and its own noticed/read state. Concretely, the delivery marks move off the message row into one row per (message, recipient): + + today: messages: id | from | to | body | created_at | notified_at | read_at + proposed: messages: id | from | to | subject | summary | body | created_at + receipts: message_id | team | recipient | noticed_at | read_at + +A worked timeline: + + 09:00 alice sends "release gate" to #devs -> message m17; no receipts yet + 09:00 daemon notifies bob and carol -> receipts (m17,bob) (m17,carol): noticed + 09:03 bob fetches its inbox -> receipt (m17,bob): read + ... carol never fetches -> (m17,carol) stays noticed-but-unread: visible + +Sending to one name is just the one-recipient case of the same mechanism. + +### Groundwork: a control channel no LLM interprets + +Some traffic is commands, not conversation. Today's example is `despawn` (tear down an agent): it travels as a message, and the *receiving LLM* is expected to read it and comply — which means safety depends on a model interpreting text. Under agmsgd, command messages are executed by the daemon, mechanically, by kind; they never reach an LLM. + +v1 deliberately ships only one command — despawn, through the terminal driver (which also fixes graceful teardown being tmux-only today). The reason this section exists is what comes after, and we want opinions on this mechanism before more command kinds are added: + +- **remote peek / poke** — from another machine or a phone: "show me bob's screen", "type this into carol's pane", arriving over sync and executed locally by the daemon; +- **an authorization model** — before more commands exist, "who may issue which command to whom" needs an explicit, inspectable answer; +- further candidates like pause / handoff follow only behind that gate. + +"The daemon executes commands" can sound alarming, so here is the security posture, stated plainly: + +- **Commands are typed, never free text.** A command is a kind plus structured arguments with a schema. The daemon never executes anything written in a message body, and no shell is involved anywhere. +- **Default deny.** The only command v1 honors is despawn, under the same conditions it works today (issued within the same team, on the same machine). Every other kind is rejected. +- **Remote is off by default.** A command arriving over multi-machine sync is ignored unless you have explicitly enabled that command kind for that team on the receiving machine — per team, per kind. +- **Everything is logged.** Accepted and rejected commands both leave a local record of who asked for what, so the history is auditable. +- The allowlist model ("these senders may issue these kinds to these targets") ships as its own ADR before any second command kind exists — that ADR is exactly the thing we want comments on. + +### Delivery mode chosen automatically + +The four-way question at join time (monitor / turn / both / off) goes away. The daemon picks the best available mode for the session's environment; an explicit setting still overrides it. As a side effect, "no settings file" and "deliberately off" stop being the same state. + +### What happens to multi-machine sync + +Today, syncing a team with other machines runs a **separate long-lived process per team**, started on demand and tracked by a pidfile. That arrangement has the same shape of problem as the watchers: the process is outside anything that supervises it, and the only record of it is a file that a second start can overwrite. On one machine while writing this note, a single team turned out to have *two* engines running eight minutes apart, with the pidfile naming only the later one — the earlier one still syncing, still writing the team's log, and unreachable by `stop`, `restart`, or status, because all three consult the pidfile (#1103). + +Under agmsgd, sync is **one loop per team inside the daemon**: + +- **No separate processes.** Nothing to orphan, nothing to lose track of, no pidfile as the sole record of what is running. What is syncing is whatever the daemon says is syncing. +- **Failures stay per team.** One team's expired credentials or unreachable server stops that team's loop and nothing else — the property the per-process design was reaching for, kept. +- **One place to ask.** Cycle counts, last success, and the current error for every team come from the daemon in one status call, instead of a pidfile plus a log file per team. + +What does **not** change, and is worth stating because it is easy to assume otherwise: **sync never notifies.** Its job is to bring messages onto this machine — it writes rows and stops there. Whether an agent is told about a message is the delivery loop's decision, on this machine, using the same `noticed` bookkeeping as a locally sent message. Sync copies; the daemon notifies. Keeping those separate is what makes "a message arrived from another machine" and "a message was sent next to me" behave identically from the agent's side. + +Sub-commands keep working: starting and stopping sync for a team stays a thing you can ask for, it just addresses a loop in the daemon rather than spawning and killing a process. + +### What this means for Codex specifically + +Real-time delivery to Codex today requires launching it through a wrapper script so that a helper process (the "bridge") can reach it. That wrapper is the single largest source of Codex-related bugs we have shipped — orphaned helper processes, respawn loops, races at launch — and it cannot work with the Codex desktop app at all, since the app controls its own launch. + +Under this proposal, Codex delivery uses two official mechanisms and nothing else: its hooks (end of turn, and after each tool call while a turn runs) and, when it runs in a managed pane, a poke like any other agent. **Neither changes how Codex starts.** The one situation left uncovered is a Codex session in a bare terminal — no tmux, no herdr — sitting idle: nothing fires a hook, and there is no pane to poke. For exactly that case the old bridge remains available as an opt-in fallback, and we expect to retire even that once we can verify whether Codex's own shared app-server daemon (new in recent Codex versions) lets an ordinarily-launched session be reached from outside. + +The **Codex desktop app** is tracked as its own environment. Its sandboxing means an agent inside it cannot write the message database directly, so sends from inside the app go through a small hand-off: the agent writes a request file, the daemon applies it to the database, and the agent reports "sent" only after the daemon confirms — if the daemon is not there to confirm, the send fails with an explicit error instead of pretending. (Today the same situation fails silently, which is worse.) A few facts about the app are still unmeasured; this note will be updated as they land. + +## Compatibility and migration + +The intent is that **nothing you type today changes meaning**, and the visible differences are all removals of friction. In detail: + +| you do today | after this change | +|---|---| +| `send`, `inbox`, `history` | identical commands, identical behavior (plus subject/summary options and search) | +| answer the 4-way delivery prompt at join | prompt gone; mode auto-detected, explicit setting still wins | +| see watcher processes in `ps` | one `agmsgd` process | +| start Codex through the wrapper for monitor mode | start Codex normally | +| `despawn` asks the target LLM to comply | daemon executes it; same command, more reliable | +| debug delivery by hunting processes | ask the daemon: one status command lists registrations, last deliveries, sync state | + +What runs where: + +| capability | with daemon | without daemon | +|---|---|---| +| send / inbox / history / search | yes | yes | +| automatic delivery (notifications) | yes | no — check inbox manually or via turn-end hooks | +| multi-machine sync | continuous, supervised | manual pull still works | +| despawn / future control commands | yes | no | +| dependency | Node 22+ | bash + sqlite only | + +Migration facts: + +- Team stores, registrations, and settings migrate in place; the rules ship with the implementation, and old single-recipient columns are carried into the receipts table mechanically. +- The bash + sqlite dependency line for plain messaging is kept on purpose: a machine that cannot run Node can still send and read. +- The Codex bridge is not deleted on day one: it becomes opt-in, off by default, for the one uncovered case (bare-terminal idle Codex). + +## Open questions (where comments help most) + +Each of these is a decision we can still change cheaply. A one-line answer ("yes, I actually do X") is enough. + +1. **Auto-selected delivery mode.** The daemon will pick how to deliver by looking at your environment (is there a pane manager? which tool?). Is there a setup you run where that guess would be wrong — where you would want a different mode than the obvious one? +2. **Noticed is not read.** After the change, a message shown as a one-line notification is not marked read; only an inbox fetch marks it. Do you have scripts or habits that assume "the watcher displayed it, therefore it is read"? +3. **One identity per (team, name).** "The same" agent name on two tools becomes one identity instead of two registrations. Does anyone run same-name-on-different-tools on purpose, needing them kept separate? +4. **Search.** What do you actually need to find — by sender, date, words, team? How far back? Would Gmail-style operators (from:, after:, subject:) cover your cases? +5. **Control commands.** Beyond despawn, what would you require before enabling remote peek / poke on one of your machines — per-team opt-in, sender allowlists, an audit log, something else? +6. **Daemonless fallback.** We plan to keep agmsg usable without the daemon (manual inbox checks, no automatic delivery). Would you actually run that way, or should we simplify and require the daemon?