feat: provider-aware rate-limit recovery as an installable plugin (#5) - #6
Merged
Conversation
An external plugin process is declared by a [[plugin]] entry and reaches a terminal pane only when that pane's [[startup_command]] names it. Both halves are off by default: enabled is false, and a pane with no plugin field is never handed to anyone. allowed_resume_flags is the list of flags a plugin may append when re-opening a pane's session. It is empty by default, and nightcrow refuses anything absent from it — the core has no idea what a given CLI's flags mean, so which ones a plugin may pass is the user's decision to make and to read. An opt-in naming a plugin that does not exist is a config error, since the only way to reach that state is a typo. Naming one that exists but is disabled is allowed: enabled is the switch for turning a plugin off without unpicking every pane that refers to it.
nightcrow launches each enabled plugin as a long-lived child and speaks newline-delimited JSON to it over a pipe. Rust has no stable ABI, so a dylib plugin would be undefined behaviour the moment versions diverged, and a cargo feature would need a recompile rather than an install. A process boundary is also the honest trust model: the plugin is not in our address space. The pane a plugin sees is named by an opaque token rather than by a PaneId or a working directory. A PaneId is a per-backend counter that restarts at 1, and a directory identifies the project, not the pane — several panes on one repository is a supported layout. The token is injected into the pane's child environment, so the provider's own helper processes inherit it and an event can be traced to its pane without guessing. Guard is the only way a plugin command becomes an action. Decoding checked shape and size; authority is checked here: the pane must exist and have opted in, the generation must match so a decision about a replaced process cannot land on its successor, input goes only to a live and quiet pane, a relaunch only to an exited one, and control characters other than CR/LF/TAB are refused so a plugin cannot drive the emulator or the running CLI's key bindings. Attempts are counted per slot, keyed by the token. A relaunch always mints a new PaneId, so counting by id handed out a fresh allowance every time and a command that exits at once paired with a plugin that relaunches on every exit would never reach the ceiling. A relaunch cannot reuse an id — ids are monotonic and every client treats Exited as final for one — so the replacement is born as a new pane that inherits the token at the next generation, and is put back at its old index with the existing Reordered message rather than a new one. Waiting for a reset can take days, so the process is released the moment it exits while the slot stays; the slot is what a relaunch needs, and holding a dead child's fds and threads for days is not. Recovery state reaches humans as pane metadata, never as screen content: the pane's tab carries a marker, the notice row carries the detail, and <prefix> c gives up on a wait. Typing into the pane cancels it too — a person taking the pane back outranks whatever a plugin was planning.
Waits out a coding CLI's usage limit and re-opens the same session afterwards. It only waits: there is no path here that raises, bypasses, or works around a provider's limit, and nothing is sent while one is in effect. Lives in its own workspace member so the knowledge of which CLI prints what stays out of nightcrow, which by design has no AI ontology. It never links the host — the two only meet over the pipe — and the published crate excludes it. Claude Code is read through its StopFailure hook for the exact session id and error_type, and through the statusline for the exact reset epoch; because rate_limits is absent for non-subscribers and before the first response, a conservative output match stands in. Codex is read from its rollout JSONL, which persists turn_complete and token_count while errors are explicitly transient, and is resumed by the exact session id rather than by a global last-session guess. OpenCode is only observed: its own retries are unbounded, so waiting for them to run out is not a thing that happens, and it is left alone until the process exits or its status returns to idle. Waiting is held against both an absolute epoch and a monotonic countdown, so a wall-clock change neither fires a wait early nor strands it. Attempts are bounded and end in NeedsAttention rather than in another try. install-hooks merges into an existing ~/.claude/settings.json rather than replacing it, keeping unknown keys and anyone else's hooks, and backs the file up first; uninstall-hooks removes only what was added.
Says why provider knowledge lives outside the core rather than in it, what the guard refuses and why each rule exists, and states plainly that the feature waits for a limit rather than getting around one. Also rebuilds the embedded viewer bundle: rust-embed reads viewer-ui/dist at compile time, so without this the recovery surface would be missing from the browser even though its source is here.
inotify reports opens, and the reader opens HEAD and refs on every read, so each read scheduled the next one and an idle repository was walked once a second forever. Filter by event kind: a completed write still wakes the reader, a mere open no longer does. The overflow rescan signal, which carries no paths, keeps its own route through.
Both tests opened their counting window while a read was still owed from the watch starting, so the read they denied could be that one. Wait on the condition instead of a fixed pause.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5.
Implements the proposal, with one deliberate change of shape: the recovery
controller is an installable plugin, not part of the core.
Why a plugin
docs/architecture.mdstates that nightcrow has no AI ontology — an agent and aperson get the same PTY and the same file mtimes. Putting provider adapters in
the application layer, as the issue sketched, would contradict that. Keeping the
provider knowledge in a separate process preserves it:
src/plugin/contains nomention of Claude, Codex, or OpenCode, and that is a greppable invariant rather
than a promise.
This does not bypass any provider limit. It automates the wait-and-resume a
person does by hand: it waits for the reset time the provider itself reported,
then re-opens the same session. Nothing is sent while a limit is in effect.
Shape
[[plugin]]needsenabled = true, and a paneis only ever visible to a plugin if its
[[startup_command]]names it. A plainshell pane can never be picked up.
by
PaneId(a per-backend counter) or by cwd (several panes per repository is asupported layout). Provider hook/statusline children inherit it, which is how an
event is tied to its pane without guessing — this is the concurrency bug
cc-session-recoverdocuments, avoided by construction.a new id, so an id-keyed budget reset itself every attempt and could not bound a
relaunch loop.
allowed_resume_flags; the core refusesanything absent from it and shell-quotes every argument. The core never
hardcodes a provider's flag names, so it cannot be the thing that weakens a
CLI's permission posture.
Exitedasfinal) but inherits the token at the next generation, and is restored to its
original index using the existing
Reorderedmessage — no new wire message forrelaunch.
the slot is all a relaunch needs.
Providers
Verified against current upstream docs and source, not from memory:
StopFailurehook (error_type == "rate_limit") + statuslinerate_limits.*.resets_atclaude --resume <id>turn_complete/token_countcodex resume <id>(never--last)opencode --session <id>OpenCode is only observed: its own retries are unbounded, so waiting for them
to run out never happens. It is left alone until the process exits or its status
returns to
idle. Claude's terminal-output fallback exists becauserate_limitsis absent for non-subscribers and before the first response.
Acceptance criteria
All items in the issue are covered except one, deliberately: recovery state does
not survive a nightcrow restart. The issue left that open ("decide whether"),
and a persisted deadline pointing at a slot that no longer exists is worse than
starting clean.
Verification
cargo clippy --all-targets --all-features -- -D warnings— cleancargo test --workspace— 1297 core + 239 plugin, 0 failuresviewer-ui: 123 vitest,tsc -bcleangit rebase --execnightcrow plugin install/list/removesmoke-tested end to end (0700 mode, pathtraversal refused, config left untouched)
New tests cover reset-time parsing per provider, token correlation with two panes
in one repository, every state transition and its idempotence, stale generations,
bounded backoff, cancellation by user input / close / generation change, the
settings.jsonmerge being non-destructive and reversible, and clock jumps inboth directions.