Skip to content

feat(agents): auto-resume an agent that stopped on a subscription usage limit - #259

Open
kiwimato wants to merge 5 commits into
simion:mainfrom
kiwimato:feat/auto-retry-usage-limit
Open

feat(agents): auto-resume an agent that stopped on a subscription usage limit#259
kiwimato wants to merge 5 commits into
simion:mainfrom
kiwimato:feat/auto-retry-usage-limit

Conversation

@kiwimato

Copy link
Copy Markdown

Implements the auto-resume argued for in #256.

Opening this as code to look at, not as a merge request. #256 asks whether
you want this in termic at all, and that question is still open. CONTRIBUTING.md
says argument before patch, so treat the issue as the argument and this as the
"here is what it looks like, concretely" attachment. Happy to close it unmerged.

What it does

When an agent stops because the subscription limit is used up, termic reads the
printed reset time, answers the CLI's wait-or-pay prompt with the wait
option if one is on screen, parks the tab behind a banner saying when it
resumes, and re-prompts once the reset has passed.

Off by default: Settings → Tasks → "Resume automatically after a usage limit".

The rule it is built around

Never select a menu option that spends money.

A false negative parks a tab until the user looks at it, which is what already
happens today. A false positive buys credits on their card. So every ambiguous
case refuses and leaves the prompt for the human: no selection marker, no
identifiably-"wait" row, a wait row that also mentions credits, a list that is
not numbered 1..n, fewer than two rows. Navigation is arrow keys to the matched
row rather than the row's digit, because the marker position is observable fact
and a digit shortcut is an assumption about the CLI's input handling.

Most of the test suite is those refusals rather than the happy path.

Where it lives

File What
src/lib/autoRetry.ts (new) patterns, parseResetAt, findWaitOption, planLimitPark. Pure and now-injected
src/lib/autoRetry.test.ts (new) 33 cases
src/components/task/TerminalPane.tsx detection folded into the existing scanOutputLines, a 30s wall-clock effect, a parked banner reusing TerminalExitedBanner
src/lib/types.ts TerminalTab.limitWait (runtime-only), capabilities.signals.limit
src/store/prefs.ts + Settings → Tasks autoResumeOnLimit (off), autoResumeMessage, autoResumeMarginSec
Settings → Agents limit as a fifth signal class next to busy/idle/attention/pending

No IPC changes, no Rust changes, no new dependency.

Cost when it is off

None, and I treated that as a requirement rather than a nicety given
performance.md. Line scanning was already gated behind match_output because
decoding and splitting every chunk is not free. With auto-resume off the limit
half contributes no patterns, so the gate is exactly as false as it was before
this existed and the data path is byte for byte unchanged.

The pref is read through a store subscription rather than captured at spawn, so
turning it on reaches terminals that are already running. That matters more
than it looks: this is the setting someone flips because they are about to
walk away
.

Testing

  • npx tsc -b clean.
  • npm test: 1204 passing, 33 of them new.
  • cargo check: this diff touches no Rust at all, so the Rust half of make check-all is unaffected by it. Running it here needs a workaround (see Notes); I will confirm it green in a comment rather than assert it now.
  • Fired once against a real session limit, 2026-08-23. Parsed
    resets 7:30pm (Europe/Amsterdam), parked, re-prompted at 19:31:21 local
    (the 19:30 reset plus the 60s default margin, on the next tick). Confirmed
    from the session transcript, not by watching the screen.
  • The menu path was exercised against a mock agent whose selection marker
    starts on the paid option, so a pass proves the money guard held rather
    than just that a key was sent.

What is not tested

  • No e2e spec. Nothing exercises detect → answer → park → resume against a
    live PTY. It wants a fixture agent printing a notice and a two-row menu, with
    the assertion being that the fixture received the keys for the wait row and
    not the paid one. I will write it before you would want to merge this, per
    the e2e skill, and add it to docs/e2e-coverage.md. Flagging it rather
    than quietly omitting it.
  • Linux, not macOS. I am on NixOS. Nothing here is platform-specific (no
    Rust, no window code) but I cannot claim a macOS run.
  • The real recording is the no-menu shape. That run printed a plain
    /usage-credits hint with nothing to answer, so the menu-answering half is
    unit-tested but has not met a real menu. It is there for the shape in
    [FEATURE] Auto-continue after the limit is reset anthropics/claude-code#18980.

Notes

  • Rebased onto 210a15e. Five commits, each carrying Refs #256.
  • make check-all does not run as-is on NixOS (SHELL=/bin/bash, which does
    not exist here); I ran the two halves directly instead. Mentioning it in case
    you want the Makefile to respect $SHELL, though that is not this PR's job.
  • CHANGELOG.md / changelog.json / the version are untouched, per the
    maintainer-only rule.
  • Built with agent assistance and then driven by hand, per the AI-contributions
    note in CONTRIBUTING.md.

