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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion crates/voro-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})\\\"\"
Expand Down
71 changes: 66 additions & 5 deletions crates/voro-core/src/cap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,8 +125,7 @@ impl CapReading {
pub fn read_cap(tail: &str) -> Option<CapReading> {
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()))];
Expand All @@ -120,15 +134,23 @@ pub fn read_cap(tail: &str) -> Option<CapReading> {
})
}

/// 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.
///
Expand Down Expand Up @@ -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() {
Expand Down
Loading