Skip to content

PR2 v0.2.0 - supervisor watches the stream - #2

Merged
bettercallzaal merged 1 commit into
mainfrom
feat/pr2-supervisor
May 24, 2026
Merged

PR2 v0.2.0 - supervisor watches the stream#2
bettercallzaal merged 1 commit into
mainfrom
feat/pr2-supervisor

Conversation

@bettercallzaal

Copy link
Copy Markdown
Owner

What this PR teaches

In PR1 the orchestrator drained the runner's event stream blindly. A bug-fix Hermes session could loop, drift into a banned tool, or burn past its cost cap, and the orchestrator would happily report it as completed.

PR2 introduces supervisor.watch() - a small async generator that sits between the runner stream and the orchestrator and emits a SupervisorVerdict per event. The orchestrator now reasons about each event in light of three live signals.

Detection rules

Rule Trigger Verdict
loop N consecutive identical assistant messages (default 3) intervene
off-track tool_use for a tool not in pattern.allowedTools intervene
cost-cap accumulated cost > costCapUsd kill

A kill verdict in PR2 is real: orchestrator calls runner.kill(handle) and marks the outcome aborted. No run can burn past the cost cap, even in PR2.

An intervene verdict in PR2 is logged but not yet acted on - the recorded intervened event has acted: false. PR3 wires runner.intervene() so the suggestedMessage flows back into the live session.

What's in this PR

  • src/supervisor.ts - the watch generator, ~100 LOC
  • src/orchestrator.ts - drain loop now consumes { event, verdict }, logs interventions, calls runner.kill on kill verdict
  • src/index.ts - exports watch + SupervisorOptions + SupervisorVerdict
  • tests/supervisor.test.ts - 8 unit tests
  • package.json - bump to 0.2.0

Limits left for later PRs

  • stuck-timeout (PR3, v0.3.0) - needs Promise.race against a timer in the drain loop; ~5 LOC, deliberately deferred to keep this PR scoped to per-event logic
  • mid-run intervention (PR3, v0.3.0) - needs the runner to switch from --print (one-shot) to a session-based mode so we can append a message; PR3 changes HermesRunner to use claude --session-id ... --append-message and sets acted: true
  • adaptive thresholds (PR4) - loopThreshold is per-pattern static today; later it learns from past intervened outcomes via learner.retrieve

Verification

typecheck   clean
tests       23 passed / 0 failed  (autonomy 7, router 3, orchestrator 5, supervisor 8)
build       clean dist/

The PR1 orchestrator tests still pass unchanged because supervisor defaults to continue for any event when no rule trips.

Build-in-public note

This is the first PR where I'd have caught a real Hermes failure mode in the field. The pattern: a fixer agent kept calling WebFetch to investigate a bug instead of reading the file. The supervisor would have flagged that as off-track on the second tool call and (in PR3) nudged it to use Read. Worth tracking how many off-track interventions actually fire once PR3 lands and ZOE starts dispatching through this.

Lands on merge

  • Auto-tag v0.2.0 (will cut release post-merge)
  • Bumps the npm install git+...#v0.2.0 pin

🤖 Generated with Claude Code

Adds supervisor.ts: a small async-generator that wraps a runner stream
and emits a SupervisorVerdict per RunEvent. Orchestrator now drains the
supervised stream instead of the raw one.

Detection rules in this PR:
- loop:      N consecutive identical assistant messages (default N=3)
- off-track: tool_use for a tool not on the pattern's allowedTools list
- cost-cap:  accumulated cost > costCapUsd  (verdict: kill)

PR2 limit (lifted in PR3 v0.3.0): supervisor verdicts are LOGGED as
'intervened' events with acted=false. PR3 wires opts.runner.intervene()
so the suggestedMessage actually flows back into the running session.
For kill verdicts the orchestrator already calls runner.kill() in PR2 -
no run will burn past the cost cap.

NOT in PR2 (PR3): stuck-timeout. Needs Promise.race against a timer in
the orchestrator's drain loop, not inside watch(). 5-line change.

Tests: 23/23 pass (+8 supervisor cases on top of PR1's 15).
- continue baseline when no rules trip
- off-track flag + payload check
- empty allowedTools = allow all
- loop detected at threshold, not before
- different messages don't loop
- cost-cap on 'cost' events
- cost-cap on 'complete' event costUsd
- no-op when opts={}

Bumped to 0.2.0. README + npm tarball updates land with the merge tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bettercallzaal
bettercallzaal merged commit 9679e61 into main May 24, 2026
3 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.

1 participant