SLOPSHOPPER

Crew Chief

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…

newcommandtoastprompttimer
★ 2v0.3.1MITupdated 2026-10-09aykyusuf/crew-chief
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew-chief
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /saver ⎿ crew-chief: saver off · 5-hour 31% · context 97k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Crew Chief

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.

Türkçe README

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

Why

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:

  • Routing policy injected at every session start (and after /clear or compaction), so it is always active.
  • Seven tiered agents with tight "use when / don't use when" descriptions, tool limits, turn limits, and a fixed report format.
  • Escalation ladder: implementer → implementer-hard → main session, instead of retrying the same tier.
  • UI testing tiers: 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.
  • Read-only guard: a hook that stops the scanner, deep-reader, reviewer, and ui-smoke from writing files, changing git state, or installing anything, even when your session runs with permissions bypassed.
  • Overrides in plain language: "do it yourself", "use agents", "give this to Sonnet at medium", or /crew-chief:crew-mode solo.
  • See who runs where: the agent panel below the prompt shows each subagent's model and effort, for example ui-tester sonnet-5.5 · medium · 41.2k Checking the login flow.
  • Session handoff, offered once per project: three small files (status, task list, progress log) so a new session picks up where the last one stopped. See Session handoff.
  • A token saver that asks first: at 70/80/90 % of your 5-hour limit (50/75/85/90 % weekly) it asks whether to cap subagent effort for this session, and warns about huge contexts and day-long sessions. See Token saver.
  • An advisor offer, once: if the experimental /advisor is off, it explains the benefit and offers to turn it on. See Advisor offer.
  • Solo that holds: /crew-chief:crew-mode solo is enforced by a hook that blocks the Agent tool, not just requested in the prompt.
  • Update notices: after an update, your first prompt shows what changed, and an open session is told once when it is still running the old copy. See Updating.
  • A mechanism guide (routing skill) for when one subagent is the wrong tool: forks, Monitor, /loop, /goal, dynamic workflows, agent teams, routines.

Install

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.

Updating

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:

  • After an auto-update Claude Code prints 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).
  • After 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.
  • Your first prompt after an update shows a short "what's new": the first three items of that version's CHANGELOG section. A fresh install shows nothing, except the old-Claude-Code warning below (an upgrade from before 0.3.0 is announced too, if you had answered the handoff offer). Switch it off with 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.

Token saver

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.

  • When it asks: the first time your 5-hour limit passes 70, 80, or 90 %, or your weekly limit passes 50, 75, 85, or 90 %, once per threshold and limit window. If a session starts already past a threshold, it asks right away. If two thresholds are crossed at once, it asks once.
  • Answers: Turn on saver, Not now (asks again at the next threshold), Don't ask again this session.
  • What the saver does, only for subagents: Opus (and Fable) run at medium effort at most; Sonnet runs at high at most (no xhigh or max); Haiku and your main session are never touched. It changes the effort of each subagent request, not the model.
  • It never sticks: every new session, /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.
  • Toasts, once per session: when the context passes 150k tokens (/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 variableEffect
CREW_CHIEF_SAVER=offNo saver questions and no toasts (/saver still works; the one-time advisor offer has its own switch below)
CREW_CHIEF_SAVER_FIVE_HOUR=60,85Your own 5-hour thresholds
CREW_CHIEF_SAVER_SEVEN_DAY=50,90Your own weekly thresholds
CREW_CHIEF_LANG=tr or enForce the language of every message
CREW_CHIEF_ADVISOR_OFFER=offNever 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 offer

/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):

  • Sonnet or Haiku main model: "asks Opus at hard moments". Opus or Fable main model: the wording becomes "a second Opus (Fable) reviews the plan", an independent check that costs extra.
  • Answers: Turn it on runs /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.
  • Never offered when 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.

Use

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 sayWhat 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.goThat 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.

Session handoff

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.

FileHoldsChanges how
STATUS.mdNow, environment commands, next, open questions, failed attempts, decisionsRewritten to describe the present
tasks.jsonTasks with id, title, priority, measurable done_when, status, evidenceOnly status and evidence change: todo → in_progress → done (every part of done_when, sign-offs included) or blocked (reason in evidence)
PROGRESS.mdDated log, newest firstOne paragraph appended per session
CLAUDE.mdA start/end routine between <!-- crew-chief:handoff:start --> and :end markersAdded 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:

  • when any handoff-like file already exists at the root or one level down, under common names (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;
  • outside git repositories, in your home directory, and in temporary directories;
  • when 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.

What it runs

Everything is plain, readable shell in this repo. Nothing is sent over the network.

ComponentWhenWhat it does
scripts/session-policy.shSessionStart (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.shAgent panel refresh, while subagents runReads 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 eventsThe 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.shUserPromptSubmit and UserPromptExpansion; PreToolUse on Agentrecord: 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.shUserPromptSubmitOn 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.shPreToolUse on BashReads 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.

Privacy

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.

How routing decides

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.

Develop

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):

CaseWith pluginWithoutPlugin indicator
solo-override1.01.0no subagent spawned
named-model-effort1.01.0Agent called with model: sonnet, effort: medium
verbose-tests-go-to-scanner1.01.0crew-chief:scanner used
routing-skill-fires1.00.0crew-routing skill loaded

One run per arm is a smoke test, not a benchmark; use --runs 3 or more before drawing conclusions.

License

MIT

Source 3 files
hooks/register.js 420 lines
1// 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}
420
hooks/saver-i18n.mjs 217 lines
1// 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}
217
hooks/saver-logic.mjs 120 lines
1// 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