Skip to content

feat: provider-aware rate-limit recovery as an installable plugin (#5) - #6

Merged
whackur merged 9 commits into
code0xff:devfrom
whackur:dev
Jul 30, 2026
Merged

feat: provider-aware rate-limit recovery as an installable plugin (#5)#6
whackur merged 9 commits into
code0xff:devfrom
whackur:dev

Conversation

@whackur

@whackur whackur commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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.md states that nightcrow has no AI ontology — an agent and a
person 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 no
mention 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

core (provider-agnostic)                plugins/nightcrow-recovery
────────────────────────────            ──────────────────────────────
[[plugin]] config, child process
pane_opened / pane_output   ──stdin──▶  provider adapters
pane_idle / pane_exited                 reset-epoch parsing, state machine
user_input / pane_closed
                            ◀─stdout──  send_input / relaunch / status
guard: opt-in, generation,
  alive+idle, rate, arg policy  ← the trust boundary; a plugin cannot pass it
  • Off by default twice over: [[plugin]] needs enabled = true, and a pane
    is only ever visible to a plugin if its [[startup_command]] names it. A plain
    shell pane can never be picked up.
  • Panes are identified by an opaque token injected into the child environment, not
    by PaneId (a per-backend counter) or by cwd (several panes per repository is a
    supported 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-recover documents, avoided by construction.
  • Retry ceilings are keyed by the slot token, not the pane id. A relaunch mints
    a new id, so an id-keyed budget reset itself every attempt and could not bound a
    relaunch loop.
  • Resume flags come from the user's allowed_resume_flags; the core refuses
    anything 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.
  • A relaunch gets a new pane id (ids are monotonic and clients treat Exited as
    final) but inherits the token at the next generation, and is restored to its
    original index using the existing Reordered message — no new wire message for
    relaunch.
  • A wait can run for days, so the PTY is released on exit while the slot is kept;
    the slot is all a relaunch needs.

Providers

Verified against current upstream docs and source, not from memory:

primary signal resume
Claude Code StopFailure hook (error_type == "rate_limit") + statusline rate_limits.*.resets_at claude --resume <id>
Codex CLI rollout JSONL turn_complete / token_count codex resume <id> (never --last)
OpenCode local server session status 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 because rate_limits
is 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 — clean
  • cargo test --workspace — 1297 core + 239 plugin, 0 failures
  • viewer-ui: 123 vitest, tsc -b clean
  • Every commit verified independently green via git rebase --exec
  • nightcrow plugin install/list/remove smoke-tested end to end (0700 mode, path
    traversal 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.json merge being non-destructive and reversible, and clock jumps in
both directions.

whackur added 4 commits July 30, 2026 20:34
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.
whackur added 5 commits July 30, 2026 22:09
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.
@whackur
whackur merged commit 88ce2de into code0xff:dev Jul 30, 2026
5 checks passed
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.

Proposal: provider-aware rate-limit recovery for Claude Code, Codex CLI, and OpenCode

1 participant