Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod hook_activity;
pub mod claude_limit;
pub mod claude_notice;
pub mod claude_resume;
pub mod limit_recovery;
pub mod permission_prompt;
pub mod prompt_hold;
pub mod usage;
Expand Down
163 changes: 163 additions & 0 deletions crates/mcp-config/src/limit_recovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! The single input handed to a parent after its limit recovery is confirmed.
//! Confirmation remains the caller's Codex/Claude gate (docs/3-accounts.md, #389).
use crate::codex::limit::{digest, Held};

pub const CONTINUE: &str = "利用上限の解除を確認しました。中断していた作業を再開してください。中断していた作業がなければ、そのまま待機してください。";

pub fn input(held: &[Held]) -> String {
match digest(held) {
Some(digest) => format!("{CONTINUE}\n\n{digest}"),
None => CONTINUE.to_string(),
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
claude_limit::{Event, Gate},
codex::limit::{Answer, Limit},
prompt_hold::{Item, Queue},
};

#[test]
fn empty_mailbox_still_resumes_and_can_wait_without_new_work() {
let text = input(&[]);
assert_eq!(text, CONTINUE);
assert!(text.contains("中断していた作業を再開してください"));
assert!(text.contains("作業がなければ、そのまま待機"));
assert!(!text.contains("read_room_history"));
}

#[test]
fn held_posts_share_one_input_with_the_continuation() {
let held = [Held {
message_id: "post-1".into(),
speaker: "Master".into(),
at: None,
content: "continue the same issue".into(),
addressed: true,
}];
let text = input(&held);
assert!(text.starts_with(CONTINUE));
assert_eq!(text.matches(CONTINUE).count(), 1);
assert!(text.contains(&digest(&held).unwrap()));
assert!(text.contains("post-1"));
assert!(text.contains("continue the same issue"));
}

#[test]
fn codex_confirmation_releases_once_even_with_no_posts() {
let mut limit = Limit::default();
limit.stop(100, Some(200));
assert!(!limit.answer(201, Answer::Unavailable));
assert!(limit.is_limited());
assert!(!limit.answer(
202,
Answer::Denied {
resets_at: Some(200)
}
));
assert!(limit.is_limited());
assert!(limit.answer(204, Answer::Allowed));
assert_eq!(input(&limit.release()), CONTINUE);
assert!(!limit.answer(203, Answer::Allowed));
}

#[test]
fn claude_confirmed_response_keeps_one_continuation_through_permission_wait() {
let mut gate = Gate::new(100, false);
gate.state.apply(Event::Rejected {
at: 101,
id: "reject".into(),
reset: Some(200),
});
assert!(gate.is_limited());
let success = Event::Succeeded {
at: 201,
id: "success".into(),
};
assert!(gate.state.apply(success.clone()));
gate.keep_holding();
assert!(gate.is_limited());
let mut queue = Queue::new();
let item = Item {
message_id: "recovery".into(),
text: input(&gate.release()),
post: None,
launch_bound: true,
relaunch_text: None,
};
assert!(queue.pass(true, item).is_none());
assert!(!gate.is_limited());
assert!(!gate.state.apply(success));
assert!(!queue.begin(true));
assert!(queue.begin(false));
assert_eq!(queue.next(false).unwrap().text, CONTINUE);
assert!(queue.next(false).is_none());
assert!(!queue.begin(false));
}

#[test]
fn recovery_waiting_on_permission_is_not_carried_to_another_launch() {
let recovery = Item {
message_id: "recovery".into(),
text: input(&[]),
post: None,
launch_bound: true,
relaunch_text: None,
};
let ordinary = Item {
message_id: "post".into(),
text: "ordinary room post".into(),
post: None,
launch_bound: false,
relaunch_text: None,
};
let mut queue = Queue::new();
queue.pass(true, recovery.clone());
queue.pass(true, ordinary.clone());
let mut next_launch = queue.carry();
assert_eq!(next_launch.len(), 1);
// An earlier launch's in-flight hand-over can also refuse after carry.
next_launch.put_first(recovery);
assert_eq!(next_launch.len(), 1);
assert!(next_launch.begin(false));
assert_eq!(next_launch.next(false), Some(ordinary));
assert!(next_launch.next(false).is_none());
}

#[test]
fn relaunch_keeps_the_existing_digest_and_watermark_without_continuation() {
let held = [Held {
message_id: "post-1".into(),
speaker: "Master".into(),
at: None,
content: "same issue".into(),
addressed: true,
}];
let fallback = digest(&held).unwrap();
let item = Item {
message_id: "recovery-watermark".into(),
text: input(&held),
post: None,
launch_bound: true,
relaunch_text: Some(fallback.clone()),
};
let mut queue = Queue::new();
queue.pass(true, item.clone());
let mut next_launch = queue.carry();
assert!(next_launch.begin(false));
let carried = next_launch.next(false).unwrap();
assert_eq!(carried.message_id, "recovery-watermark");
assert_eq!(carried.text, fallback);
assert!(!carried.text.contains(CONTINUE));
assert!(next_launch.next(false).is_none());
// A refusal of an item already taken by the old launch has the same fallback.
next_launch.put_first(item);
let mut third_launch = next_launch.carry();
assert!(third_launch.begin(false));
assert_eq!(third_launch.next(false), Some(carried));
assert!(third_launch.next(false).is_none());
}
}
27 changes: 24 additions & 3 deletions crates/mcp-config/src/prompt_hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ pub struct Item {
/// The post as the usage-limit hold keeps one, or `None` for what is not a
/// post of the room's — the limit hold's own digest.
pub post: Option<Held>,
/// Recovery continuation belongs only to the launch that confirmed it.
pub launch_bound: bool,
/// The existing held-post digest carried across relaunch, without continuation.
pub relaunch_text: Option<String>,
}

