diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c8254..56a982d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **One key gets every capped session working again.** A usage cap ends a + session's turn and leaves it there — nothing retries — so recovering the fleet + used to mean attaching to each capped session in turn and typing "continue", + and the reset hours went missing overnight. `u` now sweeps every badged + session whose reset has passed, tells each to continue, and reports how many + it nudged, how many are still waiting on their window, and any the agent + refused. For now it fires only when you press it — resuming capped sessions + automatically once the window reopens is the intended next step, and this is + the half that will sit underneath it. Sessions already back at work are + untouched, and the badge drops as each nudge lands so a second press cannot + start a second agent on the same worktree. + - **Closing a task stops its agent session.** Voro used to leave every session it launched registered with the agent forever: a `claude agents` listing full of finished `voro-*` entries, each backed by a supervisor process that runs @@ -25,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `stop` verb (`{session}`), built in for `claude`; an agent without one, such as `codex`, behaves exactly as before, and a stop that fails leaves a line in `launches.log` rather than touching the transition. + - **Capped sessions are visible instead of silently stuck.** A usage cap does not kill a backgrounded agent — the supervisor stays alive and waits for the window to reset — so capped work used to ride the running strip looking @@ -217,6 +230,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A capped session's badge now shows the reset time it actually named.** Real + cap messages end with an upgrade prompt that mentions a usage limit of its + own, and that trailing mention was winning: it carries no time, so every + genuine cap badged as a bare `⚠ capped` and the strip could never say whether + the window had reopened. The prompt is now read as the boilerplate it is. + Verified against a real cap rather than the wordings this was first written + from. + +- **A quick message no longer wakes a session that cannot do anything.** The + `message` verb carried no `--permission-mode`, and the flag is per invocation + rather than a property of the session, so every resumed turn ran in ask mode + against a closed stdin: edits and commands stopped for approvals nobody could + give, and the refusals went to the launch log instead of the TUI. Sends looked + delivered and quietly did nothing. + - Two errors a first-time user is likeliest to meet now say what to do about them. An editor that will not run reports the variable and the command it came from rather than a bare exit code — `could not run $EDITOR diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index f238604..32734f5 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -110,6 +110,14 @@ pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}"; /// and that supervisor refuses a headless `--resume` for as long as it lives, /// so the plain resume was a send that could never land (DESIGN.md §8). /// +/// It carries `--permission-mode` for the same reason `dispatch` does. The flag +/// is per invocation rather than a property of the session, so a resumed turn +/// without it runs in the default ask mode against a stdin at `/dev/null`: every +/// edit and every command outside the allowlist stops for an approval nobody can +/// give, and the refusals land in the launch log rather than the TUI. A send +/// like that appears to have been delivered and quietly does nothing, which is +/// the one failure a fire-and-forget channel cannot report. +/// /// The claude `logs` verb replays a background session's screen, which is the /// only place a usage cap is legible (DESIGN.md §8): `claude agents --json` /// reports a capped session as plain `blocked`, the same word a permission @@ -138,7 +146,7 @@ dispatch = \"claude --bg --name \\\"{session_name}\\\" --permission-mode auto sessions = \"claude agents --json\" attach = \"claude attach {session}\" resume = \"claude --resume {session}\" -message = \"claude -p --resume {session} --fork-session --session-id {new_session} \\\"$(cat {prompt_file})\\\"\" +message = \"claude -p --resume {session} --fork-session --session-id {new_session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\" logs = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\" stop = \"claude stop \\\"$(printf %.8s {session})\\\"\" plan = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\" diff --git a/crates/voro-core/src/cap.rs b/crates/voro-core/src/cap.rs index 268fad3..424e8bc 100644 --- a/crates/voro-core/src/cap.rs +++ b/crates/voro-core/src/cap.rs @@ -50,6 +50,21 @@ pub const CAP_SIGNATURES: [&str; 8] = [ /// ("Server is temporarily limiting requests (not your usage limit)"). const NOT_CAP_QUALIFIERS: [&str; 4] = ["approaching", "% of your", "not your", "close to your"]; +/// Phrases that mean the signature after them is not a *report* at all. Every +/// real limit message ends with the upgrade prompt — `/upgrade to increase your +/// usage limit.` — which contains a signature of its own and, being last, would +/// otherwise be the one that decides. +/// +/// That matters twice over. It is the *only* signature in a genuine cap whose +/// window holds no reset time, so letting it decide drops the time from every +/// real cap; and it says nothing about whether the session is held, so a warning +/// that ever trailed the same prompt would badge as a cap. Both go away once the +/// prompt is read as the boilerplate it is: skipped when choosing which +/// signature speaks, rather than negating like a qualifier — a qualifier means +/// "this one is not a cap", and skipping instead would let a genuine earlier cap +/// speak past a warning that had since replaced it. +const MENTION_PREFIXES: [&str; 2] = ["/upgrade", "increase your"]; + /// How much text after a matched signature is read for the reset time that /// goes on the badge. const WINDOW: usize = 200; @@ -110,8 +125,7 @@ impl CapReading { pub fn read_cap(tail: &str) -> Option { let text = strip_ansi(tail).to_lowercase(); let (at, signature) = last_signature(&text)?; - let before = &text[floor_boundary(&text, at.saturating_sub(QUALIFIER_WINDOW))..at]; - if NOT_CAP_QUALIFIERS.iter().any(|q| before.contains(q)) { + if look_back(&text, at, &NOT_CAP_QUALIFIERS) { return None; } let after = &text[at..ceil_boundary(&text, (at + signature.len() + WINDOW).min(text.len()))]; @@ -120,15 +134,23 @@ pub fn read_cap(tail: &str) -> Option { }) } -/// The position and text of the last cap signature in `text`, which must -/// already be lowercased. +/// The position and text of the last cap signature in `text` that reports +/// something, which must already be lowercased. Signatures the upgrade prompt +/// merely mentions ([`MENTION_PREFIXES`]) are not candidates. fn last_signature(text: &str) -> Option<(usize, &'static str)> { CAP_SIGNATURES .iter() - .filter_map(|sig| text.rfind(sig).map(|at| (at, *sig))) + .flat_map(|sig| text.match_indices(sig).map(|(at, _)| (at, *sig))) + .filter(|(at, _)| !look_back(text, *at, &MENTION_PREFIXES)) .max_by_key(|(at, _)| *at) } +/// Whether any of `phrases` appears in the short span of `text` before `at`. +fn look_back(text: &str, at: usize, phrases: &[&str]) -> bool { + let before = &text[floor_boundary(text, at.saturating_sub(QUALIFIER_WINDOW))..at]; + phrases.iter().any(|p| before.contains(p)) +} + /// Drop terminal escape sequences, keeping the spacing the surviving text had /// on screen. /// @@ -262,6 +284,45 @@ mod tests { assert_eq!(reading.reset_label().as_deref(), Some("21:50")); } + /// The wording an actual five-hour cap turned out to use, captured from + /// three live sessions on 2026-08-13 — the first real cap Voro has seen, + /// every earlier case having been read out of the agent's own binary. + /// + /// The upgrade prompt riding along behind it is the whole point: it carries + /// a signature of its own, it is last, and its window holds no time, so + /// before it was read as boilerplate every genuine cap badged without the + /// reset time it had actually named. + #[test] + fn the_real_cap_message_reads_with_its_reset_time() { + let reading = read_cap( + "You've hit your session limit · resets 6:40pm (Europe/London)\n\ + /upgrade to increase your usage limit.", + ) + .expect("a cap"); + assert_eq!(reading.reset_label().as_deref(), Some("18:40")); + } + + /// The real warning short of that cap, captured from a session that went on + /// working — and which must stay unbadged even though the same upgrade + /// prompt can follow it. + #[test] + fn the_real_warning_short_of_the_cap_is_not_capped() { + assert_eq!( + read_cap( + "You've used 98% of your session limit · resets 6:40pm (Europe/London)\n\ + /upgrade to keep using Claude Code" + ), + None + ); + assert_eq!( + read_cap( + "You've used 99% of your session limit · resets 6:40pm (Europe/London)\n\ + /upgrade to increase your usage limit." + ), + None + ); + } + /// The other wordings the agent uses for the same condition. #[test] fn every_cap_wording_reads_as_capped() { diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 48ad934..cb6bedc 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -375,6 +375,13 @@ fn state_jump_verb(state: TaskState) -> Option { /// because its session is dead: a headless resume there would restart the work /// with no tracked pid and no session row, invisible to the reconciler. /// Redispatch is the honest path for that, and `A` the one for the rest. +/// What a nudged session is told (DESIGN.md §8). One word, because the session +/// already holds the whole task: its transcript, its worktree and whatever it +/// had half-written when the window closed. Anything longer would be Voro +/// restating a brief the agent can already read, and would risk redirecting work +/// that was only ever interrupted. +const NUDGE: &str = "continue"; + fn state_accepts_message(state: TaskState) -> bool { matches!( state, @@ -1581,6 +1588,7 @@ impl App { // same pairing as `r`/`R` (DESIGN.md §9). KeyCode::Char('a') => self.message_session(), KeyCode::Char('A') => self.jump_into_session(), + KeyCode::Char('u') => self.nudge_capped(), KeyCode::Char('l') => self.view_session_log(), KeyCode::Char('w') => self.hand_off_selected(), _ => {} @@ -2105,6 +2113,120 @@ impl App { }; } + /// Nudge every cap-stuck session whose window has reopened (DESIGN.md §8). + /// + /// A usage cap ends a session's turn and leaves it sitting there: nothing + /// retries, so the work waits for a human however long ago the window + /// reopened. Walking the strip by hand costs an attach, a typed word and a + /// detach per session, which is why the reset hours go missing overnight. + /// This is that walk as one key. + /// + /// A keypress starts it, which is where this begins rather than where it is + /// meant to end: firing automatically once the window reopens is wanted, and + /// nothing here is shaped to prevent it. Automation needs a trigger, not a + /// channel — the same `reset_passed` test, read on the tick instead of on + /// the key — so it layers on top of this rather than replacing it. Manual + /// first only because a badge that false-positives costs one wasted keypress + /// today and an unwatched agent once it is automatic. + /// + /// Both guards the quick-message key answers to are stood down here, and the + /// cap reading is what earns that: [`state_accepts_message`] refuses a + /// `running` task because its session is mid-turn, and `send_session_message` + /// refuses a session that is still up — but a capped session is precisely + /// one that is up, `running`, and *not* mid-turn. Nothing else in the cockpit + /// can tell those apart, so nothing else may skip the guards. + fn nudge_capped(&mut self) { + let now = self.now_minutes; + // A cap whose time never parsed is the operator's call, not the clock's: + // they pressed the key, and a nudge sent early is refused by the agent + // rather than doing harm. This is the one rule an automatic sweep would + // have to invert — with no keypress behind it, an untimed cap has + // nothing saying the window has opened. + let (mut ready, mut waiting): (Vec, Vec) = (Vec::new(), Vec::new()); + for (id, reading) in &self.caps { + let due = + reading.reset_minutes.is_none() || now.is_some_and(|now| reading.reset_passed(now)); + if due { &mut ready } else { &mut waiting }.push(*id); + } + // A sweep visits the strip in a stable order rather than the map's. + ready.sort_unstable(); + if ready.is_empty() { + self.status = Some(if waiting.is_empty() { + "no session is capped".into() + } else { + format!( + "{} capped session{} — none has reached its reset yet", + waiting.len(), + if waiting.len() == 1 { "" } else { "s" } + ) + }); + return; + } + + let mut sent = 0usize; + let mut refused: Vec = Vec::new(); + for task_id in ready { + match self.nudge_one(task_id) { + Ok(()) => { + sent += 1; + // The badge goes at once rather than waiting out the probe + // interval, so a second press cannot put a second agent on + // the same worktree. A session that is still capped when the + // next reading lands badges again. + self.caps.remove(&task_id); + } + Err(e) => refused.push(format!("{task_id}: {e}")), + } + } + + let mut note = format!( + "nudged {sent} capped session{}", + if sent == 1 { "" } else { "s" } + ); + if !waiting.is_empty() { + note.push_str(&format!(" — {} still before its reset", waiting.len())); + } + if !refused.is_empty() { + note.push_str(&format!(" — refused {}", refused.join("; "))); + } + self.status = Some(note); + let refreshed = self.refresh(); + self.report(refreshed); + } + + /// Say [`NUDGE`] into one capped session and record the send, exactly as the + /// quick-message key does — the same verb, the same forked reference, the + /// same tracked pid — so a nudged session stays as visible to the reconciler + /// as a messaged one. + fn nudge_one(&mut self, task_id: i64) -> Result<(), String> { + let target = self + .message_target(task_id) + .ok_or_else(|| self.status.clone().unwrap_or_else(|| "no session".into()))?; + let cwd = self.task_checkout(task_id).map_err(|e| e.to_string())?; + let sent = crate::dispatch::send_message( + &self.dispatch_ctx, + crate::dispatch::SessionMessage { + task_id, + template: &target.template, + session_ref: &target.session_ref, + message: NUDGE, + cwd, + }, + )?; + let pid = sent.pid(); + if let Err(e) = + self.store + .record_session_send(target.session_id, sent.new_session_ref(), pid) + { + sent.abandon(); + return Err(format!( + "recording the send failed ({e}); the spawned agent (pid {pid}) was killed" + )); + } + sent.confirm(&self.dispatch_ctx); + Ok(()) + } + /// Resolve what a quick message needs, reporting whichever piece is missing /// on the status line exactly as `jump_into_session` does. Config is loaded /// fresh, so an agent that gained a `message` verb since the TUI started @@ -5544,13 +5666,19 @@ mod tests { } else { String::new() }; + // The nudge sweep (task #416) goes out through the same `message` verb + // the quick-message key uses, so the stub defines one that records what + // it was told and exits — a delivered send, as far as the caller can see. + let delivered = project_path.parent().unwrap().join("delivered.txt"); std::fs::write( &ctx.agents_path, format!( "default_agent = \"stub\"\n\n[agents.stub]\n\ dispatch = \"cat {{prompt_file}} && sleep 30\"\n\ - sessions = \"cat '{}'\"\n{logs}", - listing.display() + sessions = \"cat '{}'\"\n\ + message = \"cat {{prompt_file}} >> '{}' # {{session}}\"\n{logs}", + listing.display(), + delivered.display() ), ) .unwrap(); @@ -5658,6 +5786,122 @@ mod tests { let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); } + // --- nudging capped sessions back to work (task #416) --- + + /// What a nudge was told, if anything. + fn delivered(project_path: &std::path::Path) -> Option { + std::fs::read_to_string(project_path.parent().unwrap().join("delivered.txt")).ok() + } + + /// The headline case (DESIGN.md §8): one key puts every capped session whose + /// window has reopened back to work, without the operator visiting any of + /// them. Both the guards the quick-message key answers to are stood down — + /// the task is `running` and its session is listed live — because the cap + /// reading says the session is up and idle rather than mid-turn. + #[test] + fn u_nudges_a_capped_session_whose_window_has_reopened() { + let (mut app, task_id, project_path) = + cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + settle_cap(&mut app, task_id, true); + // An hour past the 21:50 the agent named. + app.now_minutes = Some(22 * 60 + 50); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!( + delivered(&project_path).as_deref().map(str::trim), + Some(NUDGE), + "the session was told to continue: {:?}", + app.status + ); + assert!( + !app.caps.contains_key(&task_id), + "the badge goes with the nudge, so a second press cannot double it" + ); + assert_eq!( + app.store.task(task_id).unwrap().state, + TaskState::Running, + "a nudge is a send, not a transition" + ); + assert!( + app.status + .as_deref() + .is_some_and(|s| s.contains("nudged 1")), + "{:?}", + app.status + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// A cap whose window has not reopened is left alone: nudging it would spend + /// a send on a session the agent will only refuse again, and the badge is + /// the operator's cue that there is nothing to do yet. + #[test] + fn u_leaves_a_capped_session_that_is_still_waiting() { + let (mut app, task_id, project_path) = + cap_env(true, "Session limit reached - Retrying in 5m (9:50pm)"); + settle_cap(&mut app, task_id, true); + // An hour short of the 21:50 the agent named. + app.now_minutes = Some(20 * 60 + 50); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!(delivered(&project_path), None, "nothing was sent"); + assert!( + app.caps.contains_key(&task_id), + "the badge stands until the window opens" + ); + assert!( + app.status + .as_deref() + .is_some_and(|s| s.contains("none has reached its reset")), + "{:?}", + app.status + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// The key on a healthy fleet says so and sends nothing, so a stray press + /// costs no agent turns. + #[test] + fn u_on_an_uncapped_fleet_sends_nothing() { + let (mut app, _, project_path) = cap_env(true, "running the test suite"); + for _ in 0..20 { + app.poll_cap_probes(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + key(&mut app, KeyCode::Char('u')); + + assert_eq!(delivered(&project_path), None); + assert_eq!(app.status.as_deref(), Some("no session is capped")); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + + /// A cap whose reset time never parsed is still nudgeable: the operator + /// pressing the key is the judgement the clock could not supply, and a send + /// that turns out to be early is refused by the agent rather than doing harm. + #[test] + fn u_nudges_a_cap_that_named_no_reset_time() { + let (mut app, task_id, project_path) = cap_env(true, "Weekly limit reached"); + settle_cap(&mut app, task_id, true); + assert_eq!(app.caps[&task_id].reset_minutes, None); + + key(&mut app, KeyCode::Char('u')); + + assert_eq!( + delivered(&project_path).as_deref().map(str::trim), + Some(NUDGE), + "{:?}", + app.status + ); + + let _ = std::fs::remove_dir_all(project_path.parent().unwrap()); + } + /// `A` on a running task whose session is still listed queues the agent's /// `attach` command — ref substituted, project path as cwd — for main() to /// run with the TUI suspended. diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 787d004..7093812 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -2079,6 +2079,7 @@ fn key_map(screen: Screen, no_projects: bool) -> Vec { ]); actions.extend(pairs(MESSAGE_KEYS)); actions.extend([ + ("u", "nudge capped sessions past their reset"), ("l", "page the session log"), ("w", "hand a review task off, to wait"), ]); @@ -2116,6 +2117,7 @@ fn key_map(screen: Screen, no_projects: bool) -> Vec { ]); actions.extend(pairs(MESSAGE_KEYS)); actions.extend([ + ("u", "nudge capped sessions past their reset"), ("l", "page the session log"), ("w", "hand a review task off, to wait"), ]); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index df8f28a..529caa6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -315,7 +315,7 @@ A session's entry in the *agent's own* registry follows its row in the same way: **Which of the two sources owns a session** is a property of the launch, not of anything else the row happens to carry, so it is recorded at the spawn (`sessions.liveness_source`, §5) by the code that performed it rather than inferred at reconciliation. Dispatch and the headless refine record the flavour of the agent's `dispatch` template — `listing` where the agent defines a `sessions` verb, since such a launch may hand the work to a supervisor and the listing is then the only source that can answer, `pid` where it defines none and the spawned pid is all there is — and the interactive refine records `pid`, being a foreground `plan` child Voro owns. The recorded source names which probe answers, not whether the row's pid is read at all: a live pid still proves the session live whichever source owns it (above). The rule this replaces read the flavour off the absence of a session ref: a `refining` session with no ref was taken for the interactive round. That was right for the flavour it was written for and wrong for the one it could not tell apart, because a headless round whose ref capture timed out has no ref either, and pid-checking it finalised the round `failed` within a second of launch while its agent went on rewriting the body. The late-rewrite backstop (§6) corrects the *marker* when the rewrite finally lands, but not the early exit from `refining` — and leaving that state early is what lets a second window hand a verdict to a proposal whose body is about to be replaced, the race the state exists to close. Recorded rather than guessed, such a round is simply unprobeable until its ref appears or its listing answers, and is left alone exactly as a ref-less dispatch already was: liveness Voro cannot determine is never grounds for finalising a session. The column is additive, and sessions already open when it lands default to `listing` — what every dispatch under an agent with a `sessions` verb already was, and the direction that leaves a session alone rather than killing a live one. -**Usage-cap detection** stays a substring match over a few KB of text for phrases like "usage limit" — deliberately narrow, and asymmetric on purpose: a cap worded in a way the list does not know is reported `failed` rather than `capped`, a labelling gap and not a functional one since both outcomes stall the task for redispatch identically, whereas a *false* cap would badge healthy work as stuck and teach the operator to disbelieve the marker. The list is therefore qualified rather than merely widened: "approaching", "80% of your", "not your" each take a match back, because an agent says all three about a limit it has not hit. What the list must cover is what agents actually write, which is not what the original three phrases assumed — Claude Code words a five-hour cap "Session limit reached" and a weekly one "Weekly limit reached", neither of which contains "usage limit", "rate limit" or "quota exceeded", so the generic phrases matched almost no real cap. +**Usage-cap detection** stays a substring match over a few KB of text for phrases like "usage limit" — deliberately narrow, and asymmetric on purpose: a cap worded in a way the list does not know is reported `failed` rather than `capped`, a labelling gap and not a functional one since both outcomes stall the task for redispatch identically, whereas a *false* cap would badge healthy work as stuck and teach the operator to disbelieve the marker. The list is therefore qualified rather than merely widened: "approaching", "80% of your", "not your" each take a match back, because an agent says all three about a limit it has not hit. What the list must cover is what agents actually write, which is not what the original three phrases assumed — Claude Code words a five-hour cap "Session limit reached" and a weekly one "Weekly limit reached", neither of which contains "usage limit", "rate limit" or "quota exceeded", so the generic phrases matched almost no real cap. A real cap also *ends* with an upgrade prompt — "/upgrade to increase your usage limit" — which carries a signature of its own, and that prompt is read as boilerplate rather than as a report: it is skipped when deciding which signature speaks. Left in, it decides every genuine cap, since it comes last; and because it is the one signature with no reset time after it, the badge lost the time the agent had actually named on every real cap. It would also let a mere warning badge as a cap on any screen where the same prompt trailed it. Skipping is not the same as qualifying it: a qualifier means "this one is not a cap" and would let an earlier, superseded cap speak past the warning that had replaced it. *Which text* is scanned is the substantive question, and Voro's own launch log is the wrong answer for the launches that matter. Under a supervisor-owned launch (`claude --bg`) the launcher exits at birth having written nothing but the backgrounding banner, so scanning that log could essentially never report `capped` however the session died. The agent's verb set therefore gains an optional **`logs`**: a session in (`{session}`), that session's recent output out. It is an opaque per-agent contract like the rest and degrades like the rest — an agent that defines none is classified from the launch-log tail exactly as before, and the built-in `codex` defines none. The text it returns may be a terminal capture rather than a log, so escape sequences are stripped before matching, with cursor movement becoming a space (it stands for the gap between two words) and colour vanishing (it does not, and would otherwise split the phrase it styles). @@ -323,6 +323,12 @@ That same channel answers a question the reconciler could not previously ask at Three properties keep that badge honest. It carries **no state change**: `stalled` means "dead dispatch, redispatch me", and a capped session is neither dead nor in need of redispatch, so the task stays `running` and the session stays open. It is **not schema**: the reading is held in memory, recomputed, and never written, which is what makes it self-clearing — the operator continues the session, its next output no longer says "limit reached", and the badge is gone on the following pass rather than needing to be retracted. And it is **off the event loop**: the verb costs the better part of a second per session, which the render path may never wait on (see *What may block the TUI event loop*), so it runs on a background thread and is debounced to one reading per session per minute — the one probe in the TUI debounced against the clock rather than against the selection, since every in-flight session is a target on every tick. There is deliberately no cockpit-header quota gauge: the statusline JSON that carries `rate_limits.five_hour.resets_at` is pushed *to* running Claude sessions and is not readable by Voro, so a gauge would need a data source that does not exist. +**Recovering a capped session** is then one key, `u`, which nudges *every* badged session whose reset has gone by. A cap does not retry: it ends the session's turn and leaves it sitting there, so work waits for a human however long ago the window reopened — and walking the strip by hand costs an attach, a typed word and a detach per session, which is how the overnight reset hours get lost. The sweep is that walk as a single keystroke, and it reports what it did: how many it nudged, how many are still before their reset, and any the agent refused. The message it sends is one word, *continue*, because the session already holds the whole task — its transcript, its worktree, its half-written work — and anything longer would be Voro restating a brief the agent can already read. + +It goes out through the existing `message` verb rather than through any new channel. The verb forks (`--fork-session` with a pre-assigned `{new_session}`), which is what makes it land at all: a supervisor-owned session refuses a plain headless `--resume` for as long as its supervisor lives, and a capped session's supervisor is alive by definition. A forked send has been confirmed accepted against a live, supervisor-held session mid-turn — a strictly harder case than a capped one, which sits idle with its turn already ended — so no `tmux send-keys` channel or supervisor IPC is needed, and none is built. The send is recorded exactly as a quick message is, with the forked reference and the pid now carrying the turn, so a nudged session stays as visible to the reconciler as a messaged one; the badge is dropped the moment the send lands, so a second press cannot put a second agent on the same worktree, and it returns on the next reading if the session is still held. + +Two guards are deliberately stood down for it, and the cap reading is what earns that. A quick message is refused on a `running` task because its session is mid-turn, and refused again when the session is listed live — but a capped session is `running`, listed live, and *not* mid-turn, which is the one combination nothing else in the cockpit can recognise. Nothing else may skip those guards. The sweep fires only when pressed, and that is a staging decision rather than a principle: automatic resumption once the window reopens is wanted, and this is deliberately the half it can be built on top of. Manual first buys the evidence automation needs — that a nudge reliably lands, and that the badge it would key on does not false-positive — while a wrong reading still costs one keypress instead of an unwatched agent. What automation adds is a trigger, not a channel: the reset-passed test the badge already computes, evaluated on the tick rather than on the key, plus a bound so a session that will not restart is not nudged around the clock. A cap whose reset time never parsed is swept too — the operator pressing the key is the judgement the clock could not supply, and a send that turns out to be early is refused by the agent rather than doing harm — and that is precisely a case automation must decide differently, since no keypress would stand behind it. Because the nudged turn does real work, the `message` verb carries `--permission-mode` like `dispatch` does: the flag is per invocation rather than a property of the session, so a resumed turn without it runs in ask mode against a stdin at `/dev/null`, stopping on approvals nobody can give and landing the refusals in the launch log — a send that appears delivered and quietly does nothing, which is the one failure a fire-and-forget channel cannot report. + A dispatched process must also be reaped once it exits, or it sits as a zombie for the life of the spawning `voro` process — and `kill -0` on a zombie still reports it alive, which would silently defeat this whole mechanism in a long-lived TUI session. Dispatch therefore hands the child to a detached reaper thread the moment the session is recorded, rather than leaving it to `Drop`. **The return path** is a small verb surface agents call from within their sessions. Dispatch advertises it by injecting a preamble at the top of every prompt it writes, ahead of the task body — the dispatcher already owns the prompt file, so prepending a known-good preamble reaches any agent runtime with no per-project install and no reliance on a CLAUDE.md/AGENTS.md snippet or a loaded skill. The preamble is rendered per dispatch from a single template in the `voro` crate — so its wording still cannot drift — with the task's concrete id and, where needed, its database written *into the verb commands themselves* rather than left to inherited environment variables: diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 37adc08..f8aecfc 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -77,7 +77,7 @@ dispatch = "claude --bg --name \"{session_name}\" --permission-mode auto --mod sessions = "claude agents --json" attach = "claude attach {session}" resume = "claude --resume {session}" -message = "claude -p --resume {session} --fork-session --session-id {new_session} \"$(cat {prompt_file})\"" +message = "claude -p --resume {session} --fork-session --session-id {new_session} --permission-mode auto \"$(cat {prompt_file})\"" logs = "claude logs \"$(printf %.8s {session})\" 2>/dev/null | tail -c 20000" stop = "claude stop \"$(printf %.8s {session})\"" plan = "claude --name \"{session_name}\" --permission-mode auto --model {model} \"$(cat {prompt_file})\"" @@ -143,6 +143,14 @@ resume = "codex resume {session}" continued. A `message` template without the placeholder resumes in place and keeps the reference it had. `{new_session}` is refused on every other verb: it names the session a send opens, and nothing else opens one. +- A `message` template should carry whatever permission flag its agent's + `dispatch` carries — the built-in `claude` one carries `--permission-mode + auto`. A resumed turn does real work, and on agents where the flag is per + invocation rather than a property of the session, leaving it off runs that + turn in the agent's default ask mode against a stdin at `/dev/null`: every + edit and every command outside the allowlist stops for an approval nobody can + give. The refusals land in the launch log rather than the TUI, so the send + looks delivered and quietly does nothing. - `logs` prints a session's recent output, taking `{session}` alone. Voro reads it for exactly one thing: whether that session is sitting on a **usage cap** (DESIGN.md §8). A cap does not kill a backgrounded session — the supervisor