feat(agents): auto-resume an agent that stopped on a subscription usage limit - #259
feat(agents): auto-resume an agent that stopped on a subscription usage limit#259kiwimato wants to merge 5 commits into
Conversation
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
|
Confirming the Rust half, as promised in the description: Three dead-code warnings come out with it, all pre-existing on Mentioning them only so the output is accounted for rather than glossed over. One environment note, unrelated to this change: |
|
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 ( Code navigation is shipping really soon, and agent hooks are next after that. Will flag this PR when the hooks work is closer. |
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.mdsays 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
src/lib/autoRetry.ts(new)parseResetAt,findWaitOption,planLimitPark. Pure andnow-injectedsrc/lib/autoRetry.test.ts(new)src/components/task/TerminalPane.tsxscanOutputLines, a 30s wall-clock effect, a parked banner reusingTerminalExitedBannersrc/lib/types.tsTerminalTab.limitWait(runtime-only),capabilities.signals.limitsrc/store/prefs.ts+ Settings → TasksautoResumeOnLimit(off),autoResumeMessage,autoResumeMarginSeclimitas a fifth signal class next to busy/idle/attention/pendingNo 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 behindmatch_outputbecausedecoding 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 -bclean.npm test: 1204 passing, 33 of them new.cargo check: this diff touches no Rust at all, so the Rust half ofmake check-allis unaffected by it. Running it here needs a workaround (see Notes); I will confirm it green in a comment rather than assert it now.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.
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
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
e2eskill, and add it todocs/e2e-coverage.md. Flagging it ratherthan quietly omitting it.
Rust, no window code) but I cannot claim a macOS run.
/usage-creditshint with nothing to answer, so the menu-answering half isunit-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
210a15e. Five commits, each carryingRefs #256.make check-alldoes not run as-is on NixOS (SHELL=/bin/bash, which doesnot 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 themaintainer-only rule.
note in
CONTRIBUTING.md.