impl Item {
fn for_relaunch(mut self) -> Option<Self> {
if self.launch_bound {
self.text = self.relaunch_text.take()?;
self.launch_bound = false;
}
Some(self)
}
}

/// One seat's kept posts.
Expand Down Expand Up @@ -117,14 +131,17 @@ impl Queue {
/// front of the queue the launch after it carried (`carry`). A hand-over of
/// that launch, if one is under way, is not ended by it.
pub fn put_first(&mut self, item: Item) {
self.items.push_front(item);
if let Some(item) = item.for_relaunch() {
self.items.push_front(item);
}
}

/// The queue of a seat launched again: what was kept carries over to the
/// new launch, and a hand-over that was under way for the old one ends.
/// new launch, except continuation bound to the old launch (#389). A
/// hand-over that was under way for the old one ends.
pub fn carry(self) -> Queue {
Queue {
items: self.items,
items: self.items.into_iter().filter_map(Item::for_relaunch).collect(),
handing: false,
}
}
Expand Down Expand Up @@ -198,6 +215,8 @@ mod tests {
Item {
message_id: id.into(),
text: format!("[pullcept] {id}"),
launch_bound: false,
relaunch_text: None,
post: Some(Held {
message_id: id.into(),
speaker: "Lin".into(),
Expand All @@ -213,6 +232,8 @@ mod tests {
message_id: id.into(),
text: format!("[pullcept] digest {id}"),
post: None,
launch_bound: false,
relaunch_text: None,
}
}

Expand Down
2 changes: 1 addition & 1 deletion docs/1-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Pullcept (Tauri app) = 部屋の壁
- **札に `role` を載せる。書くのはアプリであり、発言の本文からは決して取らない(#195)。** 値は発言が部屋へ入った口から決まる(`terminal_input::role`):
- 画面のコマンド(`room_post`、webview の Tauri コマンド)から来た発言は `admin` である。**`admin` になるのはこれだけである。**
- アプリが受けた MCP サーバの通知は `mcp` である(発言するアカウントの種別)。
- アプリ自身の知らせ——Codex の席の利用上限での停止と解除、解除後にその席へ打つ一通(#294、「[Codex の席が利用上限で止まったとき](3-accounts.md#codex-の席が利用上限で止まったとき294)」)——は `app` である。発言者は `Pullcept(自動通知)` で、アカウントを持たない。どのアカウントの種別でもなく、ソケットからは作らない。
- アプリ自身の知らせ——Codex の席の利用上限での停止と解除、解除確定後に本人の中断作業の再開指示と預かり発言のまとめを同じ入力で一度渡す一通(預かり 0 件でも再開指示を渡す、#389 / #294、「[Codex の席が利用上限で止まったとき](3-accounts.md#codex-の席が利用上限で止まったとき294)」)——は `app` である。発言者は `Pullcept(自動通知)` で、アカウントを持たない。どのアカウントの種別でもなく、ソケットからは作らない。
- 部屋ソケットから来た発言は、その席が `hello` で名乗ったアカウントの種別である(`claude_code` / `cli`)。名乗りが無い、アプリに無いアカウントを名乗った、または `admin` や `mcp` のアカウントを名乗った接続は `unknown` である。
- **`admin` はソケットから作らない(Master 判断、2026-09-28)。** 部屋のトークンは部屋全体で一つで、各セッションの `.mcp.json` に書かれている。`hello` の `account_id` も検証されない。セッションは自分で二本目のソケットを張り、`admin` のアカウント id を名乗れる。したがってソケットから来た発言は、名乗った id に依らず `admin` にしない。セッションが届かない口——webview の中のコマンド——だけが `admin` を作る。席ごとのトークンで id を縛る形より部品が少ない。
- **本物の札は一行目だけである。** 一行目を書けるのはアプリだけであり、本文は送信キーを持たない(下記)。二行目以降に札の形の行があっても、それは本文である。この規則の置き場はサイドカーの `instructions` 一か所である(Master 判断、2026-09-28)——AI 側(キャラクター・CLAUDE.md 等)に作法を書き込まずに済み、CLI の種別にも依らない。
Expand Down
2 changes: 1 addition & 1 deletion docs/2-screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ Master「コメント書き込み欄の上にある下までスクロールボ
- **待ちは席の hook から読む。** 本体かサブエージェントのどれか一つでも許可待ちであれば待ちとする(`Activity::any_waiting`)。どのエージェントの画面も同じ一つの端末に出るためである。待ちの始まりと終わりは上の「許可待ち」と同じである:`PermissionRequest` で始まり、人が端末に打った答えのキー(本体だけ)、そのエージェントの次の `PreToolUse`、同じツール名の終わり、ターンの区切り、`SubagentStop`、読めない `PostToolUse` で終わる。
- **預かるかどうかは、発言を端末の列へ渡すときに決める。** 列で順番を待っている間に許可の画面が出た発言は、そのまま打たれる。`PermissionRequest` の hook は画面より約 3 秒早く届く(上の「カードは `PermissionRequest` の hook から出る」)ため、この隙間は短い。
- **待ちが終わってすぐには打たない。** 終わりを聞いてから 1.5 秒(`prompt_hold::RELEASE_GRACE`)待ち、その時点でまだどのエージェントも待っていなければ渡し始める。端末で答えた直後に次の許可(並列の別のツール)が続くことがあり、その要求の hook は画面より前に届くためである。渡している途中で待ちが始まれば、そこで止め、残りは次の待ちの終わりまで預かる。
- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。
- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。ただし利用上限の解除確定後の作業再開入力(#389)は確認した起動にだけ渡し、別の起動へは再開指示を持ち越さない。まとめを同梱していた場合は、従来の預かり発言のまとめだけを同じ既読位置で持ち越す。
- **利用上限の預かりが先である(#294・#342)。** 利用上限で止まっている席への発言は、今までどおりその預かり(受け箱)に入り、この列には来ない。許可待ちが終わったときに席が利用上限で止まっていれば、預かった発言は打たずに利用上限の預かりへ移し、解除のまとめと一緒に一度だけ渡る。利用上限の解除のまとめを打つときに席が許可待ちであれば、まとめもこの列で預かり、待ちの後に打つ。まとめは解除そのものであるため、その後にまた上限で止まっていても利用上限の預かりへは戻さない。順番が一つだけ崩れる場合がある:許可待ちの間にこの列へ入った発言は、その待ちの途中で利用上限が始まると、利用上限が預かった発言の後ろに並ぶ。
- **端末で答えることは変えない(#336)。** 預かるのは部屋がアプリから打ち込む発言だけである。人が端末の欄に打つキーは今までどおり届き、許可の画面に答えられる。
- **記録する。** 預かった発言と、渡したときの行き先(`typed`:端末へ打った、`to-limit`:利用上限の預かりへ移した、`refused`:端末が受け付けず列へ戻した)を、上の `logs\permission-requests.log` に一行ずつ書く(`prompt_hold::log_line`)。発言の本文は書かず、`message_id` だけを書く。
Expand Down
Loading
Loading