A usage limit does not kill a session, it parks it: claude prints the
notice and then blocks on a menu whose options are "stop and wait for the
limit to reset" and "spend money". Nothing else happens until somebody
answers it, so a task left running overnight is not waiting for 3pm, it is
sitting on an unanswered question and will still be sitting on it in the
morning. Answering is only half of it: "stop and wait" stops, it does not
come back, so the session also needs re-prompting once the reset passes
(what anthropics/claude-code#18980 asks the CLI itself to do).

Off by default, in Settings > Tasks. When on, an agent tab that prints a
limit notice gets its wait option selected, parks with a banner saying when
it resumes, and is re-prompted after the printed reset plus a margin.

The rule that outranks the feature: never select an option that costs
money. A false negative parks a tab until the user looks at it, which is
what happens today anyway; a false positive buys credits on their card. So
every ambiguous case refuses: no selection marker, no identifiable wait
row, a wait row that also mentions credits, a list not numbered 1..n, fewer
than two rows. The reset-time parser refuses on the same principle rather
than clamping a nonsense clock.

Almost no new machinery: termic owns the PTY, so this reuses the tier 3
line scanner, visibleTailRows, and agentSend. No tmux, no polling loop, no
process sniffing, and it works on a tab that is not on screen. With the
pref off the line scanner contributes no patterns and the data path is
byte for byte what it was.

Detection and parsing are pure and unit tested (32 cases, most of them the
refusals). There is no e2e spec yet; docs/e2e-coverage.md records what it
needs to assert, and the money guard is the assertion that matters.

Refs simion#256
The previous commit's doc told users to set capabilities.signals.limit in
Settings > Agents, and there was no field there to set it in: the type and
the reader existed, the editor did not. So the built-ins were the only
patterns reachable, which meant claude only, which is exactly the coupling
the signal-class design exists to avoid.

Adds the field next to the other four, and counts it in the "this agent has
custom signals" summary so a limit-only config is not shown as empty. Its
placeholder comes from lib/autoRetry rather than BUILTIN_TITLE_SIGNALS,
because those describe a title and these are matched against output lines.

Refs simion#256
Both directions were silent, and silence is the wrong default for a feature
whose entire premise is that nobody is watching.

Typing into a parked tab cancels the auto-resume, which is correct (the user
is driving again) but gave no sign. The first real test run was lost to
exactly that: an Enter pressed to check on a parked tab, which cancelled the
resume invisibly and left an empty line on screen that read as the feature
having fired and sent nothing. The person most likely to press Enter "just
to check" is the same person who walked away and needs this to work.

So: a toast when user input cancels a park, and a toast when a resume
actually fires. The second matters for the same reason as the first, from
the other side. Nobody is there to see the agent wake up.

Refs simion#256
It fired for real on 2026-08-23 against a live session limit and
re-prompted at the 19:30 reset plus the 60s margin, confirmed from the
session transcript rather than from watching the screen.

Two things the recording settled that the design had only guessed at.

A session limit does not necessarily render an interactive menu: this one
printed a plain "/usage-credits to finish what you're working on." hint with
nothing to answer, so findWaitOption returning null is the ORDINARY path
here rather than the degraded one. Good thing the park was never made
conditional on finding a menu.

And the local-time assumption held on the case most likely to break it, an
Amsterdam account on an Amsterdam machine with the zone printed in the
notice and deliberately ignored.

Refs simion#256
@kiwimato

Copy link
Copy Markdown
Author

Confirming the Rust half, as promised in the description: cargo check on this
branch exits 0.

Three dead-code warnings come out with it, all pre-existing on main and none
of them mine (this branch changes zero .rs files, and Cargo.toml /
Cargo.lock are untouched):

warning: constant `CLOSE_PROMPT_ACK_GRACE` is never used
warning: enum `CloseAction` is never used
warning: function `close_action_from` is never used

Mentioning them only so the output is accounted for rather than glossed over.
They look like scaffolding for a close-confirmation path that is not wired up
yet, so quite possibly deliberate; I have not touched them.

One environment note, unrelated to this change: make check-all does not run
on NixOS as-is, because the Makefile resolves /bin/bash, which does not exist
there (make: /bin/bash: No such file or directory). Running cargo check and
tsc -b / vitest directly works fine. Respecting $SHELL in the Makefile
would fix it, but that is a separate change and I did not want to smuggle it
into this PR.

@simion

simion commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Thanks for putting this together. Useful as a reference for what auto-resume actually needs.

Holding off on merging for now. PTY-based detection (parsing the menu text, reading reset times from terminal output) is fragile and breaks silently after a Claude update.

The right foundation is the hooks system I'm building (docs/ideas/agent-hooks.md). Claude's Stop hook carries stop_reason in the payload. If a usage limit fires a recognizable stop_reason, detection is a single field check on a stable JSON contract. No menu parsing, no arrow-key injection.

Code navigation is shipping really soon, and agent hooks are next after that. Will flag this PR when the hooks work is closer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants