From ba560b545b3747df6708ba2d60329dc6abe36633 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 12 Oct 2026 02:24:57 +0900 Subject: [PATCH] fix(room): deliver Claude resume notice through the regular room path --- crates/mcp-config/src/claude_notice.rs | 14 +++++++-- crates/mcp-config/src/claude_resume.rs | 29 ++++++++++++----- crates/mcp-config/src/limit_recovery.rs | 4 +++ crates/mcp-config/src/prompt_hold.rs | 42 ++++++++++++++++++++++++- docs/3-accounts.md | 2 +- src-tauri/src/claude_limit.rs | 36 ++++++--------------- src-tauri/src/codex_limit.rs | 1 + src-tauri/src/hook_activity.rs | 6 ++++ src-tauri/src/room.rs | 40 +++++++++++++++++++---- 9 files changed, 129 insertions(+), 45 deletions(-) diff --git a/crates/mcp-config/src/claude_notice.rs b/crates/mcp-config/src/claude_notice.rs index 9e7d5f0..24aa92a 100644 --- a/crates/mcp-config/src/claude_notice.rs +++ b/crates/mcp-config/src/claude_notice.rs @@ -249,9 +249,9 @@ pub fn reset_known(name: &str, stop: &Stop, clock: &str) -> String { }; format!("{who} の解除予定は {clock} と分かりました。回復は未確認です。{action}") } -/// The parent's resume attempt (#357), told as the nudge is typed. +/// The parent resume check delivered as one room notice (#399). pub fn nudged(name: &str) -> String { - format!("{name} の親セッションの解除予定時刻を過ぎたため、端末へ再開の合図を一度打ちました。正常親応答を確認するまで投稿の保留を続けます。") + format!("{name} の親セッションの解除予定時刻を過ぎたため、利用上限の解除を確認します。{name} の親セッションだけが作業を始めず、部屋にも返信せず、端末で一言だけ応答してください。正常親応答を確認するまで投稿の保留を続けます。") } /// The confirmed recovery, whether the nudge or a person's input drew it. pub fn resumed(name: &str, held: usize) -> String { @@ -524,4 +524,14 @@ mod tests { assert!(!text.contains("(返信不要)")); } } + #[test] + fn resume_notice_requests_terminal_answer_without_claiming_delivery() { + let text = nudged("seat"); + assert!(text.contains("解除予定時刻を過ぎた")); + assert!(text.contains("一言だけ応答")); + assert!(text.contains("作業を始めず、部屋にも返信せず")); + assert!(!text.contains("打ちました")); + assert!(!text.contains("(返信不要)")); + } + } diff --git a/crates/mcp-config/src/claude_resume.rs b/crates/mcp-config/src/claude_resume.rs index bffd667..7877827 100644 --- a/crates/mcp-config/src/claude_resume.rs +++ b/crates/mcp-config/src/claude_resume.rs @@ -16,13 +16,23 @@ use super::codex::limit::{BACKOFF, GRACE}; /// response) before the attempt counts as unanswered. pub const ANSWER_WAIT: i64 = 120; -/// The one line typed into the terminal. It asks for no work and no room post: -/// any answer at all writes the parent response the gate is waiting for. -pub const NUDGE: &str = "利用上限の解除を確かめるための合図です(Pullcept)。作業は始めず、部屋にも返信せず、一言だけ応答してください。"; +/// Internal routing metadata, never deserialized from room/socket posts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Launch { + pub topic: String, + pub account: String, + pub nonce: String, + pub pty: String, +} +impl Launch { + pub fn matches(&self, topic: &str, account: &str, nonce: &str, pty: &str) -> bool { + self.topic == topic && self.account == account && self.nonce == nonce && self.pty == pty + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { - /// Type [`NUDGE`] now. The attempt counts as sent from this moment. + /// Send the resume room notice now. The attempt counts as sent from this moment. Nudge, /// The last nudge drew no answer within [`ANSWER_WAIT`]. Automatic /// attempts stop until the next rejection arms them again. @@ -307,9 +317,12 @@ mod tests { } #[test] - fn nudge_is_one_line_and_asks_for_no_work() { - assert!(!NUDGE.contains('\n')); - assert!(NUDGE.contains("作業は始めず")); - assert!(NUDGE.chars().count() < 80); + fn resume_authority_requires_every_launch_key() { + let launch = Launch { topic: "t".into(), account: "a".into(), nonce: "n".into(), pty: "p".into() }; + assert!(launch.matches("t", "a", "n", "p")); + for keys in [("other", "a", "n", "p"), ("t", "other", "n", "p"), + ("t", "a", "old", "p"), ("t", "a", "n", "old")] { + assert!(!launch.matches(keys.0, keys.1, keys.2, keys.3)); + } } } diff --git a/crates/mcp-config/src/limit_recovery.rs b/crates/mcp-config/src/limit_recovery.rs index 7a58905..af33c7f 100644 --- a/crates/mcp-config/src/limit_recovery.rs +++ b/crates/mcp-config/src/limit_recovery.rs @@ -85,6 +85,7 @@ mod tests { message_id: "recovery".into(), text: input(&gate.release()), post: None, + resume: None, launch_bound: true, relaunch_text: None, }; @@ -104,6 +105,7 @@ mod tests { message_id: "recovery".into(), text: input(&[]), post: None, + resume: None, launch_bound: true, relaunch_text: None, }; @@ -111,6 +113,7 @@ mod tests { message_id: "post".into(), text: "ordinary room post".into(), post: None, + resume: None, launch_bound: false, relaunch_text: None, }; @@ -141,6 +144,7 @@ mod tests { message_id: "recovery-watermark".into(), text: input(&held), post: None, + resume: None, launch_bound: true, relaunch_text: Some(fallback.clone()), }; diff --git a/crates/mcp-config/src/prompt_hold.rs b/crates/mcp-config/src/prompt_hold.rs index fe19806..935d361 100644 --- a/crates/mcp-config/src/prompt_hold.rs +++ b/crates/mcp-config/src/prompt_hold.rs @@ -58,12 +58,17 @@ pub struct Item { pub post: Option, /// Recovery continuation belongs only to the launch that confirmed it. pub launch_bound: bool, + /// Only the app resume-notice route supplies this authority. + pub resume: Option, /// The existing held-post digest carried across relaunch, without continuation. pub relaunch_text: Option, } impl Item { fn for_relaunch(mut self) -> Option { + if self.resume.is_some() { + return None; + } if self.launch_bound { self.text = self.relaunch_text.take()?; self.launch_bound = false; @@ -195,7 +200,7 @@ pub fn deliver( limit: impl FnOnce(&Held) -> bool, type_in: impl FnOnce(&str) -> bool, ) -> Result { - if item.post.as_ref().is_some_and(limit) { + if item.resume.is_none() && item.post.as_ref().is_some_and(limit) { return Ok(Delivered::ToLimit); } if type_in(&item.text) { @@ -215,6 +220,7 @@ mod tests { Item { message_id: id.into(), text: format!("[pullcept] {id}"), + resume: None, launch_bound: false, relaunch_text: None, post: Some(Held { @@ -232,6 +238,7 @@ mod tests { message_id: id.into(), text: format!("[pullcept] digest {id}"), post: None, + resume: None, launch_bound: false, relaunch_text: None, } @@ -496,4 +503,37 @@ mod tests { hand_over(&mut q, || false, |held| gate.hold(|| held.clone()).is_some(), &mut typed); assert_eq!(ids(&typed), ["digest notice"]); } + #[test] + fn resume_room_notice_keeps_its_body_and_id_and_bypasses_only_the_owner() { + let id = "resume-id"; + let body = crate::claude_notice::nudged("seat"); + let launch = crate::claude_resume::Launch { topic: "topic".into(), account: "owner".into(), nonce: "current".into(), pty: "pty".into() }; + let mut notice = post(id); + notice.text = body.clone(); + notice.post.as_mut().unwrap().content = body.clone(); + notice.resume = Some(launch); + let mut queue = Queue::new(); + assert!(queue.pass(true, notice.clone()).is_none()); + assert!(!queue.begin(true)); + assert!(queue.begin(false)); + let held = queue.next(false).unwrap(); + assert_eq!(held.message_id, id); + assert_eq!(held.post.as_ref().unwrap().message_id, id); + assert_eq!(held.post.as_ref().unwrap().content, body); + assert_eq!(deliver(held, |_| panic!("owner notice must bypass the limit hold"), |text| text == body), Ok(Delivered::Typed)); + let mut other_seat = notice.clone(); + other_seat.resume = None; + assert_eq!(deliver(other_seat, |_| true, |_| panic!("other limited seat must keep the same notice")), Ok(Delivered::ToLimit)); + let mut forged = post(id); + forged.text = body.clone(); + forged.post.as_mut().unwrap().content = body; + assert_eq!(deliver(forged, |_| true, |_| false), Ok(Delivered::ToLimit)); + let mut stale_queue = Queue::new(); + stale_queue.pass(true, notice.clone()); + assert!(stale_queue.carry().is_empty()); + let mut new_queue = Queue::new(); + new_queue.put_first(notice); + assert!(new_queue.is_empty()); + } + } diff --git a/docs/3-accounts.md b/docs/3-accounts.md index 1279370..46b0c52 100644 --- a/docs/3-accounts.md +++ b/docs/3-accounts.md @@ -712,7 +712,7 @@ installer は元 hook を同じ folder の一意な `.legacy-*.ps1` に byte 一 **解除予定時刻を過ぎたら、Pullcept が止まっている席の端末へ再開の合図を一度打つ。** #342 では、予定時刻を過ぎても人が端末に打ち込むまで席は「制限中」のままだった(2026-10-09 22:16 に止まった Lay と Lin は、解除予定 10/10 01:20 を過ぎても朝まで戻らなかった)。#342 の「正常な親応答を確かめてから保留を解く」という条件は変えない。変えるのは、その確認を Pullcept が自分で始めるところだけである。対象は親だけで、子エージェントの上限(#355 で親と分けたもの)には打たない。 - **いつ打つか。** 親の拒否が `quotaLimits.resetsAt` で解除予定を名指していれば、その 30 秒後(Codex の問い合わせと同じ `GRACE`)。解除予定が分からなければ打たない——打つ時刻の根拠が無いので、端末での手動再開を待つ。停止の知らせもそのどちらかを言う。 -- **何を打つか。** `claude_resume::NUDGE` の一行を、部屋の札(発言者 `Pullcept(自動通知)`、`role` は `app`)を付けて一回の打ち込みで打つ。作業を始めさせず、部屋への返事も求めない。中身はどうでもよく、席が何か一言でも答えれば、それが #342 の待っている正常な親応答になる。札の `message_id` は、同時に部屋へ出す「再開の合図を一度打ちました」の知らせの id である。許可待ちの席(#346)では、その待ちが終わるまで合図も預かる。人が端末に打ちかけのときは、ほかの打ち込みと同じく欄が空くまで待つ。 +- **何を届けるか(#399)。** 部屋に出す再開通知そのものを、同じ本文・`message_id` で本人にも通常の部屋配送経路から届ける。「解除予定時刻を過ぎたので解除を確認する。一言応答し、作業や部屋への返信はしない」と依頼し、配送完了を未確認のまま断言しない。この通知だけは、アプリ内部で付けた topic・account・nonce・current PTY に一致する本人の現 launch の利用上限保留を迂回する。本文・表示名・socket の role は迂回の根拠にならず、他の制限中の席と一般投稿は保留する。許可待ち中は通知を預かり、その待ちが終わっても本人向けの迂回を維持する。再起動した席へ古い launch の通知を持ち越さない。未送信入力があるときは通常配送と同じく欄が空くまで待つ。部屋への返信不要と端末での一言応答を区別するため、この確認通知には一般通知の「(返信不要)」を加えない。 - **返事が返ったら。** 正常な親応答を読んだところで、今までどおり保留を外す。部屋には「再開しました(正常親応答を確認)」と、預かった件数を知らせ、そのあと預かった発言のまとめを今までどおり席へ打つ。人の手動再開で戻ったときも同じ知らせである。 - **まだ止まっていたら。** 合図より後の時刻の親の拒否を読めば「まだ利用上限で止まっています」と、解除予定と次に試す時刻を部屋へ知らせ、間を空けてもう一度打つ。新しい拒否が名指す解除予定の 30 秒後が今から 1 分より先ならその時刻(5 時間の後に週次で止まった等)、そうでなければ 1 分・2 分・5 分・10 分・15 分と空け、以後は 15 分ごと(Codex の `BACKOFF`)。合図より前に書かれた拒否は合図の答えに数えない。 - **答えが無かったら。** 合図から 2 分(`ANSWER_WAIT`)たっても拒否も正常応答も読めなければ、「応答を確認できませんでした」と知らせ、自動の再開をそこで止める。答えない端末に合図を重ねて、入力欄へ溜めないためである。保留は続き、人が端末で手動再開すれば戻る。その手動の試行が拒否され、解除予定を名指せば、自動の再開がまた始まる。 diff --git a/src-tauri/src/claude_limit.rs b/src-tauri/src/claude_limit.rs index dec8165..e3f317c 100644 --- a/src-tauri/src/claude_limit.rs +++ b/src-tauri/src/claude_limit.rs @@ -321,33 +321,8 @@ impl ClaudeLimits { } match action { Some(resume::Action::Nudge) => { - let posted = crate::room::post_app_notice( - app, - &room, - topic, - ¬ice::nudged(&name), - Some(pty), - ); - let at = terminal_input::at(&crate::room::now_iso()); - let id = posted.unwrap_or_default(); - let text = terminal_input::compose( - &id, - crate::room::APP_SPEAKER, - terminal_input::ROLE_APP, - at.as_deref(), - &[], - resume::NUDGE, - ); - // One typing. A permission wait keeps it until the wait ends; - // the recovery itself is still read from the transcript. - let item = mcp_config::prompt_hold::Item { - message_id: id, - text, - post: None, - launch_bound: false, - relaunch_text: None, - }; - crate::hook_activity::type_or_keep(app, pty, item); + crate::room::post_claude_resume_notice(app, &room, ¬ice::nudged(&name), + resume::Launch { topic: topic.into(), account: account.into(), nonce: nonce.into(), pty: pty.into() }); } Some(resume::Action::NoAnswer) => { crate::room::post_app_notice( @@ -361,6 +336,12 @@ impl ClaudeLimits { None => {} } } + pub fn resume_current(&self, app: &AppHandle, launch: &resume::Launch, pty: &str) -> bool { + self.seats.lock().get(&(launch.topic.clone(), launch.account.clone())).is_some_and(|e| { + launch.matches(&launch.topic, &launch.account, &e.nonce, pty) + && e.pty.as_deref() == Some(pty) && e.current(app, &launch.topic, &launch.account) + }) + } pub fn hold(&self, pty: &str, post: impl FnOnce() -> Held) -> bool { let mut seats = self.seats.lock(); let Some(((_, account), e)) = seats @@ -551,6 +532,7 @@ fn round(app: &AppHandle, topic: &str, account: &str, nonce: &str, pty: &str) { message_id: watermark.clone().unwrap_or_default(), text: compose(&text), post: None, + resume: None, launch_bound: true, relaunch_text: limit::digest(&held).map(|digest| compose(&digest)), }; diff --git a/src-tauri/src/codex_limit.rs b/src-tauri/src/codex_limit.rs index 64b82db..c7c2ed8 100644 --- a/src-tauri/src/codex_limit.rs +++ b/src-tauri/src/codex_limit.rs @@ -313,6 +313,7 @@ impl Limiter { message_id: watermark.clone().unwrap_or_default(), text: compose(&text), post: None, + resume: None, launch_bound: true, relaunch_text: limit::digest(&held).map(|digest| compose(&digest)), }, diff --git a/src-tauri/src/hook_activity.rs b/src-tauri/src/hook_activity.rs index c188543..2453012 100644 --- a/src-tauri/src/hook_activity.rs +++ b/src-tauri/src/hook_activity.rs @@ -261,6 +261,9 @@ impl HookSeats { None => return, } }; + if item.resume.as_ref().is_some_and(|launch| !app.state::().resume_current(app, launch, &pty_id)) { + continue; + } let id = item.message_id.clone(); let at = crate::room::now_iso(); let delivered = prompt_hold::deliver( @@ -387,6 +390,9 @@ pub fn permission_answer( /// (#346). True when it was typed or kept: either way it is on its way, and /// the caller hands it nowhere else. False when the terminal is not running. pub fn type_or_keep(app: &AppHandle, pty_id: &str, item: Item) -> bool { + if item.resume.as_ref().is_some_and(|launch| !app.state::().resume_current(app, launch, pty_id)) { + return false; + } match app.state::().pass(app, pty_id, item) { Some(item) => app.state::().type_in(pty_id, item.text), None => true, diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index f04ce87..c4d5383 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -864,7 +864,7 @@ fn deliver( post: Post, last_seen: Option<&str>, ) -> Result { - deliver_except(app, room, room_id, origin, post, last_seen, None) + deliver_except(app, room, room_id, origin, post, last_seen, None, None) } /// [`deliver`], leaving one terminal untyped: the seat an app notice is about @@ -879,6 +879,7 @@ fn deliver_except( mut post: Post, last_seen: Option<&str>, except_pty: Option<&str>, + resume: Option<&mcp_config::claude_resume::Launch>, ) -> Result { let message_id = post.message_id.clone(); @@ -1000,7 +1001,7 @@ fn deliver_except( // No session in the room is not an error — the room accepts what is said // in it; a later joiner simply missed it, and can pull it. let role = role_of(app, room, origin, post.account.as_deref()); - type_into_sessions(app, room_id, &seats, origin, &role, &post, except_pty); + type_into_sessions(app, room_id, &seats, origin, &role, &post, except_pty, resume); let _ = app.emit( "room-message", @@ -1087,6 +1088,7 @@ fn type_into_sessions( role: &str, post: &Post, except_pty: Option<&str>, + resume: Option<&mcp_config::claude_resume::Launch>, ) { let ptys = app.state::(); let running = app.state::().seated(&ptys); @@ -1133,12 +1135,22 @@ fn type_into_sessions( .filter(|(origin, _, _)| target.origins.contains(origin)) .any(|(_, _, name)| post.to.contains(name)), }; - let held = limits.hold(&target.pty_id, post_for_hold) - || app.state::().hold(&target.pty_id, post_for_hold); + let recipient = seats.iter().find(|(origin, _, _)| target.origins.contains(origin)).and_then(|(_, account, _)| account.as_deref()); + let resume_for_target = resume.filter(|launch| { + launch.topic == room_id && recipient == Some(launch.account.as_str()) + && app.state::() + .resume_current(app, launch, &target.pty_id) + }); + // A replacement of the owner must not keep an old-launch check as an ordinary post. + if resume.is_some_and(|launch| launch.topic == room_id && recipient == Some(launch.account.as_str())) + && resume_for_target.is_none() { + continue; + } + let held = resume_for_target.is_none() && (limits.hold(&target.pty_id, post_for_hold) + || app.state::().hold(&target.pty_id, post_for_hold)); if held { continue; } // For addressed posts, the recipient's current call-name supersedes launch-time instructions. // A live rename does not need a new conversation or a synthetic user post. - let recipient = seats.iter().find(|(origin, _, _)| target.origins.contains(origin)).and_then(|(_, account, _)| account.as_deref()); let text = config::read_config(app).ok().and_then(|config| config.accounts.into_iter().find(|account| Some(account.id.as_str()) == recipient)).map(|account| { terminal_input::with_recipient_identity(&text, &post.to, account.address_name(), &account.name) }).unwrap_or_else(|| text.clone()); @@ -1149,6 +1161,7 @@ fn type_into_sessions( message_id: post.message_id.clone(), text: text.clone(), post: Some(post_for_hold()), + resume: resume_for_target.cloned(), launch_bound: false, relaunch_text: None, }, @@ -1176,6 +1189,20 @@ pub fn post_app_notice( content: &str, except_pty: Option<&str>, ) -> Option { + post_app_notice_routed(app, room, room_id, content, except_pty, None) +} + +pub(crate) fn post_claude_resume_notice(app: &AppHandle, room: &RoomState, content: &str, + launch: mcp_config::claude_resume::Launch) -> Option { + if !app.state::().resume_current(app, &launch, &launch.pty) { + return None; + } + let topic = launch.topic.clone(); + post_app_notice_routed(app, room, &topic, content, None, Some(launch)) +} + +fn post_app_notice_routed(app: &AppHandle, room: &RoomState, room_id: &str, content: &str, + except_pty: Option<&str>, resume: Option) -> Option { let outcome = deliver_except( app, room, @@ -1189,12 +1216,13 @@ pub fn post_app_notice( account: None, // Stamped by `deliver` off `app_origin`, as every post's is (#340). from_app: false, - content: room_floor::app_notice_content(content), + content: if resume.is_some() { content.to_string() } else { room_floor::app_notice_content(content) }, to: Vec::new(), ts: now_iso(), }, None, except_pty, + resume.as_ref(), ); match outcome { Ok(outcome) if outcome.delivered => outcome.message_id,