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
14 changes: 12 additions & 2 deletions crates/mcp-config/src/claude_notice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("(返信不要)"));
}

}
29 changes: 21 additions & 8 deletions crates/mcp-config/src/claude_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
}
}
}
4 changes: 4 additions & 0 deletions crates/mcp-config/src/limit_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ mod tests {
message_id: "recovery".into(),
text: input(&gate.release()),
post: None,
resume: None,
launch_bound: true,
relaunch_text: None,
};
Expand All @@ -104,13 +105,15 @@ mod tests {
message_id: "recovery".into(),
text: input(&[]),
post: None,
resume: None,
launch_bound: true,
relaunch_text: None,
};
let ordinary = Item {
message_id: "post".into(),
text: "ordinary room post".into(),
post: None,
resume: None,
launch_bound: false,
relaunch_text: None,
};
Expand Down Expand Up @@ -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()),
};
Expand Down
42 changes: 41 additions & 1 deletion crates/mcp-config/src/prompt_hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,17 @@ pub struct Item {
pub post: Option<Held>,
/// 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<crate::claude_resume::Launch>,
/// 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.resume.is_some() {
return None;
}
if self.launch_bound {
self.text = self.relaunch_text.take()?;
self.launch_bound = false;
Expand Down Expand Up @@ -195,7 +200,7 @@ pub fn deliver(
limit: impl FnOnce(&Held) -> bool,
type_in: impl FnOnce(&str) -> bool,
) -> Result<Delivered, Item> {
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) {
Expand All @@ -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 {
Expand All @@ -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,
}
Expand Down Expand Up @@ -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());
}

}
2 changes: 1 addition & 1 deletion docs/3-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)たっても拒否も正常応答も読めなければ、「応答を確認できませんでした」と知らせ、自動の再開をそこで止める。答えない端末に合図を重ねて、入力欄へ溜めないためである。保留は続き、人が端末で手動再開すれば戻る。その手動の試行が拒否され、解除予定を名指せば、自動の再開がまた始まる。
Expand Down
36 changes: 9 additions & 27 deletions src-tauri/src/claude_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,33 +321,8 @@ impl ClaudeLimits {
}
match action {
Some(resume::Action::Nudge) => {
let posted = crate::room::post_app_notice(
app,
&room,
topic,
&notice::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, &notice::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(
Expand All @@ -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
Expand Down Expand Up @@ -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)),
};
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/codex_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/hook_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ impl HookSeats {
None => return,
}
};
if item.resume.as_ref().is_some_and(|launch| !app.state::<crate::claude_limit::ClaudeLimits>().resume_current(app, launch, &pty_id)) {
continue;
}
let id = item.message_id.clone();
let at = crate::room::now_iso();
let delivered = prompt_hold::deliver(
Expand Down Expand Up @@ -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::<crate::claude_limit::ClaudeLimits>().resume_current(app, launch, pty_id)) {
return false;
}
match app.state::<HookSeats>().pass(app, pty_id, item) {
Some(item) => app.state::<crate::pty::PtyState>().type_in(pty_id, item.text),
None => true,
Expand Down
Loading
Loading