OrchestKit · #3789 · CC 2.1.251 · 2026-08-29
The rule "no Fable spend without consent" had one door guarded: an agent spawn that pins a Fable id. The session switching itself via /model was wide open. CC 2.1.251 added a hook at exactly that seam. This page shows the payload as measured, the end-to-end run, and the defect the run caught.
Captured with a stdin-dumping hook on a real 2.1.251 -p session running /model claude-opus-5. PreModelSwitch and PostModelSwitch carry the same shape.
{
"hook_event_name": "PreModelSwitch",
"from_model": "claude-sonnet-5",
"to_model": "claude-opus-5",
"requested_model": "claude-opus-5",
"source": "command",
"context_tokens": 0,
"prompt_cache_warm": false,
"cache_ttl": "1h",
"estimated_cache_write_usd": 0,
"pricing": "catalog",
"session_id": "…", "transcript_path": "…", "cwd": "…", "prompt_id": "…"
}
The binary's hook registry adds the output contract: "JSON permissionDecision allow/deny/ask as for PreToolUse; exit 2 blocks the switch", matcher field to_model. The same release put staleness on SessionStart resume: seconds_since_last_response, context_tokens, prompt_cache_likely_expired, estimated_cache_write_usd, session_title (measured; not the staleness_ms the binary's string table hints at, which belongs to the device bridge).
| Situation | PreToolUse[Agent] (existing) | PreModelSwitch (this PR) |
|---|---|---|
| Destination is Fable / Mythos, session is not on that tier | ask | ask, quoting estimated_cache_write_usd when non-zero |
| Already on the premium tier (fable → fable[1m]) | n/a | allow silently |
ORK_FABLE_OK=1 | allow | allow |
| Any non-premium destination | allow | allow |
--dangerously-skip-permissions | still asks (spend is not a permission) | still asks, same reasoning |
/model fable stays a silent 2x-Opus-output decision.Same three claude -p commands, hooks wired to the built runner. The first pass was green on 58 unit tests and wrong in production.
| Run | Command | Before the guard fix | After |
|---|---|---|---|
| 1 | /model claude-opus-5 | Set model to Opus 5 | Set model to Opus 5 |
| 2 | /model claude-fable-5, no consent | Set model to Fable 5, then: "PreModelSwitch hook failed: hookSpecificOutput is missing required field" | "Model switch to Fable 5 was blocked by a PreModelSwitch hook: Switching this session from claude-sonnet-5 to claude-fable-5 puts every following turn on Claude Fable 5 pricing…" |
| 3 | ORK_FABLE_OK=1 + /model claude-fable-5 | Set model to Fable 5 | Set model to Fable 5 |
| telemetry | ~/.claude/analytics/model-switch.jsonl | 3 lines (incl. the switch that should have been blocked) | +2 lines: exactly the two switches that happened |
ork's runner passes every hook result through output-guard.mjs, whose allow-list of events that consume hookEventName lives in the generated cc-output-keys.generated.mjs (stamped at CC 2.1.226). PreModelSwitch was not in it, so the guard stripped the field, CC rejected the envelope, and by CC's own rule for a failed hook the switch proceeded. The gate failed open, and no unit test could see it because the guard runs only in the real runner.
PreModelSwitch added to EVENTS_WITH_HOOK_EVENT_NAME and to the permissionDecision key set; spec updated to match; contract tests and derive --check green.| Hook | Before | Now |
|---|---|---|
sync-session-dispatcher | session-start-perf.jsonl records duration, source, messages | On resume also records seconds_since_last_response, context_tokens, prompt_cache_likely_expired, estimated_cache_write_usd, warm_recent_resume |
session-handoff-injector | Injected the previous session's handoff on every SessionStart it could claim | Skips a resume that came back within 30 minutes onto a cache CC reports warm (conversation intact); cold or long-idle resumes still get it. Unknown fields (older CC) never read as "recent". |