Difficulty-based routing for Claude Code. You pick the main model and effort; Claude decides what to do itself and what to hand to tiered Haiku, Sonnet, and…

Difficulty-based subagent routing for Claude Code. You pick the main session's model and effort. Claude then decides, task by task, what to do itself and what to hand to a cheaper or stronger subagent, at the right effort. It escalates when a tier gets stuck and follows your overrides.
you ──▶ main session (your model/effort) ──┬─ does interactive, coupled, quick work itself
├─▶ scanner haiku / low lookups, noisy logs
├─▶ deep-reader sonnet / medium multi-file analysis
├─▶ implementer sonnet / medium well-specified edits
├─▶ implementer-hard opus / high hard, multi-module work
├─▶ ui-smoke haiku / low quick read-only UI check
├─▶ ui-tester sonnet / medium browser/emulator flows
└─▶ reviewer opus / high independent review
Out of the box, every subagent runs on whatever model and effort it was given, no matter how easy or hard the job is. Claude Code now lets the main session set model and effort per subagent call (2.1.292+), but something has to decide when to use them. Crew Chief is that decision layer, plus the tiers it routes to:
/clear or compaction), so it is always active.implementer → implementer-hard → main session, instead of retrying the same tier.ui-smoke for a quick read-only check and ui-tester for multi-step browser, emulator, or simulator flows, so screenshots stay out of the main context./crew-chief:crew-mode solo.ui-tester sonnet-5.5 · medium · 41.2k Checking the login flow./advisor is off, it explains the benefit and offers to turn it on. See Advisor offer./crew-chief:crew-mode solo is enforced by a hook that blocks the Agent tool, not just requested in the prompt.routing skill) for when one subagent is the wrong tool: forks, Monitor, /loop, /goal, dynamic workflows, agent teams, routines.Claude Code plugin (full: agents, hooks, skills):
claude plugin marketplace add aykyusuf/crew-chief
claude plugin install crew-chief@crew-chief
Or inside a session: /plugin install crew-chief --marketplace aykyusuf/crew-chief.
Skills only, any agent tool (npx skills):
npx skills add aykyusuf/crew-chief
This installs the skills but not the agents or hooks. In Claude Code, run /crew-chief:crew-setup (or /crew-setup when installed with npx) once per project to copy the agents, the guard, and the policy into .claude/ and CLAUDE.md. Restart Claude Code afterwards if .claude/agents/ did not exist before.
Pick one route per machine. Installing both the plugin and the npx skills lists each skill twice (/crew-chief:crew-routing and /crew-routing), which only wastes context. /crew-setup detects the plugin and skips the agents and guard, and the plugin's session hook stays quiet when the policy is already in CLAUDE.md.
Requirements: Claude Code 2.1.292 or later for per-call effort (older versions still work, routing by tier defaults); 2.1.287 or later for the token saver. The guard parses its input with jq when installed and with sed otherwise.
Third-party marketplaces do not auto-update by default, so turn it on once: /plugin → Marketplaces → crew-chief → Enable auto-update. Claude Code then refreshes the plugin shortly after your first message in a session. Or update by hand: claude plugin update crew-chief@crew-chief.
An update does not change the session that is already running:
Plugin updated: crew-chief · Run /reload-plugins to apply. Run /reload-plugins to switch the hooks in that session (a new session loads the new version by itself).claude plugin update in a shell, other open sessions get no message from Claude Code. Crew Chief fills that gap: on your next prompt it says once per session that this session still runs the old copy and to run /reload-plugins. After an auto-update you may see both messages, since the old copy is marked in that case too.CREW_CHIEF_WHATS_NEW=off.Both notices are printed by a UserPromptSubmit hook (systemMessage) under your prompt, not written by the model. A SessionStart hook's systemMessage is not shown by Claude Code 2.1.295, which is why the notice waits for your first prompt.
Plan limits are eaten mostly by long sessions and big contexts, then by subagents on Opus. The saver is a small mod (hooks/register.js) that watches your limits from inside Claude Code and asks before it changes anything.
xhigh or max); Haiku and your main session are never touched. It changes the effort of each subagent request, not the model./clear, and /resume starts with the saver off. No saver state is saved; what is remembered between sessions is the language and the advisor-offer answer (see below)./saver on|off|status switches it by hand and shows your limits, their reset times, and the context size./compact mid-task, /clear between tasks) and when the session is 8 hours old.Language. Questions, toasts, /saver, and the update notices speak English or Turkish, whichever you write in. Order of precedence: CREW_CHIEF_LANG=tr|en, then Claude Code's language setting (turkish or english), then your recent prompts (Turkish letters and common Turkish words; the majority of the last five prompts, so one stray English sentence changes nothing; slash commands and code count for nothing), then the language last detected (so the first question of a new session already speaks it), then the LANG locale, then English. Kept between sessions in the plugin's store: that one word, tr or en, and the answer to the advisor offer. Other languages fall back to English; adding one means adding an object to hooks/saver-i18n.mjs (a test checks that its keys match English). Update notes come from CHANGELOG.tr.md when it has the version, otherwise from CHANGELOG.md.
| Environment variable | Effect |
|---|---|
CREW_CHIEF_SAVER=off | No saver questions and no toasts (/saver still works; the one-time advisor offer has its own switch below) |
CREW_CHIEF_SAVER_FIVE_HOUR=60,85 | Your own 5-hour thresholds |
CREW_CHIEF_SAVER_SEVEN_DAY=50,90 | Your own weekly thresholds |
CREW_CHIEF_LANG=tr or en | Force the language of every message |
CREW_CHIEF_ADVISOR_OFFER=off | Never offer the advisor |
Needs: Claude Code 2.1.287 or later (older versions never load the mod; the first prompt after install tells you), and a Pro or Max plan for the limit data (with an API key the mod stays quiet; the context and age toasts still work). The questions need an interactive session; claude -p never asks.
Measured on Claude Code 2.1.295: the limits are available right when a session starts, and match /usage and the status line. For a hard cap that needs no questions, set "maxEffortLevel": "medium" in your settings.
/advisor (experimental, Anthropic API only) lets Claude ask a stronger model for advice at hard moments: before choosing an approach, when stuck, before finishing. Subagents inherit it, so a Sonnet implementer can consult Opus instead of stopping and being re-run as implementer-hard. In Anthropic's own benchmark a Sonnet and Opus pair finished tasks about 12% cheaper and slightly better; each consultation is billed at the advisor's rates.
When the advisor is off, the saver mod offers it once, after the first finished turn of a session (never at startup, never over a saver question):
/advisor opus (/advisor fable for Fable) for you, which saves advisorModel in your user settings exactly as if you had typed it, and says so in a toast. If the setting does not appear (a policy, Fable usage credits not enabled), the toast tells you to run /advisor yourself to see why, and the offer is not repeated. Remind me in a week and Don't ask again are remembered in the plugin's store. Dismissing the dialog remembers nothing.advisorModel is already set, on Bedrock, Vertex, Foundry or the other cloud routes, when CLAUDE_CODE_DISABLE_ADVISOR_TOOL is set, when this Claude Code has no /advisor, when the main model is unknown, in claude -p, or after you said yes (turn it off later with /advisor off; it will not come back). It also waits for a turn that ended normally, not one you interrupted.CREW_CHIEF_ADVISOR_OFFER=off switches the offer off for good.Nothing to do: start a session, pick your model and effort as usual, and work. Crew Chief's policy tells Claude when to delegate.
| You say | What happens | |||
|---|---|---|---|---|
| (nothing) | auto mode: routed by difficulty | |||
| "do it yourself", "no subagents" | solo: everything stays in the main session (/crew-chief:crew-mode solo also blocks the Agent tool with a hook until you switch back) | |||
| "use agents", "delegate this" | delegate: independent pieces go to tiers in parallel | |||
| "do the UI directly with Opus" | That part stays in the main session; the rest is routed | |||
| "give the tests to Haiku", "Sonnet at medium for this" | The subagent runs with exactly that model/effort | |||
@agent-crew-chief:implementer-hard fix the race in sync.go | That agent, guaranteed | |||
| `/crew-chief:crew-mode auto\ | solo\ | delegate\ | status` | Switch or show the mode |
Change a tier's model or effort: create .claude/agents/<name>.md in your project with the same name (for example implementer.md). Project agents take priority over plugin agents, and plugin updates never overwrite them.
A new session starts with no memory of the last one, so it rediscovers the project: what works, what was tried, what is next. Crew Chief can set up three small files that carry that over. This follows Anthropic's engineering post Effective harnesses for long-running agents (Nov 2025): a progress file, a JSON task list whose status is the only thing that changes (models are less likely to rewrite JSON than Markdown), and reading git log plus the progress file at the start of each session.
| File | Holds | Changes how |
|---|---|---|
STATUS.md | Now, environment commands, next, open questions, failed attempts, decisions | Rewritten to describe the present |
tasks.json | Tasks with id, title, priority, measurable done_when, status, evidence | Only status and evidence change: todo → in_progress → done (every part of done_when, sign-offs included) or blocked (reason in evidence) |
PROGRESS.md | Dated log, newest first | One paragraph appended per session |
CLAUDE.md | A start/end routine between <!-- crew-chief:handoff:start --> and :end markers | Added once; the rest of the file is not touched |
How it is offered. On a fresh session start (not resume, /clear, or compaction) in a git repository that has none of these files yet, the session hook asks Claude to ask you once, saying what would change and why. Answers: Set it up, Not now (asks again after 7 days), Never for this project. The hook stays quiet:
tasks.json, STATUS.md, PROGRESS.md, HANDOFF.md, claude-progress.txt, feature_list.json, and their Turkish equivalents durum.md, ilerleme.md) or as a crew-chief:handoff block in CLAUDE.md;CREW_CHIEF_HANDOFF_OFFER=off is set in your environment.What setup does (/crew-chief:crew-handoff, or just say "set up handoff files"): it looks for existing equivalents first and offers to keep using them instead of creating a parallel set; shows the files and a preview filled only from what it can verify in the repository (unverifiable items are marked unverified); creates only missing files; adds the CLAUDE.md block; never overwrites, renames, or commits anything. /crew-chief:crew-setup --handoff runs the same steps after a project install.
Everything is plain, readable shell in this repo. Nothing is sent over the network.
| Component | When | What it does |
|---|---|---|
scripts/session-policy.sh | SessionStart (startup, resume, clear, compact) | Prints the ~2 KB routing policy into the session context. On a fresh start in a git repository without handoff files, also adds the one-time handoff offer; it never writes files itself |
scripts/subagent-row.sh | Agent panel refresh, while subagents run | Reads the panel's row data (agent type, model, effort, tokens) and prints the row text. Needs jq; without it the default rows stay |
hooks/register.js (a mod) | Session events | The token saver: reads $.session.usage() for your plan limits and context size, asks with $.ui.ask, and lowers subagent effort in turn.step while the saver is on. Keeps saver state in memory only; remembers the language and your answer to the advisor offer. Also offers /advisor once (see Advisor offer) |
scripts/mode-guard.sh | UserPromptSubmit and UserPromptExpansion; PreToolUse on Agent | record: when your prompt is /crew-chief:crew-mode <mode>, remembers the mode for this session. check: while that mode is solo, blocks subagent spawns (exit 2). No recorded mode means everything passes |
scripts/plugin-notices.sh | UserPromptSubmit | On the first prompt after an update, shows the version change and the first lines of its CHANGELOG section. In a session that still runs a replaced copy, says so once instead. Both print a systemMessage for you |
scripts/readonly-guard.sh | PreToolUse on Bash | Reads the hook input; if the caller is crew-chief:scanner, deep-reader, reviewer, or ui-smoke, blocks commands that write files, change git state, or install packages (exit 2). Every other caller passes through untouched |
With /crew-setup (no plugin), the same guard is copied to .claude/hooks/crew-chief-guard.sh and wired through .claude/settings.json with --project, matching the bare agent names. It is not put in the agents' frontmatter on purpose: Claude Code skips frontmatter hooks of project agents until the folder's trust dialog is accepted, and never runs them in -p sessions.
The guard is a pattern check, not a sandbox: it blocks the common ways to write and errs on the side of blocking (for example a > inside a quoted awk expression). It does not try to catch deliberately obfuscated commands.
Crew Chief collects and sends no data. Its hooks run locally and print text or an exit code. They read the hook input Claude Code passes them (the session's project path and id, the first line of your prompt to spot /crew-chief:crew-mode, and the Bash command a subagent is about to run) and, at session start, look at the project itself: whether it is a git repository, whether CLAUDE.md or handoff files exist. What is stored stays in ~/.claude/plugins/data/<plugin>/ on your machine, which Claude Code deletes when you uninstall the plugin: your answer to the handoff offer (handoff-offers.tsv: a line per answer, the project path and installed, never, or later with a timestamp; the latest line per project counts), the plugin version you were last told about (last-seen-version), the plugin version for which it last checked your Claude Code version (claude-version-checked), and small per-session marker files for solo mode (modes/) and the stale-session notice (stale-notified/), named by session id and pruned when newer ones are written once they are two weeks old. Nothing is written into your project. The saver reads your plan usage, context size, and the first lines of your prompts (only to tell Turkish from English) inside Claude Code, keeps just the detected language (tr or en) and your answer to the advisor offer (answered, never, or a pause time) in the plugin's store, and sends nothing anywhere. There is no telemetry or network access. Bug reports go to GitHub issues.
Delegate only when the work is self-contained and it can run in parallel, would flood the main context, or a cheaper tier can do it equally well. Otherwise the main session does it. Multi-agent setups cost several times the tokens of one session and most coding work is tightly coupled, so the default leans towards doing it yourself. Difficulty is read from what is visible (scope, whether a check exists, ambiguity, whether the root cause is known, blast radius), not guessed. When a subagent fails, the rule is to diagnose before escalating: careless means raise effort, out of its depth means raise the model. Escalation also triggers on evidence (check not run, files changed outside the brief, maxTurns hit), and a task that turns out harder mid-run can be steered, stopped, or resumed on a stronger model. The optional advisor (/advisor, experimental) lets a cheaper agent consult a stronger model itself.
The full policy is in skills/crew-routing/SKILL.md, with mechanisms and model and effort references summarised from the official Claude Code docs.
claude --plugin-dir . # load this checkout for one session
python3 tests/test_guard.py # guard tests, with and without jq
python3 tests/test_session_policy.py # session hook: policy and when the handoff offer appears
python3 tests/test_mode_guard.py # solo mode: record and block
python3 tests/test_plugin_notices.py # what's-new, Claude Code version, and stale-session notices
node --test tests/saver-logic.test.mjs tests/saver-i18n.test.mjs # saver thresholds, effort caps, messages, language detection (plain Node)
claude plugin test # the saver mod's hooks, with stubs (no session, no network)
python3 tools/build_assets.py # regenerate skills/crew-setup/assets after editing agents/ or scripts/
claude plugin validate . --strict # marketplace manifest
claude plugin validate .claude-plugin/plugin.json --strict
claude plugin eval . --scaffold --allow-tools Bash --runs 1 # behaviour evals (real model calls, billed)
Eval cases live in evals/: a solo override, a named model and effort, a noisy test run that should go to the scanner, an orchestration question that should load the routing skill, and (added in 0.3.0, not yet run) a quick edit that must stay inline, a risky change that should get the reviewer at xhigh, an escalation after a stopped implementer, and a user constraint that must reach the subagent's brief.
Last run (0.1.0, Claude Code 2.1.294, --model sonnet --judge-model haiku --runs 1, total cost $0.47):
| Case | With plugin | Without | Plugin indicator |
|---|---|---|---|
| solo-override | 1.0 | 1.0 | no subagent spawned |
| named-model-effort | 1.0 | 1.0 | Agent called with model: sonnet, effort: medium |
| verbose-tests-go-to-scanner | 1.0 | 1.0 | crew-chief:scanner used |
| routing-skill-fires | 1.0 | 0.0 | crew-routing skill loaded |
One run per arm is a smoke test, not a benchmark; use --runs 3 or more before drawing conclusions.
MIT
hooks/register.js 420 lines1// crew-chief saver mod: watches your plan limits and asks whether to turn on a token saver.
2//
3// What it does (Claude Code 2.1.287 or later; older versions never load this file):
4// - When the 5-hour window crosses 70/80/90 % or the weekly window 50/75/85/90 %, asks once per
5// threshold and window whether to turn the saver on for THIS session. A new session, /clear,
6// and /resume always start with the saver off.
7// - While the saver is on, subagent requests are capped: Opus (and Fable) at medium effort,
8// Sonnet at high (no xhigh or max). Haiku and the main session are never touched.
9// - One toast when the context passes 150k tokens, one when the session is 8 hours old.
10// - /saver [on|off|status]
11// - Once, after the first finished turn of a session where the advisor is off: offers to turn on
12// /advisor (a stronger model Claude can ask at hard moments). It asks first, remembers "not now"
13// for a week and "never" for good, and runs /advisor only after a yes.
14// Environment: CREW_CHIEF_SAVER=off silences the saver questions and toasts (not the advisor offer);
15// CREW_CHIEF_SAVER_FIVE_HOUR and CREW_CHIEF_SAVER_SEVEN_DAY set the thresholds ("70,80,90");
16// CREW_CHIEF_ADVISOR_OFFER=off never offers the advisor;
17// CREW_CHIEF_LANG=tr|en forces the language of the messages (otherwise: Claude Code's `language`
18// setting, then what you have been writing, then the last language seen, then the locale).
19// Kept between sessions, in the plugin's store: the language ("tr" or "en") and the answer to the
20// advisor offer ("never", "answered", or "later:<time>"). Nothing leaves the machine.
21import { classifyText, pickLanguage, t, unitsFor, windowName } from './saver-i18n.mjs'
22import {
23 CONTEXT_TOAST_TOKENS,
24 FIVE_HOUR_DEFAULT,
25 SEVEN_DAY_DEFAULT,
26 SESSION_AGE_TOAST_MS,
27 ADVISOR_LATER_MS,
28 advisorOfferAllowed,
29 advisorPlan,
30 capEffort,
31 crossing,
32 formatDuration,
33 isEnvFlag,
34 parseThresholds,
35 tokensLabel,
36} from './saver-logic.mjs'
37
38const VOTES_KEPT = 5
39const TURN_STALE_MS = 10 * 60 * 1000
40const STEP_STALE_MS = 5 * 60 * 1000
41
42// Per-session state; resetState() puts it back when the session ends, /clear, or /resume.
43let saver = false
44let snoozed = false
45let asking = false
46let pending = null
47let lastPromptAt = 0
48let lastCompleteAt = 0
49let lastStepAt = 0
50let registered = false
51let quiet = false
52let contextToasted = false
53let ageToasted = false
54let capToasted = false
55let advisorChecked = false
56let settingPromise
57let storedPromise
58let storedLang
59const votes = []
60const seen = new Set()
61
62function resetState() {
63 saver = false
64 snoozed = false
65 asking = false
66 pending = null
67 lastPromptAt = 0
68 lastCompleteAt = 0
69 lastStepAt = 0
70 registered = false
71 quiet = false
72 contextToasted = false
73 ageToasted = false
74 capToasted = false
75 advisorChecked = false
76 settingPromise = undefined
77 storedPromise = undefined
78 storedLang = undefined
79 votes.length = 0
80 seen.clear()
81}
82
83async function readConfig($) {
84 const off = (await $.env.get('CREW_CHIEF_SAVER')) === 'off'
85 const five = parseThresholds(await $.env.get('CREW_CHIEF_SAVER_FIVE_HOUR'), FIVE_HOUR_DEFAULT)
86 const week = parseThresholds(await $.env.get('CREW_CHIEF_SAVER_SEVEN_DAY'), SEVEN_DAY_DEFAULT)
87 return { off, five, week }
88}
89
90async function loadLanguageSetting($) {
91 try {
92 const settings = await $.settings.read()
93 return typeof settings.language === 'string' ? settings.language : undefined
94 } catch (err) {
95 // No settings to read: the other signals still decide.
96 return undefined
97 }
98}
99
100async function loadStoredLanguage($) {
101 try {
102 const value = await $.store.get('lang')
103 return value === 'tr' || value === 'en' ? value : undefined
104 } catch (err) {
105 // No store: the language is simply not remembered.
106 return undefined
107 }
108}
109
110// The language of the messages. Cheap to call: the settings and the stored language are read once
111// per session (the promises are kept, so two overlapping calls share one read), and a language
112// read off the prompts is remembered (that one word, "tr" or "en") so the next session can ask its
113// first question in it, before any prompt has been typed.
114async function resolveLang($) {
115 const override = await $.env.get('CREW_CHIEF_LANG')
116 const locale = await $.env.get('LANG')
117 if (settingPromise === undefined) settingPromise = loadLanguageSetting($)
118 if (storedPromise === undefined) storedPromise = loadStoredLanguage($)
119 const setting = await settingPromise
120 if (storedLang === undefined) storedLang = await storedPromise
121 const pick = pickLanguage({ override, setting, votes, stored: storedLang, locale })
122 if (pick.source === 'prompts' && pick.lang !== storedLang) {
123 storedLang = pick.lang
124 try {
125 await $.store.set('lang', pick.lang)
126 } catch (err) {
127 // Not remembered; it is detected again next time.
128 }
129 }
130 return pick.lang
131}
132
133// The window that most needs a question, or undefined. Every threshold at or below the current
134// percent is marked as seen, so crossing 70 and 80 together asks once, and never again for 70.
135function findHit(rateLimits, cfg) {
136 let hit
137 for (const w of rateLimits) {
138 const list = w.kind === 'five_hour' ? cfg.five : w.kind === 'seven_day' ? cfg.week : undefined
139 if (list === undefined) continue
140 const c = crossing(w.percentUsed, list, seen, w.kind + '|' + w.resetsAt + '|')
141 c.crossed.forEach((key) => seen.add(key))
142 if (c.top !== undefined && (hit === undefined || w.percentUsed > hit.percent)) {
143 hit = { kind: w.kind, percent: w.percentUsed, resetsAt: w.resetsAt }
144 }
145 }
146 return hit
147}
148
149async function contextNotices($, usage) {
150 const tokens = usage.context ? usage.context.tokens : undefined
151 if (!contextToasted && typeof tokens === 'number' && tokens >= CONTEXT_TOAST_TOKENS) {
152 contextToasted = true
153 const lang = await resolveLang($)
154 $.ui.toast(t(lang, 'toast_context', { tokens: tokensLabel(tokens) }), { timeoutMs: 12000 })
155 }
156 const now = await $.clock.now()
157 if (!ageToasted && typeof usage.startedAt === 'number' && now - usage.startedAt >= SESSION_AGE_TOAST_MS) {
158 ageToasted = true
159 const lang = await resolveLang($)
160 $.ui.toast(t(lang, 'toast_age', { duration: formatDuration(now - usage.startedAt, unitsFor(lang)) }), { timeoutMs: 12000 })
161 }
162}
163
164async function askUser($, hit) {
165 asking = true
166 try {
167 const lang = await resolveLang($)
168 const answers = [t(lang, 'answer_on'), t(lang, 'answer_not_now'), t(lang, 'answer_never')]
169 const now = await $.clock.now()
170 const left = hit.resetsAt ? Date.parse(hit.resetsAt) - now : 0
171 const reset = left > 0 ? t(lang, 'reset_in', { duration: formatDuration(left, unitsFor(lang)) }) : ''
172 const answer = await $.ui.ask(
173 t(lang, 'ask', { window: windowName(lang, hit.kind), percent: hit.percent, reset }),
174 answers,
175 )
176 if (answer === answers[0]) {
177 saver = true
178 $.ui.toast(t(lang, 'toast_saver_on'), { timeoutMs: 8000 })
179 } else if (answer === answers[2]) {
180 snoozed = true
181 }
182 } catch (err) {
183 // Dismissed, or a run with nobody to ask: treat it as "not now".
184 } finally {
185 asking = false
186 }
187}
188
189// Not awaited on purpose: the hook that calls this must not wait for the person to answer.
190function launchAsk($) {
191 const hit = pending
192 pending = null
193 if (hit !== null && !asking) {
194 askUser($, hit)
195 }
196}
197
198async function evaluate($) {
199 const cfg = await readConfig($)
200 quiet = cfg.off
201 if (cfg.off) return
202 const usage = await $.session.usage()
203 await contextNotices($, usage)
204 if (saver || snoozed || asking) return
205 const hit = findHit(usage.rateLimits, cfg)
206 if (hit !== undefined) pending = hit
207}
208
209async function onCloudProvider($) {
210 // The advisor is a server tool of the Anthropic API; these routes do not have it.
211 const flags = [
212 await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
213 await $.env.get('CLAUDE_CODE_USE_VERTEX'),
214 await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
215 await $.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
216 await $.env.get('CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD'),
217 ]
218 return flags.some(isEnvFlag)
219}
220
221async function rememberAdvisorAnswer($, value) {
222 try {
223 await $.store.set('advisor', value)
224 } catch (err) {
225 // Not remembered: the offer may come back next session.
226 }
227}
228
229// The command writes the setting a moment after it returns, so look a few times before giving up.
230async function advisorIsSet($) {
231 for (let attempt = 0; attempt < 5; attempt++) {
232 const settings = await $.settings.read()
233 if (settings.advisorModel) return true
234 if (attempt < 4) await $.clock.sleep(400)
235 }
236 return false
237}
238
239// Runs outside the hook that scheduled it (a hook the turn waits on may not run commands). Only a
240// result that is visible in the settings counts as "answered"; a refusal (a policy, Fable credits)
241// is remembered as "never" so the offer does not repeat a command that cannot work.
242async function enableAdvisor($, advisor, lang) {
243 try {
244 await $.command.run({ command: 'advisor', args: advisor })
245 if (!(await advisorIsSet($))) throw new Error('the advisor setting did not change')
246 await rememberAdvisorAnswer($, 'answered')
247 $.ui.toast(t(lang, 'advisor_toast_on', { advisor: t(lang, 'model_' + advisor) }), { timeoutMs: 8000 })
248 } catch (err) {
249 await rememberAdvisorAnswer($, 'never')
250 $.ui.toast(t(lang, 'advisor_toast_failed'), { timeoutMs: 12000 })
251 }
252}
253
254// Offers the advisor once. Every gate that says "not for this person or this setup" returns
255// quietly; a dismissed dialog (or a run with nobody to ask) leaves no trace, so it can come back.
256async function offerAdvisor($) {
257 if (asking) return
258 asking = true
259 try {
260 if ((await $.env.get('CREW_CHIEF_ADVISOR_OFFER')) === 'off') return
261 if (isEnvFlag(await $.env.get('CLAUDE_CODE_DISABLE_ADVISOR_TOOL'))) return
262 if (await onCloudProvider($)) return
263 const settings = await $.settings.read()
264 if (settings.advisorModel) return
265 const commands = await $.command.list()
266 if (!commands.some((c) => c.name === 'advisor')) return
267 const plan = advisorPlan(await $.session.model())
268 if (plan === undefined) return
269 let stored
270 try {
271 stored = await $.store.get('advisor')
272 } catch (err) {
273 // No store: treated as never asked.
274 }
275 const now = await $.clock.now()
276 if (!advisorOfferAllowed(stored, now)) return
277 const lang = await resolveLang($)
278 const answers = [t(lang, 'advisor_answer_on'), t(lang, 'advisor_answer_later'), t(lang, 'advisor_answer_never')]
279 const main = t(lang, 'model_' + plan.family)
280 const advisor = t(lang, 'model_' + plan.advisor)
281 let answer
282 try {
283 answer = await $.ui.ask(t(lang, plan.kind === 'second' ? 'advisor_ask_second' : 'advisor_ask_standard', { main, advisor }), answers)
284 } catch (err) {
285 return
286 }
287 if (answer === answers[0]) {
288 $.clock.after(300, () => enableAdvisor($, plan.advisor, lang))
289 } else if (answer === answers[1]) {
290 await rememberAdvisorAnswer($, 'later:' + (now + ADVISOR_LATER_MS))
291 } else if (answer === answers[2]) {
292 await rememberAdvisorAnswer($, 'never')
293 }
294 } catch (err) {
295 // Anything unexpected: stay quiet rather than get in the way.
296 } finally {
297 asking = false
298 }
299}
300
301// A main-thread turn is running when a prompt or a model request went out after the last finished
302// turn. Model requests count too: a prompt queued while the previous turn ran, a turn longer than
303// TURN_STALE_MS, and a turn started by a background notification never pass through prompt.submit
304// at the right time. Both marks go stale so a lost turn.complete cannot block questions for good.
305async function turnInFlight($) {
306 const now = await $.clock.now()
307 const byPrompt = lastPromptAt > lastCompleteAt && now - lastPromptAt < TURN_STALE_MS
308 const byStep = lastStepAt > lastCompleteAt && now - lastStepAt < STEP_STALE_MS
309 return byPrompt || byStep
310}
311
312async function statusText($) {
313 const lang = await resolveLang($)
314 const units = unitsFor(lang)
315 const usage = await $.session.usage()
316 const now = await $.clock.now()
317 const parts = [t(lang, saver ? 'status_on' : 'status_off') + (snoozed ? t(lang, 'status_snoozed') : '')]
318 for (const w of usage.rateLimits) {
319 const left = w.resetsAt ? Date.parse(w.resetsAt) - now : 0
320 parts.push(windowName(lang, w.kind) + ' ' + t(lang, 'percent', { n: w.percentUsed }) + (left > 0 ? t(lang, 'status_resets', { duration: formatDuration(left, units) }) : ''))
321 }
322 if (usage.rateLimits.length === 0) parts.push(t(lang, 'status_no_data'))
323 if (usage.context && typeof usage.context.tokens === 'number') parts.push(t(lang, 'status_context', { tokens: tokensLabel(usage.context.tokens) }))
324 return parts.join(' · ')
325}
326
327export function register(on) {
328 on('session.start', async ($, e, next) => {
329 resetState()
330 const result = await next(e)
331 try {
332 await $.command.register({
333 name: 'saver',
334 description: t(await resolveLang($), 'cmd_description'),
335 argumentHint: '[on|off|status]',
336 })
337 registered = true
338 } catch (err) {
339 // The name is taken (a built-in or another plugin's command): leave that command alone.
340 // The saver still works through the questions.
341 }
342 return result
343 })
344
345 on('session.end', async ($, e, next) => {
346 resetState()
347 return next(e)
348 })
349
350 on('prompt.submit', async ($, e, next) => {
351 lastPromptAt = await $.clock.now()
352 // Only what the person types counts: task notifications, peer sessions, schedules, and other
353 // plugins also come through prompt.submit, and their text is usually English.
354 const kind = e.origin ? e.origin.kind : undefined
355 const verdict = kind === undefined || kind === 'composer' || kind === 'bridge' ? classifyText(e.text) : undefined
356 if (verdict !== undefined) {
357 votes.push(verdict)
358 if (votes.length > VOTES_KEPT) votes.shift()
359 }
360 return next(e)
361 })
362
363 on('session.measure', async ($, e, next) => {
364 await evaluate($)
365 if (pending !== null && !(await turnInFlight($))) launchAsk($)
366 return next(e)
367 })
368
369 // The measure event can arrive mid-turn or not at all; a finished main turn is the safe moment
370 // to ask, and a second chance to notice the limits.
371 on('turn.complete', async ($, e, next) => {
372 if (e.agentId === undefined) {
373 lastCompleteAt = await $.clock.now()
374 await evaluate($)
375 launchAsk($)
376 if (e.reason === 'answer' && !advisorChecked && pending === null && !asking) {
377 advisorChecked = true
378 offerAdvisor($)
379 }
380 }
381 return next(e)
382 })
383
384 on('turn.step', async function* ($, e, next) {
385 if (e.agentId === undefined) lastStepAt = await $.clock.now()
386 if (saver && e.agentId !== undefined) {
387 const cap = capEffort(e.model, e.effort)
388 if (cap.capped) {
389 if (!capToasted && !quiet) {
390 capToasted = true
391 const lang = await resolveLang($)
392 const model = t(lang, cap.tier === 'heavy' ? 'model_heavy' : 'model_sonnet')
393 $.ui.toast(t(lang, 'toast_cap', { model, from: cap.from, to: cap.effort }), { timeoutMs: 6000 })
394 }
395 $.ui.log('saver: subagent ' + e.agentId + ' on ' + e.model + ' effort ' + cap.from + ' -> ' + cap.effort, { to: 'debug' })
396 return yield* next({ ...e, effort: cap.effort })
397 }
398 }
399 return yield* next(e)
400 })
401
402 on('command.run', { command: 'saver' }, async ($, e, next) => {
403 if (!registered) return next(e)
404 const word = String(e.args || '').trim().toLowerCase()
405 const lang = await resolveLang($)
406 if (word === 'on') {
407 saver = true
408 snoozed = false
409 return { text: t(lang, 'cmd_on') }
410 }
411 if (word === 'off') {
412 saver = false
413 snoozed = true
414 return { text: t(lang, 'cmd_off') }
415 }
416 if (word === '' || word === 'status') return { text: await statusText($) }
417 return { text: t(lang, 'cmd_usage') }
418 })
419}
420hooks/saver-i18n.mjs 217 lines1// Messages and language detection for the saver mod (hooks/register.js). Pure functions, no Claude
2// Code API, so tests/saver-i18n.test.mjs can run them with plain Node. Languages: English (the
3// fallback) and Turkish. Add a language by adding an object to MESSAGES with the same keys; the
4// parity test fails until every key and every {placeholder} matches English.
5
6export const MESSAGES = {
7 en: {
8 win_five_hour: '5-hour',
9 win_seven_day: 'weekly',
10 unit_d: 'd',
11 unit_h: 'h',
12 unit_m: 'm',
13 unit_lt: '<1m',
14 reset_in: ' It resets in {duration}.',
15 ask:
16 'Your {window} limit is {percent}% used.{reset} Turn on the token saver for this session? (Opus subagents run at medium effort, Sonnet at high at most.)',
17 answer_on: 'Turn on saver',
18 answer_not_now: 'Not now',
19 answer_never: "Don't ask again this session",
20 toast_saver_on: 'Saver is on for this session. /saver off switches it off.',
21 toast_context: 'Context is {tokens} tokens and every request resends it. /compact mid-task, /clear between tasks.',
22 toast_age: 'This session was opened {duration} ago. A fresh one starts lean.',
23 toast_cap: 'Saver: {model} subagent effort {from} -> {to}',
24 model_heavy: 'Opus',
25 model_sonnet: 'Sonnet',
26 cmd_description: 'crew-chief token saver: on, off, or status (Opus subagents at medium effort)',
27 cmd_on: 'saver on for this session: Opus subagents at medium effort, Sonnet at high at most.',
28 cmd_off: 'saver off for this session; it will not ask again until /clear or a new session.',
29 cmd_usage: 'usage: /saver [on|off|status]',
30 status_on: 'saver ON',
31 status_off: 'saver off',
32 status_snoozed: ' (questions snoozed for this session)',
33 status_resets: ' (resets in {duration})',
34 status_no_data: 'no plan limit data (needs a Pro or Max subscription)',
35 status_context: 'context {tokens}',
36 percent: '{n}%',
37 model_opus: 'Opus',
38 model_fable: 'Fable',
39 model_haiku: 'Haiku',
40 advisor_ask_standard:
41 "The advisor is off. Turned on, {main} asks {advisor} for advice at hard moments (before choosing an approach, when stuck, before finishing). In Anthropic's own benchmarks Sonnet with an Opus advisor finished tasks about 12% cheaper and slightly better than Sonnet alone, and Haiku gained even more. It is experimental, and each consultation is billed at {advisor} rates. Turn it on? (/advisor off switches it off again.)",
42 advisor_ask_second:
43 "The advisor is off. Turned on, a second {advisor} reviews {main}'s plan at hard moments: an independent check, most useful for high-stakes work. It costs extra, since each consultation reads the whole conversation again at {advisor} rates, and it is experimental. Turn it on? (/advisor off switches it off again.)",
44 advisor_answer_on: 'Turn it on',
45 advisor_answer_later: 'Remind me in a week',
46 advisor_answer_never: "Don't ask again",
47 advisor_toast_on: 'Advisor set to {advisor}. /advisor off switches it off.',
48 advisor_toast_failed: 'Could not turn the advisor on from here. Run /advisor yourself to see why or to pick another model.',
49 },
50 tr: {
51 win_five_hour: '5 saatlik',
52 win_seven_day: 'haftalık',
53 unit_d: 'g',
54 unit_h: 'sa',
55 unit_m: 'dk',
56 unit_lt: '<1dk',
57 reset_in: ' {duration} sonra sıfırlanır.',
58 ask:
59 '{window} limitin %{percent} dolu.{reset} Bu session için token tasarruf modu açılsın mı? (Opus alt ajanları medium, Sonnet en fazla high effort ile çalışır.)',
60 answer_on: 'Tasarruf modunu aç',
61 answer_not_now: 'Şimdi değil',
62 answer_never: "Bu session'da bir daha sorma",
63 toast_saver_on: 'Tasarruf modu bu session için açık. Kapatmak için /saver off.',
64 toast_context: 'Bağlam {tokens} token oldu ve her istekte baştan gönderiliyor. Görev ortasında /compact, görevler arasında /clear iyi gelir.',
65 toast_age: 'Bu session {duration} önce açıldı. Yeni bir session daha hafif başlar.',
66 toast_cap: 'Tasarruf: {model} alt ajanı effort {from} -> {to}',
67 model_heavy: 'Opus',
68 model_sonnet: 'Sonnet',
69 cmd_description: 'crew-chief token tasarrufu: on, off veya status (Opus alt ajanları medium effort)',
70 cmd_on: 'tasarruf modu bu session için açık: Opus alt ajanları medium, Sonnet en fazla high effort ile çalışır.',
71 cmd_off: "tasarruf modu bu session için kapalı; /clear ya da yeni session'a kadar bir daha sormaz.",
72 cmd_usage: 'kullanım: /saver [on|off|status]',
73 status_on: 'tasarruf modu AÇIK',
74 status_off: 'tasarruf modu kapalı',
75 status_snoozed: ' (bu session için sorular kapalı)',
76 status_resets: ' ({duration} sonra sıfırlanır)',
77 status_no_data: 'plan limiti verisi yok (Pro veya Max abonelik gerekir)',
78 status_context: 'bağlam {tokens}',
79 percent: '%{n}',
80 model_opus: 'Opus',
81 model_fable: 'Fable',
82 model_haiku: 'Haiku',
83 advisor_ask_standard:
84 "Advisor kapalı. Açıkken {main}, zor anlarda (yaklaşım seçmeden önce, takılınca, bitirmeden önce) {advisor} modeline danışır. Anthropic'in kendi ölçümlerinde Opus advisor'lı Sonnet görevleri tek başına Sonnet'ten yaklaşık %12 daha ucuza ve biraz daha iyi bitirdi, Haiku ise daha da çok kazandı. Deneysel; her danışma {advisor} fiyatından faturalanır. Açılsın mı? (Tekrar kapatmak için /advisor off.)",
85 advisor_ask_second:
86 'Advisor kapalı. Açıkken ikinci bir {advisor}, zor anlarda {main} modelinin planını gözden geçirir: bağımsız bir kontrol, en çok yüksek riskli işlerde işe yarar. Ek maliyeti var, çünkü her danışmada bütün konuşma {advisor} fiyatından yeniden okunur; ayrıca deneysel. Açılsın mı? (Tekrar kapatmak için /advisor off.)',
87 advisor_answer_on: 'Aç',
88 advisor_answer_later: 'Bir hafta sonra hatırlat',
89 advisor_answer_never: 'Bir daha sorma',
90 advisor_toast_on: 'Advisor {advisor} olarak ayarlandı. Kapatmak için /advisor off.',
91 advisor_toast_failed: 'Advisor buradan açılamadı. Nedenini görmek ya da başka bir model seçmek için /advisor komutunu kendin çalıştır.',
92 },
93}
94
95export const LANGUAGES = Object.keys(MESSAGES)
96
97// t('tr', 'toast_context', { tokens: '160k' }). An unknown language or key falls back to English;
98// a placeholder with no value is left as written.
99export function t(lang, key, vars = {}) {
100 const table = MESSAGES[lang] ?? MESSAGES.en
101 const template = table[key] ?? MESSAGES.en[key] ?? key
102 return template.replace(/\{(\w+)\}/g, (whole, name) => (name in vars ? String(vars[name]) : whole))
103}
104
105// 'five_hour' -> "5-hour" / "5 saatlik"; a window kind this plugin does not know is shown as is.
106export function windowName(lang, kind) {
107 if (kind === 'five_hour') return t(lang, 'win_five_hour')
108 if (kind === 'seven_day') return t(lang, 'win_seven_day')
109 return String(kind)
110}
111
112export function unitsFor(lang) {
113 return { d: t(lang, 'unit_d'), h: t(lang, 'unit_h'), m: t(lang, 'unit_m'), lt: t(lang, 'unit_lt') }
114}
115
116// ---- language detection -------------------------------------------------------------------
117
118// Letters only Turkish uses (dotless i, g with breve, s with cedilla). o/u with umlaut and c with
119// cedilla are shared with German, French and others, so they are not evidence.
120const TURKISH_LETTERS = /[ığşİĞŞ]/g
121
122// Only words that are Turkish and nothing else. Short words that other languages also use (mi, ne,
123// ve, var, ile, ben, ama, sen ...) are left out on purpose: French, Italian, Spanish and Dutch
124// writers must never be read as Turkish. scripts/plugin-notices.sh keeps the same two lists in awk;
125// tests/saver-i18n.test.mjs fails when they drift apart.
126export const TURKISH_WORDS = new Set([
127 'bir', 'bu', 'için', 'evet', 'hayır', 'tamam', 'bana', 'gibi', 'daha', 'çok', 'yap', 'yaz',
128 'bakalım', 'nasıl', 'neden', 'şu', 'bunu', 'şunu', 'olsun', 'olur', 'değil', 'lütfen', 'merhaba',
129 'selam', 'peki', 'hepsini', 'sonra', 'önce', 'kadar', 'göster', 'ekle', 'sil', 'düzelt', 'devam',
130 'başla', 'şimdi', 'zaten', 'çünkü', 'hangi', 'burada', 'yeni', 'eski', 'iyi', 'günler',
131 'günaydın', 'teşekkürler', 'sağol',
132])
133
134export const ENGLISH_WORDS = new Set([
135 'the', 'and', 'is', 'are', 'to', 'of', 'for', 'with', 'that', 'this', 'you', 'can', 'it', 'in',
136 'be', 'do', 'not', 'what', 'how', 'please', 'yes', 'fix', 'add', 'run', 'why', 'when', 'where',
137 'will', 'would', 'should', 'have', 'has', 'was', 'from', 'my', 'your', 'we', 'me', 'if', 'then',
138 'so', 'but', 'or', 'as', 'at', 'by', 'all', 'now', 'just', 'also', 'make', 'use', 'need', 'want',
139])
140
141// Drops what is code or log output, not prose: fenced blocks, indented lines, lines with braces,
142// semicolons, arrows, "()" or "::", shell prompts, "SomethingError:" lines and stack frames.
143// Keywords such as for/in/if/is/not would otherwise count as English.
144export function stripCode(text) {
145 const out = []
146 let fenced = false
147 for (const line of String(text).split(/\r?\n/)) {
148 if (/^\s*(```|~~~)/.test(line)) {
149 fenced = !fenced
150 continue
151 }
152 if (fenced) continue
153 if (/^(\s{4,}|\t)/.test(line)) continue
154 if (/[{};]|=>|\(\)|::|^\s*\$ |^\s*\w+Error:|\bat\s+\S+\s*\(/.test(line)) continue
155 out.push(line)
156 }
157 return out.join('\n')
158}
159
160// 'tr', 'en', or undefined when the text is too short, too mixed, or mostly code to tell. Turkish
161// needs a real Turkish word, or three distinctive letters in text with no English word at all (one
162// or two letters, or letters among English words, are a name like Ayşe, Barış or Çağrı) and at least as much evidence as English; with two or more Turkish words a tie goes to
163// Turkish (a Turkish sentence around a pasted English error message). English needs two common
164// English words and more of them than Turkish evidence.
165export function classifyText(text) {
166 if (typeof text !== 'string' || text.trim() === '' || text.trimStart().startsWith('/')) return undefined
167 const sample = stripCode(text.slice(0, 2000))
168 const letters = Math.min((sample.match(TURKISH_LETTERS) || []).length, 6)
169 let wordsTr = 0
170 let wordsEn = 0
171 // toLowerCase turns the dotted capital İ into i plus a combining dot; drop the dot.
172 for (const word of sample.toLowerCase().replace(/̇/g, '').split(/[^\p{L}]+/u)) {
173 if (word === '') continue
174 if (TURKISH_WORDS.has(word)) wordsTr += 1
175 if (ENGLISH_WORDS.has(word)) wordsEn += 1
176 }
177 const turkish = wordsTr * 2 + letters
178 const turkishLeads = turkish > wordsEn || (wordsTr >= 2 && turkish >= wordsEn)
179 if ((wordsTr >= 1 || (letters >= 3 && wordsEn === 0)) && turkish >= 2 && turkishLeads) return 'tr'
180 if (wordsEn >= 2 && wordsEn > turkish) return 'en'
181 return undefined
182}
183
184// The majority of the recent per-prompt verdicts; a tie or an empty list decides nothing.
185export function majority(votes) {
186 const tr = votes.filter((v) => v === 'tr').length
187 const en = votes.filter((v) => v === 'en').length
188 if (tr > en) return 'tr'
189 if (en > tr) return 'en'
190 return undefined
191}
192
193// "turkish", "Türkçe", "tr" -> 'tr'; "english", "en" -> 'en'; anything else (a language this plugin
194// has no messages for, "turkmen", or garbage) -> undefined, so detection moves on to the next signal.
195export function languageFromName(name) {
196 if (typeof name !== 'string') return undefined
197 const n = name.trim().toLowerCase().replace(/\u0307/g, '')
198 if (['tr', 'turkish', 'turkce', 'türkçe'].includes(n)) return 'tr'
199 if (['en', 'english', 'ingilizce'].includes(n)) return 'en'
200 return undefined
201}
202
203// Which language to speak, and which signal decided. Order: CREW_CHIEF_LANG, Claude Code's
204// `language` setting, what the person has been writing, what was detected in an earlier session,
205// the system locale, English.
206export function pickLanguage({ override, setting, votes = [], stored, locale } = {}) {
207 const fromOverride = languageFromName(override)
208 if (fromOverride) return { lang: fromOverride, source: 'override' }
209 const fromSetting = languageFromName(setting)
210 if (fromSetting) return { lang: fromSetting, source: 'setting' }
211 const fromVotes = majority(votes)
212 if (fromVotes) return { lang: fromVotes, source: 'prompts' }
213 if (stored === 'tr' || stored === 'en') return { lang: stored, source: 'stored' }
214 if (typeof locale === 'string' && /^tr([_.@-]|$)/i.test(locale.trim())) return { lang: 'tr', source: 'locale' }
215 return { lang: 'en', source: 'default' }
216}
217hooks/saver-logic.mjs 120 lines1// Pure helpers for the saver mod (hooks/register.js). No Claude Code API in here, so
2// tests/saver-logic.test.mjs can run them with plain Node.
3
4export const FIVE_HOUR_DEFAULT = [70, 80, 90]
5export const SEVEN_DAY_DEFAULT = [50, 75, 85, 90]
6export const CONTEXT_TOAST_TOKENS = 150_000
7export const SESSION_AGE_TOAST_MS = 8 * 60 * 60 * 1000
8
9const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }
10
11// "70, 80,90" -> [70, 80, 90]. Anything that is not a whole number from 1 to 100 is dropped;
12// when nothing usable is left (or the variable is unset) the fallback list is used.
13export function parseThresholds(text, fallback) {
14 if (typeof text !== 'string' || text.trim() === '') return [...fallback]
15 const found = text
16 .split(',')
17 .map((part) => part.trim())
18 .filter((part) => /^\d{1,3}$/.test(part))
19 .map(Number)
20 .filter((n) => n >= 1 && n <= 100)
21 const unique = [...new Set(found)].sort((a, b) => a - b)
22 return unique.length > 0 ? unique : [...fallback]
23}
24
25// Which thresholds a window has newly crossed. `seen` holds keys for thresholds already acted on.
26// `top` is the highest new one (ask once for it); `crossed` lists the keys of every threshold at or
27// below the percent, so the caller can mark them all and never ask for the lower ones later.
28export function crossing(percent, thresholds, seen, keyPrefix) {
29 if (typeof percent !== 'number' || Number.isNaN(percent)) return { top: undefined, crossed: [] }
30 const reached = thresholds.filter((t) => percent >= t)
31 const fresh = reached.filter((t) => !seen.has(keyPrefix + t))
32 return {
33 top: fresh.length > 0 ? Math.max(...fresh) : undefined,
34 crossed: reached.map((t) => keyPrefix + t),
35 }
36}
37
38// 'claude-opus-5-5' / 'opus' -> 'heavy'; sonnet -> 'sonnet'; everything else -> 'other'.
39export function modelTier(model) {
40 const name = typeof model === 'string' ? model.toLowerCase() : ''
41 if (name.includes('opus') || name.includes('fable')) return 'heavy'
42 if (name.includes('sonnet')) return 'sonnet'
43 return 'other'
44}
45
46// The saver's effort ceiling for a subagent request. Opus and Fable run at medium at most,
47// Sonnet at high at most (no xhigh or max), everything else is left alone. A missing or numeric
48// effort is never touched.
49export function capEffort(model, effort) {
50 const rank = EFFORT_RANK[effort]
51 if (rank === undefined) return { effort, capped: false }
52 const tier = modelTier(model)
53 const ceiling = tier === 'heavy' ? 'medium' : tier === 'sonnet' ? 'high' : undefined
54 if (ceiling === undefined || rank <= EFFORT_RANK[ceiling]) return { effort, capped: false }
55 return { effort: ceiling, capped: true, from: effort, tier }
56}
57
58// 6_300_000 -> "1h 45m", 90_000 -> "2m" (rounded up), 1 ms -> "1m", zero or invalid -> "<1m".
59// `units` renames the letters (see unitsFor in saver-i18n.mjs).
60export function formatDuration(ms, units = { d: 'd', h: 'h', m: 'm', lt: '<1m' }) {
61 if (typeof ms !== 'number' || !(ms > 0)) return units.lt
62 const minutes = Math.ceil(ms / 60_000)
63 if (minutes < 1) return units.lt
64 const days = Math.floor(minutes / 1440)
65 const hours = Math.floor((minutes % 1440) / 60)
66 const rest = minutes % 60
67 if (days > 0) return `${days}${units.d} ${hours}${units.h}`
68 if (hours > 0) return `${hours}${units.h} ${rest}${units.m}`
69 return `${rest}${units.m}`
70}
71
72export function tokensLabel(n) {
73 if (typeof n !== 'number') return '?'
74 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
75}
76
77// ---- advisor offer -------------------------------------------------------------------------
78
79export const ADVISOR_LATER_MS = 7 * 24 * 60 * 60 * 1000
80
81// 'claude-sonnet-5-5' -> 'sonnet'. Used to pick the advisor and to name the model in a message.
82export function modelFamily(model) {
83 const name = typeof model === 'string' ? model.toLowerCase() : ''
84 for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
85 if (name.includes(family)) return family
86 }
87 return 'other'
88}
89
90// Whether and how to offer the advisor for this main model. Sonnet and Haiku get the usual offer
91// (a stronger model on call); Opus and Fable get the "second opinion" wording, with the advisor
92// Claude Code accepts for them (Opus 5+ or Fable for Opus, only Fable for Fable). Unknown models
93// get nothing: the pairing rules are not known.
94export function advisorPlan(model) {
95 const family = modelFamily(model)
96 if (family === 'sonnet' || family === 'haiku') return { family, advisor: 'opus', kind: 'standard' }
97 if (family === 'opus') return { family, advisor: 'opus', kind: 'second' }
98 if (family === 'fable') return { family, advisor: 'fable', kind: 'second' }
99 return undefined
100}
101
102// What is stored under the `advisor` key: 'never' and 'answered' end the offers for good,
103// 'later:<ms>' pauses them until that time, anything else (including nothing) allows one.
104export function advisorOfferAllowed(stored, now) {
105 if (stored === 'never' || stored === 'answered') return false
106 if (typeof stored === 'string' && stored.startsWith('later:')) {
107 const until = Number(stored.slice('later:'.length))
108 // A pause longer than a week was written with a clock that has since moved back: ignore it.
109 return Number.isFinite(until) ? now >= until || until - now > ADVISOR_LATER_MS : true
110 }
111 return true
112}
113
114// An environment variable that switches something on: set, and not 0/false/no/off.
115export function isEnvFlag(value) {
116 if (typeof value !== 'string') return false
117 const v = value.trim().toLowerCase()
118 return v !== '' && !['0', 'false', 'no', 'off'].includes(v)
119}
120