Automatic reasoning effort for main chats and sub-agents, with planning, cache checks, spawn tags, tripwires and control tools.

Reasoning effort for the Claude Code main chat and for the sub-agents it launches. Each main-chat turn starts at a planned level, the model raises or lowers its own with set_effort, failing tests raise it, and a cache check keeps every change safe. A sub-agent's effort is set per spawn with a tag, and an agent whose definition opts in with effortControl follows its own trouble while it works.
Main chat. The turn starts at planLevel (xhigh for a long prompt or a configured planUpshiftPatterns match); the model calls set_effort with a level for this turn only, or auto to hand the effort back. hintPatterns names design and similar words in the reminder without moving the level; planUpshiftPatterns defaults to empty. /effort <level> pins until /effort auto or /auto-effort-control on, surviving /clear. A bare /effort picker choice is inferred from the next turn's carried effort. Two failing test runs in a row lift the level one step and the next passing run returns it. After every change the plugin checks that the prompt cache survived and switches a model off where it did not. /auto-effort-control shows, switches off and on, and clears.
Sub-agents. Make [effort: low|medium|high|xhigh|max] the first non-blank text of the Agent prompt, on one line. The tag is removed before the sub-agent reads its prompt, and its own model requests run at that level. An agent definition with effortControl: on is steered: its effort rises at a failing test run or a red sub-task and returns once the trouble resolves, and it can press effort_up, effort_down and effort_auto itself.
It requires function hooks enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) and is tested on Claude Code 2.1.296. Install it with /plugin marketplace add rezzminator/auto-effort and /plugin install auto-effort-control@auto-effort-control (the GitHub repository keeps its old name, auto-effort, until it is renamed). Remove it with claude plugin uninstall auto-effort-control@auto-effort-control. It replaces the auto-effort and effort-live-control plugins and honours or reports their settings; the full documentation, the precedence of every source and the migration notes live in the repository: <https://github.com/rezzminator/auto-effort>
Set under the plugin's settings, or in the environment variable named in the table. A variable wins over the plugin's setting, which wins over the default; an unset or blank variable counts as not set. Write a boolean as 1, 0, true or false (any case), a list as a JSON array of strings ([] is the empty list). models and testCommandPatterns entries carry their scope, main: or subagent: (testCommandPatterns entries with neither apply to both); a models entry with neither is rejected. A bad value is reported by name and the default used. Settings and variables of the retired auto-effort and effort-live-control plugins are honoured for their scope or reported by old and new key.
| Option | Environment variable | Default | What it does | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
floor | AUTO_EFFORT_CONTROL_FLOOR | medium | The lowest reasoning effort the plugin sends: low, medium, high, xhigh or max. A lower level the model asks for is raised to this one. Must not be above ceiling. | |||||||||||||||||||||||||||||||||
ceiling | AUTO_EFFORT_CONTROL_CEILING | xhigh | The highest reasoning effort the plugin sends: low, medium, high, xhigh or max. A higher level the model asks for, or an escalation, is lowered to this one. Must not be below floor. | |||||||||||||||||||||||||||||||||
planLevel | AUTO_EFFORT_CONTROL_PLAN_LEVEL | high | The effort the first request of every turn runs at, before the model has said what the work needs: low, medium, high, xhigh or max. | |||||||||||||||||||||||||||||||||
planUpshiftPatterns | AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_PATTERNS | (none) | Optional regular expressions, matched case-insensitively. A match starts the turn at xhigh instead of planLevel. Empty by default; set patterns to restore keyword upshifts. | |||||||||||||||||||||||||||||||||
hintPatterns | AUTO_EFFORT_CONTROL_HINT_PATTERNS | \bdesign<br>\barchitect<br>\btrade-?offs?\b<br>\broot[- ]?causes?\b<br>`\bspec(s\ | ced\ | ification)?\b<br>\bthink (hard\ | harder\ | deeply\ | carefully\ | it through)\b<br>\bultrathink\b` | Main chat only. Case-insensitive regular expressions whose matches the reminder names as hints, never level changes. Up to three distinct whole words or phrases, in prompt order, each clipped to 40 characters. | |||||||||||||||||||||||||||
planUpshiftChars | AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_CHARS | 1500 | A prompt longer than this many characters starts its turn at xhigh instead of planLevel. | |||||||||||||||||||||||||||||||||
turnReminder | AUTO_EFFORT_CONTROL_TURN_REMINDER | true | On: each prompt carries one line naming the effort the turn starts at and asking the model to call set_effort in its first response. Off: only the tool's description asks for the call. | |||||||||||||||||||||||||||||||||
escalateAfterFailures | AUTO_EFFORT_CONTROL_ESCALATE_AFTER_FAILURES | 2 | After this many failing test runs in a row in one turn the effort rises one step, up to the ceiling, for the requests that follow; the count then starts again. A passing test run sets the count back to 0. | |||||||||||||||||||||||||||||||||
lowerOnPass | AUTO_EFFORT_CONTROL_LOWER_ON_PASS | true | On: the first passing test run after a rise returns the effort to the level the turn had before its failing streak raised it (never below floor), unless the model set a level with set_effort since. Off: the raised level holds until the turn ends. | |||||||||||||||||||||||||||||||||
failurePatterns | AUTO_EFFORT_CONTROL_FAILURE_PATTERNS | --- FAIL<br>FAIL(ED)?<br>`(?<!\d)[1-9]\d* (failed\ | failing)<br>AssertionError<br>panic:<br>Traceback` | One regular expression per entry, matched case-sensitively against the result of a test run (see testCommandPatterns). A test run whose result matches any entry fails; one that matches none passes. | ||||||||||||||||||||||||||||||||
failureTools | AUTO_EFFORT_CONTROL_FAILURE_TOOLS | Bash | One tool name per entry (Bash runs the tests). A call of these tools whose command matches testCommandPatterns is a test run; its result is matched against failurePatterns. | |||||||||||||||||||||||||||||||||
testCommandPatterns | AUTO_EFFORT_CONTROL_TEST_COMMAND_PATTERNS | main:\bgo\s+(-C\s+\S+\s+)?test\b<br>`main:\b(npm\ | pnpm\ | yarn\ | bun)\s+(run\s+)?test\b<br>main:\bvitest\b<br>main:\bjest\b<br>main:\bpytest\b<br>main:\bcargo\s+(test\ | nextest)\b<br>main:\bmake\b[^;&\ | \n]*?\b(test\ | check\ | verify)\b<br>main:\bjust\b[^;&\ | \n]*?\b(test\ | check)\b<br>main:\b(mvn\ | gradle\ | gradlew)\b[^;&\ | \n]*?\btest\b<br>main:\bdotnet\s+test\b<br>main:\bdeno\s+test\b<br>main:\bswift\s+test\b<br>main:\bmix\s+test\b<br>main:\b(rspec\ | phpunit\ | ctest\ | tox\ | nox)\b<br>main:\bscripts?/test[\w.-]*\.sh\b<br>subagent:\bgo\s+(-C\s+\S+\s+)?test\b<br>subagent:\b(npm\ | pnpm\ | yarn\ | bun)\s+(run\s+)?test\b<br>subagent:\bvitest\b<br>subagent:\bjest\b<br>subagent:\bpytest\b<br>subagent:\bpython(\d(\.\d+)?)?\s+-m\s+unittest\b<br>subagent:\bnode\s+(--test\ | --test-only)\b<br>subagent:\bcargo\s+(test\ | nextest)\b<br>subagent:\bmake\b[^;&\ | \n]*?\b(test\ | check\ | verify)\b<br>subagent:\bjust\b[^;&\ | \n]*?\b(test\ | check)\b<br>subagent:\b(mvn\ | gradle\ | gradlew)\b[^;&\ | \n]*?\btest\b<br>subagent:\bdotnet\s+test\b<br>subagent:\bdeno\s+test\b<br>subagent:\bswift\s+test\b<br>subagent:\bmix\s+test\b<br>subagent:\b(rspec\ | phpunit\ | ctest\ | tox\ | nox)\b<br>subagent:\bscripts?/test[\w.-]*\.sh\b` | Regular expressions prefixed main: or subagent: select that scope; unprefixed expressions apply to both. Defaults retain each original scope. |
userEffortPins | AUTO_EFFORT_CONTROL_USER_EFFORT_PINS | true | On: when you change the effort yourself with /effort, auto-effort-control stops steering that session until /effort auto or /auto-effort-control on. The pin survives /clear. Off: it keeps steering whatever you set. | |||||||||||||||||||||||||||||||||
models | AUTO_EFFORT_CONTROL_MODELS | main:claude-opus-5-5=allow<br>main:claude-fable-5-1=allow<br>main:claude-sonnet-5-5=probation<br>main:claude-haiku-5-5=probation<br>main:claude-sonnet-5=deny<br>subagent:claude-opus-5-5<br>subagent:claude-fable-5-1<br>subagent:claude-sonnet-5-5 | Scope-tagged entries: main:model=allow\ | probation\ | deny and subagent:model-prefix. Unqualified entries are rejected. | |||||||||||||||||||||||||||||||
unknownModels | AUTO_EFFORT_CONTROL_UNKNOWN_MODELS | off | off leaves it untouched; allow, probation and deny act as in models. | |||||||||||||||||||||||||||||||||
probationSwitches | AUTO_EFFORT_CONTROL_PROBATION_SWITCHES | 5 | How many effort switches in a row of a probation model must keep the cache (see canaryThreshold) before it counts as allow. | |||||||||||||||||||||||||||||||||
canaryThreshold | AUTO_EFFORT_CONTROL_CANARY_THRESHOLD | 0.9 | After an effort change, the switched request must read at least this fraction of the previous request's whole prompt from the prompt cache. Below it the plugin stops changing effort on that model, tells you once, and remembers across sessions until /auto-effort-control clear. | |||||||||||||||||||||||||||||||||
cacheTtlSeconds | AUTO_EFFORT_CONTROL_CACHE_TTL_SECONDS | 300 | When more than this many seconds passed between two requests the cache may have expired on its own, so the check judges nothing. 300 fits the five-minute cache; use 3600 with the one-hour cache. | |||||||||||||||||||||||||||||||||
statusLine | AUTO_EFFORT_CONTROL_STATUS_LINE | true | On: when the effort's mode, level or reason changes, a short notice shows the reason alone. The mode and level themselves are in stateFile, for a status line to show. Off: no such notice. | |||||||||||||||||||||||||||||||||
logFile | AUTO_EFFORT_CONTROL_LOG_FILE | ~/.local/state/auto-effort-control/decisions.jsonl | Where decisions are appended, one JSON row per decision, request and turn, never the prompt text. The session id is inserted before the extension, one file per session; after 1000 rows the next file starts (decisions.<session>.2.jsonl, .3.jsonl, ...). A leading ~ is your home directory. Empty: no log. | |||||||||||||||||||||||||||||||||
stateFile | AUTO_EFFORT_CONTROL_STATE_FILE | ~/.local/state/auto-effort-control/state.json | One JSON line per session naming the mode the main chat's effort is in (auto, model, pin or off), its level, the reason and the keeper that vouches for it, rewritten when one of them changes; the line at a path the plugin leaves is retired (keeper 0). The session id is inserted before the extension (state.<session>.json). A leading ~ is your home directory; a relative path is under the session's working directory, so give an absolute one. Empty: no file. | |||||||||||||||||||||||||||||||||
parentTag | AUTO_EFFORT_CONTROL_PARENT_TAG | honor | honor: a tag on the Agent prompt wins over the effort: of the agent's definition, and a steered agent starts at the tag held within its floor and ceiling. ignore: the definition's effort: keeps and the tag is ignored. effortControl.parentTag overrides it. | |||||||||||||||||||||||||||||||||
judge | AUTO_EFFORT_CONTROL_JUDGE | false | Off (default): every tripwire (a failing test run, a sub-task that returned red, a red result sent back) raises the effort one step. On: a tripwire makes one cheap model call that estimates the probability that the trouble continues; the effort rises one step only when it is at least judgeRaiseAt. An agent's effortControl.judge overrides it. | |||||||||||||||||||||||||||||||||
judgeRaiseAt | AUTO_EFFORT_CONTROL_JUDGE_RAISE_AT | 25 | With the judge on: a probability (0 to 100) at or above this raises the effort one step; below it the effort holds. The judge never lowers it. | |||||||||||||||||||||||||||||||||
tripwires | AUTO_EFFORT_CONTROL_TRIPWIRES | fail1,fail2,red,redispatch | The armed tripwires, comma-separated: fail1 (the first failing test run), fail2 (the 2nd failing run in a row, then every even count), red (a sub-task returned red), redispatch (a red result sent back). A disarmed tripwire is counted but raises nothing; empty arms none. effortControl.tripwires overrides it. | |||||||||||||||||||||||||||||||||
struggleDecay | AUTO_EFFORT_CONTROL_STRUGGLE_DECAY | 20 | A steered agent's open struggle ends once it has made this many requests without a new tripwire: its effort returns to the level it had before, and the red sub-agent returns still open and the failing-test streak are cleared. A later tripwire opens a new struggle. 0 turns it off. effortControl.struggleDecay overrides it. | |||||||||||||||||||||||||||||||||
judgeModel | AUTO_EFFORT_CONTROL_JUDGE_MODEL | haiku | The model alias or id of the judge call (it costs one call per tripwire). | |||||||||||||||||||||||||||||||||
judgeEffort | AUTO_EFFORT_CONTROL_JUDGE_EFFORT | xhigh | The reasoning effort of the judge call: low, medium, high, xhigh or max. | |||||||||||||||||||||||||||||||||
judgeTimeoutMs | AUTO_EFFORT_CONTROL_JUDGE_TIMEOUT_MS | 45000 | A judge call that has not answered after this many milliseconds is dropped and the effort rises one step instead. | |||||||||||||||||||||||||||||||||
buttons | AUTO_EFFORT_CONTROL_BUTTONS | true | On: every agent can call effort_up, effort_down and effort_auto; a live-controlled agent moves its own effort one step and holds it there until effort_auto hands it back to the automatic rules at its home level; any other agent is refused, the main chat with the name of its own effort control. Off: the tools are not registered. | |||||||||||||||||||||||||||||||||
announce | AUTO_EFFORT_CONTROL_ANNOUNCE | true | On: the agent's next tool result carries one line naming its new effort and why. Off: it is not told. | |||||||||||||||||||||||||||||||||
readOnlyCommands | AUTO_EFFORT_CONTROL_READ_ONLY_COMMANDS | cat<br>grep<br>egrep<br>fgrep<br>rg<br>ag<br>ack<br>echo<br>printf<br>sed<br>awk<br>head<br>tail<br>less<br>more<br>ls<br>find<br>fd<br>git<br>wc<br>which<br>type<br>man<br>file<br>stat<br>diff<br>jq<br>yq<br>sort<br>uniq<br>cut<br>tr<br>tee<br>test<br>[<br>[[<br>cd<br>pushd<br>popd<br>mkdir<br>rm<br>cp<br>mv<br>touch<br>chmod<br>ln<br>basename<br>dirname<br>realpath<br>readlink<br>date<br>sleep<br>true<br>false<br>export<br>read<br>wait<br>kill | One program name per entry. A command whose program is listed is skipped when a Bash call is checked for a test run, so grep, cat, echo or git naming a test command is not one. The Bash call is split at ; & \ | and newlines, heredoc bodies are dropped, and env, time, nice, nohup, sudo, command, exec and timeout are looked through. source and . are not listed because they execute. A foreground Bash call whose every command is listed is also a file read, the only shell call a return line is read from. | ||||||||||||||||||||||||||||||||
redWords | AUTO_EFFORT_CONTROL_RED_WORDS | FAIL<br>FAILED<br>BLOCKED<br>PARTIAL<br>SPEC-DRIFT | A sub-agent answer or a read result whose line is WORD, WORD name or WORD name: detail with one of these words is a failed return. Case-sensitive. | |||||||||||||||||||||||||||||||||
greenWords | AUTO_EFFORT_CONTROL_GREEN_WORDS | DONE<br>PASS<br>FIXED | Like redWords, for a return that clears an earlier red one from the same unit. | |||||||||||||||||||||||||||||||||
agentDirs | AUTO_EFFORT_CONTROL_AGENT_DIRS | (none) | One directory per entry, searched after .claude/agents of the project, of CLAUDE_CONFIG_DIR and of the home directory, for the definition of an agent type (its effort and effortControl). | |||||||||||||||||||||||||||||||||
logPackets | AUTO_EFFORT_CONTROL_LOG_PACKETS | false | On: judge rows carry the snapshot sent to the judge and its answer, which holds the agent's opening brief and last words. Off: they do not. |
Error-level diagnostics appear on screen and in errors.jsonl. Warnings and routine effort-change diagnostics go only to warnings.jsonl and info.jsonl under $PLUGIN_LOG_DIR/auto-effort-control/, or $HOME/.local/state/auto-effort-control/logs/ when PLUGIN_LOG_DIR is unset. If no log directory resolves or writes fail, every unwritten warning and info message falls back to the screen. The decision logs named by logFile are separate: the main chat writes decisions.<session>.jsonl, the sub-agents decisions.<session>.subagent.jsonl.
Built and maintained with Professor.
hooks/auto-effort-control.ts 1561 lines1import { projectOptions, resolveOptions, type Scope, type ReadValue } from '../src/options.ts';
2import type { ConfigRow, EngineInterface, Register, ModelCompleteRequest, ModelCompleteResult, On, PluginOptions } from 'claude-code';
3import type { Usage } from '../src/canary.ts';
4import { parseCommand, statusText, USAGE } from '../src/command.ts';
5import { Controller, readSessionState, type Row, type StepPlan } from '../src/controller.ts';
6import { EMPTY_PERSISTED, mergeModels, readPersisted } from '../src/gate.ts';
7import { levelsWithin } from '../src/levels.ts';
8import { expandHome, fillSegments, SEGMENT_ROWS, sessionLogPath } from '../src/log.ts';
9import { normalizeModel } from '../src/model.ts';
10import { envName, PLUGIN, resolveSettings, SETTING_KEYS, type EnvValues } from '../src/settings.ts';
11import { KEEPER_ARGV, stateText, type EffortState } from '../src/state.ts';
12import { quote, toolDescription } from '../src/text.ts';
13import { diagnosticOnScreen, levelRow, pluginLogDir, PluginLogQueue, type PluginLogLevel } from '../src/plugin-log.ts';
14import { VERSION } from '../src/version.ts';
15
16// Thin adapter: every decision lives in src/controller.ts. A failure is logged with its context and the request,
17// the prompt or the tool result goes through as it was.
18
19const TOOL_NAME = 'set_effort';
20const STORE_KEY = 'state';
21/** `<plugin>.<field>`, or `<plugin>@<marketplace>.<field>` where the marketplace name may hold dots. */
22const ROW_KEY = new RegExp(`^${PLUGIN}(?:@.+)?\\.(\\w+)$`);
23
24function message(error: unknown): string {
25 return error instanceof Error ? error.message : String(error);
26}
27
28type State = {
29 ctl: Controller;
30 /** The options the plugin registered with: what applies where /config has no row. */
31 options: Record<string, unknown>;
32 errors: string[];
33 /** The option variables as the last successful read held them: what a failed read falls back to. */
34 env: EnvValues;
35 mergedOptions: Record<string, unknown>;
36 migrationErrors: string[];
37 /** The option errors last reported, so a new prompt does not report them again. */
38 errorKey: string;
39 settingsFrom: string;
40 starting: Promise<void> | undefined;
41 /** The floor and ceiling the tool is registered for. */
42 toolKey: string | undefined;
43 /** The session the rows go to; a new id at a prompt (after /clear) starts a new session. */
44 sessionId: string | undefined;
45 /** The on/off and pin the host holds for the session, as text: a save is skipped when nothing changed. */
46 savedSession: string;
47 /** The session whose saved on/off and pin were read; another id at a prompt reads its own. */
48 restored: string | undefined;
49 /** The on/off and pin, as text, whose save failed and was reported: a retry that fails again says nothing. */
50 saveFailed: string;
51 /** Rows waiting for the log, each with the session it was recorded in. */
52 pending: Array<{ session: string | undefined; line: string }>;
53 /** Models whose marks changed in this session and are not saved yet. */
54 unsaved: Set<string>;
55 /** Each session log's newest file and its text, so a flush rewrites at most SEGMENT_ROWS rows and reads nothing. */
56 segments: Map<string, { segment: number; text: string }>;
57 writing: Promise<void>;
58 pluginLog?: PluginLogQueue;
59 pluginWriting: Promise<void>;
60 pluginFailure: boolean;
61 /** The mode, level and reason last seen, so the reason shows as a notice only when one of them changes. */
62 shown: string;
63 /** The state file's path, state and keeper last written, so an unchanged line is not written again. */
64 stateWritten: string;
65 /** The path whose line names a live keeper, and the state it holds: retired (keeper 0) when the plugin leaves it. */
66 statePath: string;
67 stateLast: EffortState | undefined;
68 /** The pid of the keeper vouching for the state file's line; 0 while none runs. */
69 keeper: number;
70 keeperStarting: Promise<void> | undefined;
71 /** A keeper start failed and was said: the next failure says nothing until one starts. */
72 keeperFailed: boolean;
73 /** This keeper's end was said: a later end says nothing until another keeper starts. */
74 keeperEnded: boolean;
75 /** The session whose line was retired at its end: no publish vouches for it again until a hook follows a session. */
76 stateRetired: string | undefined;
77 /** Lines whose retire failed, by path, with the state each holds: tried again at every write; a failure said once per path. */
78 stateUnretired: Map<string, { last: EffortState; said: boolean }>;
79 /** The path and state whose write failed last, so a failure is said once per failing path and state; '' after a success. */
80 stateFailed: string;
81 stateWriting: Promise<void>;
82};
83
84function pluginFallback($: EngineInterface, notice: string): void {
85 $.ui.log(`auto-effort-control: ${notice}`);
86}
87
88function runPluginLog($: EngineInterface, argv: string[]) {
89 return $.process.run(argv, { timeoutMs: 5000 });
90}
91
92function plog($: EngineInterface, st: State, level: PluginLogLevel, event: string, msg: string, where: string, detail?: Record<string, unknown>): void {
93 const session = st.sessionId;
94 const shown = diagnosticOnScreen(level, true);
95 if (shown) {
96 try { $.ui.log(msg); }
97 catch (screenError) { // A broken screen must not prevent the log write or the request.
98 void screenError;
99 }
100 }
101 st.pluginWriting = st.pluginWriting.then(async () => {
102 let logged = false;
103 try {
104 if (st.pluginLog === undefined) {
105 const env = await $.env.get('PLUGIN_LOG_DIR');
106 const home = await $.env.get('HOME');
107 st.pluginLog = new PluginLogQueue(pluginLogDir(env, home, PLUGIN), PLUGIN, (notice) => pluginFallback($, notice));
108 }
109 const row = levelRow({ plugin: PLUGIN, level, event, msg, where, session, detail }, await $.clock.now());
110 st.pluginLog.add(row);
111 const result = await st.pluginLog.flush((argv) => runPluginLog($, argv));
112 logged = result.dropped === 0;
113 st.pluginFailure = false;
114 } catch (error) {
115 if (!st.pluginFailure) {
116 st.pluginFailure = true;
117 try { pluginFallback($, `plugin log initialization failed: ${message(error)}; 1 rows dropped`); }
118 catch (screenError) { // With both outputs gone, contain the failure so the request still goes through.
119 void screenError;
120 }
121 }
122 }
123 if (!shown && diagnosticOnScreen(level, logged)) {
124 try { $.ui.log(msg); }
125 catch (screenError) { // Both outputs are gone; the request must still go through.
126 void screenError;
127 }
128 }
129 });
130}
131
132function note($: EngineInterface, st: State, level: PluginLogLevel, event: string, where: string, what: string, error: unknown, then: string): void {
133 const text = `auto-effort-control: ${what} failed: ${message(error)}; ${then}`;
134 plog($, st, level, event, text, where, { error: message(error) });
135}
136
137function toUsage(raw: unknown): Usage | null {
138 if (typeof raw !== 'object' || raw === null) return null;
139 const u = raw as Record<string, unknown>;
140 if (typeof u.input_tokens !== 'number') return null;
141 const count = (key: string): number => (typeof u[key] === 'number' ? u[key] : 0);
142 return {
143 input_tokens: u.input_tokens,
144 output_tokens: count('output_tokens'),
145 cache_read_input_tokens: count('cache_read_input_tokens'),
146 cache_creation_input_tokens: count('cache_creation_input_tokens'),
147 };
148}
149
150// The session log's newest file, found once per session: the highest segment that exists, with its rows.
151async function newestSegment($: EngineInterface, st: State, logFile: string, id: string): Promise<{ segment: number; text: string }> {
152 const key = sessionLogPath(logFile, id);
153 const known = st.segments.get(key);
154 if (known !== undefined) return known;
155 let segment = 1;
156 while (await $.fs.exists(sessionLogPath(logFile, id, segment + 1))) segment += 1;
157 const file = sessionLogPath(logFile, id, segment);
158 const found = { segment, text: (await $.fs.exists(file)) ? await $.fs.read(file) : '' };
159 st.segments.set(key, found);
160 return found;
161}
162
163// The decision log: rows are queued and written one batch at a time, never on the request's path.
164async function flush($: EngineInterface, st: State): Promise<void> {
165 const batch = st.pending.splice(0);
166 if (batch.length === 0) return;
167 const bySession = new Map<string | undefined, string[]>();
168 for (const { session, line } of batch) bySession.set(session, [...(bySession.get(session) ?? []), line]);
169 for (const [session, lines] of bySession) {
170 let left = lines.length;
171 try {
172 const file = st.ctl.settings.logFile;
173 if (file === '') return;
174 const id = session ?? (st.sessionId ??= await $.session.id());
175 const home = await $.env.get('HOME');
176 const logFile = expandHome(file, home);
177 const newest = await newestSegment($, st, logFile, id);
178 const opened = newest.segment;
179 let skipped = 0;
180 for (const part of fillSegments(newest.text, newest.segment, lines, SEGMENT_ROWS)) {
181 let segment = part.segment + skipped;
182 // A new file the probe did not reach (past a missing one) is never written over: the next free number is taken.
183 while (segment > opened && (await $.fs.exists(sessionLogPath(logFile, id, segment)))) {
184 segment += 1;
185 skipped += 1;
186 }
187 await $.fs.write(sessionLogPath(logFile, id, segment), part.text);
188 left -= part.added;
189 newest.segment = segment;
190 newest.text = part.text;
191 }
192 } catch (error) {
193 note($, st, 'error', 'decision-log-write-failed', 'hooks/auto-effort-control.ts#flush', 'writing the decision log', error, `${left} rows were not kept`);
194 }
195 }
196}
197
198function record($: EngineInterface, st: State, rows: readonly Row[]): void {
199 if (rows.length === 0 || st.ctl.settings.logFile === '') return;
200 const ts = new Date().toISOString();
201 st.pending.push(...rows.map((row) => ({ session: st.sessionId, line: JSON.stringify({ ts, ...row }) })));
202 st.writing = st.writing.then(() => flush($, st));
203}
204
205function notice($: EngineInterface, st: State, text: string): void {
206 try {
207 $.ui.toast(text);
208 } catch (error) {
209 note($, st, 'warning', 'toast-failed', 'hooks/auto-effort-control.ts#notice', 'showing a notice', error, `the notice was: ${text}`);
210 }
211}
212
213// The keeper vouches for the state file's line while this module is loaded: the host kills it when the module unloads
214// (a disable, a reload, the session's process ending), and it exits within two seconds of the process that started it.
215// It prints its own pid first, on stdout; one that cannot start, prints no pid within two seconds or ends leaves keeper 0,
216// written to the line it vouched for at once, so the line reads stale. It never rejects.
217function ensureKeeper($: EngineInterface, st: State): Promise<void> {
218 if (st.keeper !== 0) return Promise.resolve();
219 st.keeperStarting ??= startKeeper($, st).finally(() => {
220 st.keeperStarting = undefined;
221 });
222 return st.keeperStarting;
223}
224
225async function startKeeper($: EngineInterface, st: State): Promise<void> {
226 try {
227 const stream = $.process.spawn({ argv: KEEPER_ARGV });
228 // The pid is the first stdout line, which may come in pieces; what the shell writes to stderr is no pid.
229 const deadline = $.clock.sleep(2000).then(() => undefined);
230 let out = '';
231 let err = '';
232 let why = '';
233 while (!out.includes('\n')) {
234 const piece = await Promise.race([stream.next(), deadline]);
235 if (piece === undefined) why = 'it printed no pid within 2 s';
236 else if (piece.done) why = `it exited at once (code ${String(piece.value.code)})`;
237 else if (piece.value.stream === 'stdout') out += piece.value.text;
238 else err += piece.value.text;
239 if (why !== '') break;
240 }
241 const line = out.split('\n')[0]!.trim();
242 if (why === '' && (!/^\d+$/.test(line) || Number(line) <= 0)) why = `its first output ${JSON.stringify(line)} is no pid`;
243 if (why !== '') {
244 void stream.return(undefined as never).catch(() => undefined);
245 throw new Error(err === '' ? why : `${why}; it wrote to stderr: ${JSON.stringify(err.trim())}`);
246 }
247 st.keeper = Number(line);
248 st.keeperFailed = false;
249 st.keeperEnded = false;
250 void (async () => {
251 let why = 'it exited';
252 let stopped = false;
253 try {
254 for (;;) {
255 const piece = await stream.next();
256 if (piece.done) {
257 why = `it exited (code ${String(piece.value.code)}, signal ${String(piece.value.signal ?? 'none')})`;
258 stopped = piece.value.signal === 'SIGTERM';
259 break;
260 }
261 }
262 } catch (error) {
263 why = message(error);
264 }
265 st.keeper = 0;
266 try {
267 publish($, st);
268 } catch {
269 // A module the host unloaded has no state left to write: the unload retired its line.
270 }
271 if (st.keeperEnded) return;
272 st.keeperEnded = true;
273 try {
274 // SIGTERM is the host unloading the plugin (a session's end, a reload, a disable): the expected end, not a fault.
275 if (stopped) plog($, st, 'info', 'state-keeper-stopped', 'auto-effort-control: the state file keeper was stopped by the host (SIGTERM); a status line shows the state stale until the next prompt starts another', 'hooks/auto-effort-control.ts#startKeeper');
276 else note($, st, 'warning', 'state-keeper-ended', 'hooks/auto-effort-control.ts#startKeeper', 'keeping the state file keeper', new Error(`the state file keeper ended: ${why}`), 'a status line shows the state stale until the next prompt starts another');
277 } catch {
278 // A module the host unloaded has no screen left: its keeper ending is the unload itself.
279 }
280 })();
281 } catch (error) {
282 st.keeper = 0;
283 if (st.keeperFailed) return;
284 st.keeperFailed = true;
285 try {
286 note($, st, 'warning', 'state-keeper-failed', 'hooks/auto-effort-control.ts#startKeeper', 'starting the state file keeper', error, 'the state file names no keeper, so a status line shows it stale; the next prompt tries again');
287 } catch (screenError) {
288 plog($, st, 'warning', 'state-keeper-failed', `auto-effort-control: showing the state file keeper failure failed: ${message(screenError)}; the failure was: ${message(error)}`, 'hooks/auto-effort-control.ts#startKeeper');
289 }
290 }
291}
292
293// The line the plugin leaves: its last state, vouched by no keeper. A retire that fails is kept and tried again at every
294// write until one lands, and said once per path.
295async function retireState($: EngineInterface, st: State): Promise<void> {
296 const path = st.statePath;
297 const last = st.stateLast;
298 st.statePath = '';
299 st.stateLast = undefined;
300 st.stateWritten = '';
301 if (path === '' || last === undefined) return;
302 const held = st.stateUnretired.get(path);
303 st.stateUnretired.set(path, { last, said: held?.said ?? false });
304 await retryRetires($, st, '');
305}
306
307async function retryRetires($: EngineInterface, st: State, keep: string): Promise<void> {
308 for (const [path, held] of [...st.stateUnretired]) {
309 if (path === keep) {
310 // The line is written again with its keeper: nothing is left to retire there.
311 st.stateUnretired.delete(path);
312 continue;
313 }
314 try {
315 await $.fs.write(path, stateText(held.last, 0, new Date(await $.clock.now())));
316 st.stateUnretired.delete(path);
317 } catch (error) {
318 if (held.said) continue;
319 held.said = true;
320 try {
321 note($, st, 'warning', 'state-file-retire-failed', 'hooks/auto-effort-control.ts#retireState', `retiring the state file ${path}`, error, 'a status line reading it trusts it until a later write retires it');
322 } catch (screenError) {
323 plog($, st, 'warning', 'state-file-retire-failed', `auto-effort-control: showing the state file retire failure failed: ${message(screenError)}; the failure was: ${message(error)}`, 'hooks/auto-effort-control.ts#retireState');
324 }
325 }
326 }
327}
328
329// The state file a status line reads: rewritten whole when the path, the state or the keeper changes; the line at a
330// path the plugin leaves (stateFile changed or emptied, a new session) is retired first. A failed write is tried again
331// at every publish until one succeeds, and said once per failing path and state. It never rejects, so a failure cannot
332// stop the writes queued after it.
333async function writeState($: EngineInterface, st: State, state: EffortState): Promise<void> {
334 const file = st.ctl.settings.stateFile;
335 let key = `${file}\n${JSON.stringify(state)}`;
336 try {
337 let path = '';
338 if (file !== '') {
339 const id = st.sessionId ?? (st.sessionId = await $.session.id());
340 path = sessionLogPath(expandHome(file, await $.env.get('HOME')), id);
341 }
342 if (st.statePath !== '' && st.statePath !== path) await retireState($, st);
343 // A line retired at its session's end stays retired: a publish still running for that session writes nothing.
344 if (path === '' || (st.stateRetired !== undefined && st.stateRetired === st.sessionId)) {
345 await retryRetires($, st, '');
346 return;
347 }
348 await retryRetires($, st, path);
349 const keeper = st.keeper;
350 key = `${path}\n${JSON.stringify(state)}\n${String(keeper)}`;
351 if (key === st.stateWritten) return;
352 await $.fs.write(path, stateText(state, keeper, new Date(await $.clock.now())));
353 st.stateWritten = key;
354 st.statePath = path;
355 st.stateLast = state;
356 st.stateFailed = '';
357 } catch (error) {
358 if (st.stateFailed === key) return;
359 st.stateFailed = key;
360 try {
361 note($, st, 'warning', 'state-file-write-failed', 'hooks/auto-effort-control.ts#writeState', 'writing the state file', error, 'a status line reading it shows the state it held before');
362 } catch (screenError) {
363 plog($, st, 'warning', 'state-file-write-failed', `auto-effort-control: showing the state file failure failed: ${message(screenError)}; the failure was: ${message(error)}`, 'hooks/auto-effort-control.ts#writeState');
364 }
365 }
366}
367
368// The session's state, after anything that may change it: the reason as a notice when the mode, level or reason changed
369// while the plugin steers, and the state file, never on the request's path.
370function publish($: EngineInterface, st: State): void {
371 const state = st.ctl.stateNow();
372 const shown = `${state.mode}|${String(state.level)}|${state.reason}`;
373 if (shown !== st.shown) {
374 st.shown = shown;
375 if (st.ctl.settings.statusLine && state.level !== null && (state.mode === 'auto' || state.mode === 'model')) notice($, st, state.reason);
376 }
377 st.stateWriting = st.stateWriting.then(() => writeState($, st, state));
378}
379
380function reportErrors($: EngineInterface, st: State, errors: string[]): void {
381 st.errors = errors;
382 const key = errors.join('\n');
383 if (key === st.errorKey) return;
384 for (const line of errors) {
385 plog($, st, 'error', 'option-error', line, 'hooks/auto-effort-control.ts#reportErrors');
386 }
387 st.errorKey = key;
388 record($, st, errors.map((line) => ({ event: 'option-error', message: line })));
389}
390
391// The tool is registered for the levels the clamp lets through, so a changed floor or ceiling registers it again.
392async function ensureTool($: EngineInterface, st: State): Promise<void> {
393 const { floor, ceiling } = st.ctl.settings;
394 const key = `${floor}|${ceiling}`;
395 if (key === st.toolKey) return;
396 const levels = levelsWithin(floor, ceiling);
397 const { tool } = await $.tool.register({
398 name: TOOL_NAME,
399 description: toolDescription(floor, ceiling),
400 inputSchema: {
401 type: 'object',
402 properties: {
403 level: { type: 'string', enum: [...levels, 'auto'], description: 'The reasoning effort from the next request on; it holds until this turn ends or you call it with auto, which hands the effort back to the automatic rules.' },
404 effort: { type: 'string', enum: [...levels, 'auto'], description: 'The same as level.' },
405 reason: { type: 'string', description: 'A few words on why this level fits the work ahead.' },
406 },
407 },
408 isDeferred: false,
409 });
410 st.ctl.toolName = tool;
411 st.toolKey = key;
412}
413
414// The host keeps the on/off and the pin per session id, so a reload (an option change) builds a controller that carries on.
415// Only a value read back counts as saved: after a failed or unusable read the next save writes what the session does.
416async function restoreSession($: EngineInterface, st: State): Promise<void> {
417 if (st.sessionId === undefined) return;
418 st.restored = st.sessionId;
419 st.savedSession = '';
420 try {
421 const { value } = await $.state.get({ plugin: 'auto-effort-control', key: 'session', id: st.sessionId });
422 if (value !== undefined) {
423 const saved = readSessionState(value);
424 if (saved === undefined) {
425 note($, st, 'error', 'saved-switch-unreadable', 'hooks/auto-effort-control.ts#restoreSession', 'reading the saved on/off, the pin and the set_effort level', new Error(`${quote(value)} is not in the expected form`), 'this session is on and unpinned, with no set_effort level');
426 } else {
427 st.ctl.restoreSession(saved);
428 st.savedSession = JSON.stringify(st.ctl.sessionState());
429 }
430 }
431 } catch (error) {
432 note($, st, 'error', 'saved-switch-unreadable', 'hooks/auto-effort-control.ts#restoreSession', 'reading the saved on/off, the pin and the set_effort level', error, 'this session is on and unpinned, with no set_effort level');
433 }
434}
435
436async function saveSession($: EngineInterface, st: State): Promise<void> {
437 if (st.sessionId === undefined) return;
438 const now = st.ctl.sessionState();
439 const text = JSON.stringify(now);
440 if (text === st.savedSession) return;
441 let failure: unknown;
442 try {
443 const result = await $.state.set({ plugin: 'auto-effort-control', key: 'session', id: st.sessionId }, now);
444 if (result.isSet) {
445 st.savedSession = text;
446 st.saveFailed = '';
447 return;
448 }
449 failure = new Error('the host refused the write');
450 } catch (error) {
451 failure = error;
452 }
453 // The save is tried again at each request; it is said once for each state that cannot be saved.
454 if (st.saveFailed === text) return;
455 st.saveFailed = text;
456 note($, st, 'error', 'saved-switch-unsaved', 'hooks/auto-effort-control.ts#saveSession', 'saving the on/off, the pin and the set_effort level', failure, 'they hold in this session, but a reload would lose them');
457}
458
459async function start($: EngineInterface, st: State): Promise<void> {
460 try {
461 st.sessionId ??= await $.session.id();
462 } catch (error) {
463 note($, st, 'error', 'session-id-unreadable', 'hooks/auto-effort-control.ts#start', 'reading the session id', error, 'the log file is named at the first row');
464 }
465 try {
466 st.ctl.loadPersisted(readPersisted(await $.store.get(STORE_KEY)));
467 } catch (error) {
468 note($, st, 'error', 'saved-marks-unreadable', 'hooks/auto-effort-control.ts#start', 'reading the saved model marks', error, 'starting with none');
469 st.ctl.loadPersisted(EMPTY_PERSISTED);
470 }
471 await restoreSession($, st);
472 applyLayers($, st, st.options, await currentEnv($, st), 'plugin options');
473 try {
474 await ensureTool($, st);
475 } catch (error) {
476 note($, st, 'error', 'tool-register-failed', 'hooks/auto-effort-control.ts#start', 'registering the set_effort tool', error, 'the turn reminder and the planning level still apply; the next prompt tries again');
477 }
478 // The effort line moved to the state file: a line an older version pinned to the status area goes.
479 try {
480 $.ui.status(undefined);
481 } catch (error) {
482 note($, st, 'warning', 'status-failed', 'hooks/auto-effort-control.ts#start', 'clearing the status line', error, 'a line an older version pinned may stay');
483 }
484 await ensureKeeper($, st);
485 publish($, st);
486 try {
487 await $.command.register({ name: PLUGIN, description: 'Show or change automatic reasoning effort: status, on, off, set <key> <value>, clear [model]' });
488 } catch (error) {
489 note($, st, 'error', 'command-register-failed', 'hooks/auto-effort-control.ts#start', `registering the /${PLUGIN} command`, error, 'automatic effort works without it');
490 }
491}
492
493// A start that failed is dropped, so the next hook starts again.
494function ensureStarted($: EngineInterface, st: State): Promise<void> {
495 st.starting ??= start($, st).catch((error: unknown) => {
496 st.starting = undefined;
497 throw error;
498 });
499 return st.starting;
500}
501
502// After /clear the process goes on under a new session id, with no session.start: that session starts afresh.
503// An id the start could not read is learnt here, and the session's saved state read with it. Called by every hook
504// that reads or changes the session's on/off and pin, so a command typed after /clear acts for the new session.
505async function followSession($: EngineInterface, st: State): Promise<void> {
506 let id: string;
507 try {
508 id = await $.session.id();
509 } catch (error) {
510 note($, st, 'error', 'session-id-unreadable', 'hooks/auto-effort-control.ts#followSession', 'reading the session id', error, 'this stays with the session it had');
511 return;
512 }
513 if (st.sessionId !== undefined && id !== st.sessionId) {
514 st.ctl.resetSession();
515 // The ended session's files are read again from disk should a late row of it arrive; only the current one is held.
516 st.segments.clear();
517 }
518 st.sessionId = id;
519 st.stateRetired = undefined;
520 if (st.restored !== id) await restoreSession($, st);
521}
522
523// The marks as the store holds them, with this session's unsaved changes laid over them and saved:
524// another chat's marks for other models survive the save, and a failed save is tried again at the next prompt.
525async function syncMarks($: EngineInterface, st: State, changed: readonly string[]): Promise<void> {
526 for (const model of changed) st.unsaved.add(normalizeModel(model));
527 const merged = mergeModels(readPersisted(await $.store.get(STORE_KEY)), st.ctl.persisted, [...st.unsaved]);
528 st.ctl.loadPersisted(merged);
529 // Only the models this save carries are done: one marked while it was in flight stays for its own save.
530 const saving = [...st.unsaved];
531 if (saving.length === 0) return;
532 await $.store.set(STORE_KEY, merged);
533 for (const model of saving) st.unsaved.delete(model);
534}
535
536/** False when the marks could not be read or saved: they hold in this session and the next prompt tries again. */
537async function saveMarks($: EngineInterface, st: State, changed: readonly string[]): Promise<boolean> {
538 try {
539 await syncMarks($, st, changed);
540 return true;
541 } catch (error) {
542 note($, st, 'error', 'marks-failed', 'hooks/auto-effort-control.ts#saveMarks', 'reading or saving the model marks', error, 'they hold in this session, and the next prompt tries again');
543 return false;
544 }
545}
546
547// The environment variables of the options, each spelled as a string literal (Claude Code reads the names off the source)
548// and all asked in one round.
549async function currentEnv($: EngineInterface, st: State, options = st.options, rows: readonly { key: string; value: unknown; explicit?: boolean }[] = []): Promise<EnvValues> {
550 const merged = await readMergedOptions($, 'main', options, rows, st.env);
551 st.mergedOptions = merged.options;
552 st.migrationErrors = merged.errors;
553 st.env = merged.env as EnvValues;
554 return st.env;
555}
556
557/** The option layer: the `/config` rows of this plugin, by option. */
558function rowLayer(rows: readonly ConfigRow[]): Record<string, unknown> {
559 const layer: Record<string, unknown> = {};
560 for (const row of rows) {
561 const field = ROW_KEY.exec(row.key)?.[1];
562 if (field !== undefined && (SETTING_KEYS as readonly string[]).includes(field)) layer[field] = row.value;
563 }
564 return layer;
565}
566
567/** The settings of the layers, the variables over the options: applied, their errors reported, and where they came from kept for status. */
568function applyLayers($: EngineInterface, st: State, options: Readonly<Record<string, unknown>>, env: EnvValues, base: string) {
569 const resolved = resolveSettings(st.mergedOptions, env);
570 st.settingsFrom = resolved.fromEnv.length === 0 ? base : `${base}; environment overrides: ${resolved.fromEnv.map(envName).join(', ')}`;
571 reportErrors($, st, [...st.migrationErrors, ...resolved.errors]);
572 st.ctl.applySettings(resolved.settings);
573 return resolved;
574}
575
576// The options as /config holds them now, over the options the plugin registered with, under their variables.
577async function refresh($: EngineInterface, st: State): Promise<void> {
578 try {
579 const rows: readonly ConfigRow[] = await $.config.list();
580 const fromRows = rowLayer(rows);
581 applyLayers($, st, { ...st.options, ...fromRows }, await currentEnv($, st, { ...st.options, ...fromRows }, rows), Object.keys(fromRows).length > 0 ? '/config' : 'plugin options');
582 await saveMarks($, st, []);
583 await ensureTool($, st);
584 } catch (error) {
585 note($, st, 'error', 'options-unreadable', 'hooks/auto-effort-control.ts#refresh', 'reading the plugin options', error, 'the settings of the last prompt stay');
586 }
587}
588
589async function finishStep($: EngineInterface, st: State, raw: unknown): Promise<void> {
590 try {
591 const end = st.ctl.endStep(toUsage(raw));
592 record($, st, end.rows);
593 if (end.warning !== undefined) plog($, st, 'warning', end.warning.event, end.warning.msg, end.warning.where);
594 publish($, st);
595 if (end.model !== undefined) await saveMarks($, st, [end.model]);
596 } catch (error) {
597 st.ctl.failStep();
598 note($, st, 'error', 'judge-failed', 'hooks/auto-effort-control.ts#finishStep', 'judging the request', error, 'the next request is not compared with it');
599 }
600}
601
602// The main chat's level is the session's, like the on/off and the pin: saved, so a reload keeps it.
603async function answerSetEffort($: EngineInterface, st: State, e: Record<string, unknown>): Promise<{ result: string }> {
604 try {
605 const answer = st.ctl.setEffort({ input: { level: e.level, effort: e.effort, reason: e.reason }, agent: e.agentId !== undefined });
606 record($, st, answer.rows);
607 publish($, st);
608 await saveSession($, st);
609 return { result: answer.text };
610 } catch (error) {
611 note($, st, 'error', 'set-effort-failed', 'hooks/auto-effort-control.ts#answerSetEffort', 'applying set_effort', error, 'the effort stays as it was');
612 return { result: 'Not applied: the effort stays as it was.' };
613 }
614}
615
616async function runCommand($: EngineInterface, st: State, args: string): Promise<string> {
617 const command = parseCommand(args);
618 switch (command.kind) {
619 case 'bad':
620 return command.message;
621 case 'on':
622 case 'off':
623 st.ctl.setEnabled(command.kind === 'on');
624 await saveSession($, st);
625 publish($, st);
626 return `auto-effort-control is ${command.kind} for this session.`;
627 case 'clear': {
628 const stored = readPersisted(await $.store.get(STORE_KEY));
629 const mine = st.ctl.persisted;
630 const named = command.model === undefined ? [...Object.keys(stored.unsafe), ...Object.keys(mine.unsafe)] : [normalizeModel(command.model)];
631 if (!named.some((model) => stored.unsafe[model] !== undefined || mine.unsafe[model] !== undefined)) {
632 return command.model === undefined ? 'Nothing to clear: no model has a cache mark.' : `Nothing to clear: ${command.model} has no cache mark.`;
633 }
634 st.ctl.clear(command.model);
635 publish($, st);
636 const saved = await saveMarks($, st, named);
637 const cleared = `Cache mark cleared for ${command.model ?? 'every model'}; automatic effort applies to it again.`;
638 return saved ? cleared : `${cleared} Saving it failed, so it holds in this session; the next prompt tries again.`;
639 }
640 case 'set': {
641 const next = resolveSettings({ ...st.ctl.settings, [command.key]: command.value });
642 if (next.errors.length > 0) return `Not changed: ${next.errors.map((line) => line.replace(/; using the defaults?\b.*$/, '')).join('\n')}`;
643 const typed = next.settings[command.key];
644 const rows = await $.config.list();
645 const key = rows.find((row) => ROW_KEY.exec(row.key)?.[1] === command.key)?.key ?? `${PLUGIN}.${command.key}`;
646 const saved = await $.config.set({ key, value: typed });
647 if ('deny' in saved && saved.deny !== undefined) return `Not changed: the settings refused "${command.key}": ${saved.deny}`;
648 st.options[command.key] = typed;
649 // What the next prompt would compute: the options with this one saved, under the variables.
650 const fromRows = rowLayer(rows);
651 const applied = applyLayers($, st, { ...st.options, ...fromRows, [command.key]: typed }, await currentEnv($, st, { ...st.options, ...fromRows, [command.key]: typed }, [...rows, { key, value: typed, explicit: true }]), Object.keys(fromRows).length > 0 ? '/config' : 'plugin options');
652 await ensureTool($, st);
653 publish($, st);
654 const now = JSON.stringify(applied.settings[command.key]);
655 if (applied.fromEnv.includes(command.key)) return `${command.key} is saved as ${JSON.stringify(typed)}, but ${envName(command.key)} is set and still applies: ${command.key} stays ${now}.`;
656 if (now !== JSON.stringify(typed)) return `${command.key} is saved as ${JSON.stringify(typed)}, but ${now} is in effect: see the option errors in /auto-effort-control status.`;
657 return `${command.key} is now ${JSON.stringify(typed)}.`;
658 }
659 case 'status': {
660 await refresh($, st);
661 return statusText({ view: st.ctl.view(), settings: st.ctl.settings, errors: st.errors, version: VERSION, settingsFrom: st.settingsFrom });
662 }
663 }
664}
665
666
667import { Definitions as SubDefinitions, type ControlConfig as SubControlConfig, type Definition as SubDefinition } from '../src/subagent/agents.ts';
668import { Controller as SubController, type Action as SubAction } from '../src/subagent/control.ts';
669import { describe as SubDescribe, denyText as SubDenyText, EMPTY_TEXT as SubEMPTY_TEXT, isStillRunning as SubIsStillRunning, parseTag as SubParseTag, stepEffort as SubStepEffort, stripTag as SubStripTag, type Effort as SubEffort, type Pin as SubPin } from '../src/subagent/effort.ts';
670import { JUDGE_SYSTEM as SubJUDGE_SYSTEM, parseTripwires as SubParseTripwires, TRIPWIRES as SubTRIPWIRES } from '../src/subagent/judge.ts';
671import { isLevel as SubIsLevel } from '../src/subagent/ladder.ts';
672import { appendRows as SubAppendRows, sessionLogPath as SubSessionLogPath } from '../src/subagent/log.ts';
673import { diagnosticVisible as SubDiagnosticVisible, levelRow as SubLevelRow, pluginLogDir as SubPluginLogDir, PluginLogQueue as SubPluginLogQueue, type Level as SubLevel } from '../src/subagent/plugin-log.ts';
674import { compile as SubCompile, envName as SubEnvName, resolveSettings as SubResolveSettings, SETTING_KEYS as SubSETTING_KEYS, type Compiled as SubCompiled, type OptionEnv as SubOptionEnv, type Settings as SubSettings } from '../src/subagent/settings.ts';
675import { isRead as SubIsRead, isTestRun as SubIsTestRun, testOutcome as SubTestOutcome } from '../src/subagent/signals.ts';
676import { startOf as SubStartOf } from '../src/subagent/start.ts';
677import { BUTTON_AUTO as SubBUTTON_AUTO, BUTTON_DOWN as SubBUTTON_DOWN, BUTTON_UP as SubBUTTON_UP, MAIN_REFUSED as SubMAIN_REFUSED, mainEffortToolOf as SubMainEffortToolOf, REFUSED as SubREFUSED, type Direction as SubDirection } from '../src/subagent/text.ts';
678
679// Thin adapter: every decision lives in src/. A failure is logged with its context and
680// the spawn, request or tool call goes through at the effort it already had.
681
682/** The spawning tool's name, past and present. */
683const SubAGENT_TOOLS = new Set(['Agent', 'Task']);
684
685/** How long a request of a loop the plugin has not seen waits for spawns in flight. */
686const SubSPAWN_WAIT_MS = 5000;
687
688const SubLOG_CAP = 5000;
689const SubJUDGE_MAX_TOKENS = 4000;
690
691/** The main chat's id in the decision log: it is never steered here. */
692const SubMAIN = 'main';
693/** How long the main chat's button waits for the session's tool list before its refusal names /effort. */
694const SubTOOL_LIST_WAIT_MS = 2000;
695
696type SubFields = Record<string, unknown>;
697/** What a button tool does: one step either way, or back to the automatic rules. */
698type SubButton = SubDirection | 'auto';
699
700function SubMessage(error: unknown): string {
701 return error instanceof Error ? error.message : String(error);
702}
703
704// Pins live in host state, so a plugin reload keeps them; one value per agent id.
705async function SubReadPin(rt: SubRuntime, $: EngineInterface, agentId: string): Promise<SubPin | undefined> {
706 try {
707 const { value } = await $.state.get({ plugin: 'auto-effort-control', key: 'pin', id: agentId });
708 return value ?? undefined;
709 } catch (error) {
710 SubNotice(rt, $, 'error', 'pin-unreadable', `auto-effort-control: reading sub-agent ${agentId}'s pin failed: ${SubMessage(error)}; its request goes at the effort it carries`, 'readPin');
711 throw error;
712 }
713}
714
715async function SubWritePin($: EngineInterface, agentId: string, pin: SubPin): Promise<void> {
716 await $.state.set({ plugin: 'auto-effort-control', key: 'pin', id: agentId }, pin);
717}
718
719type SubSpawns = {
720 /** Spawns whose next() has not returned yet. */
721 inFlight: number;
722 /** Resolved and replaced each time a spawn settles. */
723 settled: Promise<void>;
724 wake: () => void;
725 /** Loops that waited once and were never attributed to a spawn: never wait again. */
726 strangers: Set<string>;
727};
728
729function SubNewSpawns(): SubSpawns {
730 const spawns = { inFlight: 0, strangers: new Set<string>() } as SubSpawns;
731 spawns.settled = new Promise((resolve) => (spawns.wake = resolve));
732 return spawns;
733}
734
735function SubSettle(spawns: SubSpawns): void {
736 spawns.inFlight -= 1;
737 const wake = spawns.wake;
738 spawns.settled = new Promise((resolve) => (spawns.wake = resolve));
739 wake();
740}
741
742// A sub-agent's first request can start before agent.spawn returns its id. An
743// unknown id waits until its own pin appears or no spawn is in flight, never past
744// SPAWN_WAIT_MS, and at most once for its lifetime. The turn's abort ends the
745// wait at once, and however the wait ends its timer is cancelled.
746async function SubAwaitPin(rt: SubRuntime, $: EngineInterface, spawns: SubSpawns, agentId: string, signal: AbortSignal): Promise<SubPin | undefined> {
747 let pin = await SubReadPin(rt, $, agentId);
748 if (pin !== undefined || spawns.strangers.has(agentId)) return pin;
749 const waiting = new AbortController();
750 const stop = () => waiting.abort();
751 signal.addEventListener('abort', stop);
752 if (signal.aborted) stop();
753 const deadline = $.clock.sleep(SubSPAWN_WAIT_MS, { signal: waiting.signal }).then(
754 () => 'over' as const,
755 (error: unknown) => {
756 if (!waiting.signal.aborted) SubNotice(rt, $, 'error', 'spawn-wait-failed', `auto-effort-control: sub-agent ${agentId}'s wait for its spawn failed: ${SubMessage(error)}; it goes on without waiting`, 'awaitPin');
757 return 'over' as const;
758 },
759 );
760 try {
761 while (pin === undefined && spawns.inFlight > 0) {
762 if ((await Promise.race([spawns.settled.then(() => 'settled' as const), deadline])) === 'over') break;
763 pin = await SubReadPin(rt, $, agentId);
764 }
765 } finally {
766 signal.removeEventListener('abort', stop);
767 waiting.abort();
768 }
769 if (pin === undefined) spawns.strangers.add(agentId);
770 return pin;
771}
772
773/** A steered agent whose home waits for its first request. */
774type SubAwaiting = { type: string; control: SubControlConfig; brief: string };
775
776type SubRuntime = {
777 /** The plugin options as the host gave them; the environment variables are layered over them once, by `settleOptions`. */
778 rawOptions: Readonly<Record<string, unknown>>;
779 optionsSettled?: Promise<void>;
780 pluginSetup?: Promise<string | undefined>;
781 pluginDir?: string;
782 pluginSession?: Promise<string | undefined>;
783 pluginQueue?: SubPluginLogQueue;
784 pluginWriting: Promise<void>;
785 pluginFlushing: Promise<void>;
786 modelFallback?: string;
787 modelUnavailable?: boolean;
788 config: SubSettings;
789 compiled: SubCompiled;
790 optionErrors: string[];
791 started?: Promise<void>;
792 defs?: SubDefinitions;
793 /** The decision log's path pattern with `~` expanded; empty: no log. */
794 logFile: string;
795 logPath?: Promise<string | undefined>;
796 pending: string[];
797 writing: Promise<void>;
798 steered: Map<string, SubController>;
799 awaiting: Map<string, SubAwaiting>;
800 /** Each child agent's id → its steered parent's id. */
801 childOf: Map<string, string>;
802 /** The full tool names `$.tool.register` returned. */
803 buttons: Map<string, SubButton>;
804};
805
806// The fallback must not log itself or turn a failed log write into failed work.
807function SubLogFallback($: EngineInterface, line: string): void {
808 try { $.ui.log(line); } catch { /* The screen door itself is unavailable; no other sink exists. */ }
809}
810
811function SubSetupPlugin(rt: SubRuntime, $: EngineInterface): Promise<string | undefined> {
812 rt.pluginSetup ??= (async () => {
813 let home: string | undefined;
814 let configured: string | undefined;
815 let homeError: { cause: unknown } | undefined;
816 let dirError: { cause: unknown } | undefined;
817 try { home = await $.env.get('HOME'); } catch (error) { homeError = { cause: error }; }
818 try { configured = await $.env.get('PLUGIN_LOG_DIR'); } catch (error) { dirError = { cause: error }; }
819 rt.pluginDir = SubPluginLogDir(configured, home, 'auto-effort-control');
820 rt.pluginQueue = new SubPluginLogQueue(rt.pluginDir, 'auto-effort-control');
821 if (homeError !== undefined) SubNotice(rt, $, 'error', 'home-missing', `auto-effort-control: reading HOME failed: ${SubMessage(homeError.cause)}; skipping ~/.claude/agents and ~ in logFile`, 'start');
822 if (dirError !== undefined) SubNotice(rt, $, 'warning', 'plugin-log-dir-unreadable', `auto-effort-control: reading PLUGIN_LOG_DIR failed: ${SubMessage(dirError.cause)}; using the HOME fallback when available`, 'start');
823 return home;
824 })();
825 return rt.pluginSetup;
826}
827
828async function SubFlushPlugin(rt: SubRuntime, $: EngineInterface): Promise<void> {
829 const out = await rt.pluginQueue!.flush(async (argv, init) => {
830 try { return await $.process.run(argv, init); }
831 catch (error) { throw new Error(`$.process.run failed: ${SubMessage(error)}`); }
832 });
833 for (const line of out.fallback ?? []) SubLogFallback($, line);
834 if (out.error !== undefined) {
835 SubLogFallback($, `auto-effort-control: ${out.error}`);
836 SubRecord(rt, $, 'main', 'plugin', { event: 'plugin-log-failed', error: out.error });
837 }
838}
839
840// Only the metadata preparation is queued here; subprocesses have their own
841// serial chain, so ordinary hooks never wait for the write.
842function SubPlog(rt: SubRuntime, $: EngineInterface, level: SubLevel, event: string, msg: string, where: string, detail?: SubFields): void {
843 rt.pluginWriting = rt.pluginWriting.then(async () => {
844 await SubSetupPlugin(rt, $);
845 rt.pluginSession ??= $.session.id().catch((error) => {
846 SubNotice(rt, $, 'warning', 'session-id-unreadable', `auto-effort-control: reading the session id for plugin logs failed: ${SubMessage(error)}; rows omit the session`, 'plog');
847 return undefined;
848 });
849 const session = await rt.pluginSession;
850 const now = await $.clock.now();
851 rt.pluginQueue!.add(SubLevelRow({ plugin: 'auto-effort-control', level, event, msg, where, session, detail }, now));
852 }).catch((error) => {
853 if (!SubDiagnosticVisible(level, true)) SubLogFallback($, msg);
854 SubLogFallback($, `auto-effort-control: plugin log ${rt.pluginDir ?? 'off'}: preparing ${event} failed: ${SubMessage(error)}`);
855 });
856 rt.pluginFlushing = rt.pluginWriting.then(() => SubFlushPlugin(rt, $)).catch((error) => SubLogFallback($, `auto-effort-control: plugin log ${rt.pluginDir ?? 'off'}: flushing failed: ${SubMessage(error)}`));
857 if (SubDiagnosticVisible(level, true)) $.ui.log(msg);
858}
859
860function SubNotice(rt: SubRuntime, $: EngineInterface, level: SubLevel, event: string, text: string, component: string): void {
861 SubPlog(rt, $, level, event, text, `hooks/auto-effort-control.ts#${component}`);
862}
863
864// Rows queue in memory; each flush reads the session's file and appends them,
865// so a resumed session keeps its earlier rows and no other chat shares the file.
866function SubRecord(rt: SubRuntime, $: EngineInterface, agent: string, type: string, row: SubFields): void {
867 const file = rt.logFile;
868 if (!file) return;
869 const { event, ...rest } = row;
870 rt.pending.push(JSON.stringify({ ts: new Date().toISOString(), event, agent, type, ...rest }));
871 rt.logPath ??= $.session.id().then(
872 (id) => SubSessionLogPath(file, id),
873 (error) => {
874 SubNotice(rt, $, 'error', 'session-id-unreadable', `auto-effort-control: reading the session id failed: ${SubMessage(error)}; the decision log is off for this session`, 'record');
875 return undefined;
876 },
877 );
878 rt.writing = rt.writing
879 .then(async () => {
880 const path = await rt.logPath;
881 const rows = rt.pending.splice(0);
882 if (!path || rows.length === 0) return;
883 const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : '';
884 await $.fs.write(path, SubAppendRows(existing, rows, SubLOG_CAP));
885 })
886 .catch((error) => SubNotice(rt, $, 'error', 'decision-log-write-failed', `auto-effort-control: writing the decision log for ${file} failed: ${SubMessage(error)}`, 'record'));
887}
888
889// The option was validated when the settings resolved, so a list that fails here is a bug: arm all four rather than none.
890function SubTripwiresOf(text: string) {
891 const found = SubParseTripwires(text);
892 return found.ok ? found.value : [...SubTRIPWIRES];
893}
894
895function SubSettleOptions(rt: SubRuntime, $: EngineInterface): Promise<void> {
896 rt.optionsSettled ??= (async () => {
897 const merged = await readMergedOptions($, 'subagent', rt.rawOptions);
898 const { settings, errors } = SubResolveSettings(merged.options, merged.env as SubOptionEnv);
899 rt.config = settings;
900 rt.compiled = SubCompile(settings);
901 rt.optionErrors = [...merged.errors, ...errors];
902 })().catch((error) => SubNotice(rt, $, 'error', 'environment-unreadable', `auto-effort-control: reading configuration failed: ${SubMessage(error)}; using the plugin options`, 'settleOptions'));
903 return rt.optionsSettled;
904}
905
906// First hook call: read where definitions live, report option errors, build the resolver.
907function SubStart(rt: SubRuntime, $: EngineInterface): Promise<void> {
908 rt.started ??= (async () => {
909 await SubSettleOptions(rt, $);
910 const home = await SubSetupPlugin(rt, $);
911 let cwd = '.';
912 let configDir: string | undefined;
913 try {
914 cwd = await $.session.cwd();
915 } catch (error) {
916 SubNotice(rt, $, 'error', 'cwd-unreadable', `auto-effort-control: reading the session cwd failed: ${SubMessage(error)}; using relative paths`, 'start');
917 }
918 try {
919 configDir = await $.env.get('CLAUDE_CONFIG_DIR');
920 } catch (error) {
921 SubNotice(rt, $, 'error', 'config-dir-missing', `auto-effort-control: reading CLAUDE_CONFIG_DIR failed: ${SubMessage(error)}; skipping its agents directory`, 'start');
922 }
923 const file = rt.config.logFile;
924 if (file.startsWith('~') && !home) SubNotice(rt, $, 'error', 'log-file-needs-home', `auto-effort-control: logFile ${file} needs HOME, which is unset; the decision log is off`, 'start');
925 else rt.logFile = file.startsWith('~') ? `${home}${file.slice(1)}` : file;
926 for (const line of rt.optionErrors) {
927 SubNotice(rt, $, 'error', 'option-error', line, 'start');
928 SubRecord(rt, $, 'main', 'option-error', { event: 'option-error', line });
929 }
930 const dirs = [`${cwd}/.claude/agents`, ...(configDir ? [`${configDir}/agents`] : []), ...(home ? [`${home}/.claude/agents`] : []), ...rt.config.agentDirs];
931 rt.defs = new SubDefinitions(
932 dirs,
933 { floor: rt.config.floor, ceiling: rt.config.ceiling, judge: rt.config.judge, tripwires: SubTripwiresOf(rt.config.tripwires), parentTag: rt.config.parentTag, struggleDecay: rt.config.struggleDecay },
934 {
935 exists: (path) => $.fs.exists(path),
936 read: (path) => $.fs.read(path),
937 listMarkdown: async (dir) => (await $.fs.list(dir)).filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md')).map((entry) => entry.name),
938 },
939 (line) => SubNotice(rt, $, 'error', 'definition-unreadable', line, 'definitionOf'),
940 );
941 })().catch((error) => SubNotice(rt, $, 'error', 'start-failed', `auto-effort-control: starting failed: ${SubMessage(error)}; sub-agents run unsteered at the effort they carry`, 'start'));
942 return rt.started;
943}
944
945const SubNO_DEFINITION = (rt: SubRuntime): SubDefinition => ({ path: undefined, effort: undefined, control: undefined, parentTag: rt.config.parentTag, errors: [] });
946
947async function SubDefinitionOf(rt: SubRuntime, $: EngineInterface, type: string | undefined): Promise<SubDefinition> {
948 if (!type || !rt.defs) return SubNO_DEFINITION(rt);
949 try {
950 return await rt.defs.resolve(type);
951 } catch (error) {
952 SubNotice(rt, $, 'error', 'definition-unreadable', `auto-effort-control: reading the definition of ${type} failed: ${SubMessage(error)}; treating it as having none`, 'definitionOf');
953 return SubNO_DEFINITION(rt);
954 }
955}
956
957// The steered controller of an agent id; the main chat (no id) is never steered here.
958function SubControllerOf(rt: SubRuntime, agentId: string | undefined): SubController | undefined {
959 return agentId === undefined ? undefined : rt.steered.get(agentId);
960}
961
962function SubNewController(rt: SubRuntime, id: string, type: string, control: SubControlConfig, home: SubEffort, brief: string): SubController {
963 const controller = new SubController({
964 id,
965 type,
966 home,
967 floor: control.floor,
968 ceiling: control.ceiling,
969 judge: control.judge,
970 judgeRaiseAt: rt.config.judgeRaiseAt,
971 tripwires: new Set(control.tripwires),
972 landing: control.landing,
973 models: rt.config.models,
974 words: { red: rt.config.redWords, green: rt.config.greenWords },
975 announce: rt.config.announce,
976 struggleDecay: control.struggleDecay,
977 brief,
978 });
979 rt.steered.set(id, controller);
980 return controller;
981}
982
983// Carries out what a controller decided: log rows, and judge calls started detached.
984function SubPerform(rt: SubRuntime, $: EngineInterface, controller: SubController, actions: readonly SubAction[]): void {
985 for (const action of actions) {
986 if (action.kind === 'log') {
987 const { event, ...rest } = action.row;
988 if (event === 'judge-failed' && rest.reason !== 'model-unavailable') SubPlog(rt, $, 'error', 'judge-failed', `auto-effort-control: judge failed for ${controller.id}: ${String(rest.reason)}; effort ${String(rest.from)} -> ${String(rest.to)}`, 'src/subagent/control.ts#failed', { agent: controller.id, error: String(rest.reason) });
989 if (rest.from !== undefined && rest.to !== undefined && rest.from !== rest.to) SubPlog(rt, $, 'info', 'effort-steered', `auto-effort-control: ${controller.id}'s effort changed from ${String(rest.from)} to ${String(rest.to)}`, 'hooks/auto-effort-control.ts#perform', { agent: controller.id, from: rest.from, to: rest.to });
990 SubRecord(rt, $, controller.id, controller.type, { event, ...rest, ...(rt.config.logPackets ? action.detail : {}) });
991 } else {
992 SubRunJudge(rt, $, controller, action);
993 }
994 }
995}
996
997function SubOauthDenied(reply: ModelCompleteResult): boolean {
998 return !reply.isAnswered && reply.reason === 'api-error' && reply.status === 403 && reply.error === 'oauth_org_not_allowed';
999}
1000
1001// Only the default model may switch: explicit model choices remain authoritative.
1002async function SubCompleteModel(rt: SubRuntime, $: EngineInterface, request: ModelCompleteRequest): Promise<ModelCompleteResult | undefined> {
1003 if (request.model !== 'haiku') return $.model.complete(request);
1004 if (rt.modelUnavailable) return undefined;
1005 const sentModel = rt.modelFallback ?? request.model;
1006 let reply = await $.model.complete({ ...request, model: sentModel });
1007 if (SubOauthDenied(reply) && sentModel === request.model) {
1008 const current = rt.modelFallback ?? await $.session.model();
1009 if (rt.modelFallback === undefined) {
1010 rt.modelFallback = current;
1011 SubPlog(rt, $, 'warning', 'model-fallback', `auto-effort-control: default haiku refused (api-error 403 oauth_org_not_allowed); using current main model ${current} for the default judge`, 'hooks/auto-effort-control.ts#completeModel', { model: current });
1012 }
1013 if (rt.modelFallback !== request.model) reply = await $.model.complete({ ...request, model: rt.modelFallback });
1014 }
1015 if (SubOauthDenied(reply)) {
1016 if (!rt.modelUnavailable) {
1017 rt.modelUnavailable = true;
1018 SubNotice(rt, $, 'error', 'model-unavailable', 'auto-effort-control: model judgment unavailable for this session (api-error 403 oauth_org_not_allowed); judges use deterministic failure handling', 'completeModel');
1019 }
1020 return undefined;
1021 }
1022 return reply;
1023}
1024
1025// One judge call, never awaited by a hook: no request waits for it. Whatever it
1026// comes back with is read by the controller, which raises one step when it gives no level.
1027function SubRunJudge(rt: SubRuntime, $: EngineInterface, controller: SubController, action: Extract<SubAction, { kind: 'judge' }>): void {
1028 const started = Date.now();
1029 SubCompleteModel(rt, $, { model: rt.config.judgeModel, effort: rt.config.judgeEffort, system: SubJUDGE_SYSTEM, prompt: action.packet, maxTokens: SubJUDGE_MAX_TOKENS, timeoutMs: rt.config.judgeTimeoutMs })
1030 .then(
1031 (reply) => {
1032 if (rt.steered.get(controller.id) !== controller) return;
1033 const ms = Date.now() - started;
1034 const out = reply === undefined
1035 ? controller.onJudgeFailure(action.epoch, 'model-unavailable')
1036 : reply.isAnswered
1037 ? controller.onJudgeAnswer(action.epoch, reply.text, ms, reply.usage)
1038 : controller.onJudgeFailure(action.epoch, reply.reason === 'api-error' ? `api-error ${reply.status ?? 'no status'} ${reply.error}` : reply.reason, reply.usage);
1039 SubPerform(rt, $, controller, out.actions);
1040 },
1041 (error: unknown) => {
1042 if (rt.steered.get(controller.id) !== controller) return;
1043 SubPerform(rt, $, controller, controller.onJudgeFailure(action.epoch, `threw: ${SubMessage(error)}`).actions);
1044 },
1045 )
1046 .catch((error: unknown) => SubNotice(rt, $, 'error', 'judge-threw', `auto-effort-control: reading the judge's answer for ${controller.id} failed: ${SubMessage(error)}; its effort stays where it is`, 'runJudge'));
1047}
1048
1049// What a spawn starts at: the tag against the definition's own effort, by `parentTag`.
1050// A steered agent's home waits for its first request when neither names a level.
1051function SubStartOfSpawn(definition: SubDefinition, tagged: SubEffort | undefined) {
1052 return SubStartOf({ tagged, own: definition.effort, parentTag: definition.parentTag, range: definition.control });
1053}
1054
1055// A spawn returned an id: log what the tag did, tell a steered parent, and start steering the child.
1056function SubAfterSpawn(rt: SubRuntime, $: EngineInterface, agentId: string, type: string, parentId: string | undefined, name: string | undefined, definition: SubDefinition, tagged: SubEffort | undefined, brief: string): void {
1057 try {
1058 const opening = SubStartOfSpawn(definition, tagged);
1059 if (opening.row) SubRecord(rt, $, agentId, type, opening.row);
1060 const parent = SubControllerOf(rt, parentId);
1061 if (parent) {
1062 rt.childOf.set(agentId, parent.id);
1063 SubPerform(rt, $, parent, parent.onSpawn({ childId: agentId, type, name }).actions);
1064 }
1065 if (definition.control) {
1066 if (opening.level !== undefined) SubNewController(rt, agentId, type, definition.control, opening.level, brief);
1067 else rt.awaiting.set(agentId, { type, control: definition.control, brief });
1068 }
1069 } catch (error) {
1070 SubNotice(rt, $, 'error', 'steer-subagent-failed', `auto-effort-control: steering sub-agent ${agentId} failed: ${SubMessage(error)}; it runs unsteered at its pin`, 'afterSpawn');
1071 }
1072}
1073
1074// The first request of an agent whose home is its effort: steer it when that is a level.
1075function SubAdoptFirst(rt: SubRuntime, $: EngineInterface, agentId: string, requested: unknown): SubController | undefined {
1076 const waiting = rt.awaiting.get(agentId);
1077 if (!waiting) return undefined;
1078 rt.awaiting.delete(agentId);
1079 if (!SubIsLevel(requested)) {
1080 SubRecord(rt, $, agentId, waiting.type, { event: 'not-steered', requested: requested ?? null });
1081 SubNotice(rt, $, 'error', 'not-a-level', `auto-effort-control: sub-agent ${agentId} (${waiting.type}) starts at ${String(requested)}, not a level; it is not steered`, 'adoptFirst');
1082 return undefined;
1083 }
1084 return SubNewController(rt, agentId, waiting.type, waiting.control, requested, waiting.brief);
1085}
1086
1087// What a steered agent's own tool call tells the controller.
1088function SubObserve(rt: SubRuntime, $: EngineInterface, controller: SubController, e: SubFields & { tool: string }, result: { text?: string; isError?: boolean }): void {
1089 const text = result.text ?? '';
1090 if (e.tool === 'SendMessage') {
1091 SubPerform(rt, $, controller, controller.onSendMessage(String(e.to)).actions);
1092 return;
1093 }
1094 const command = SubIsTestRun(e.tool, e, rt.compiled.testCommand, rt.compiled.readOnly);
1095 if (command !== undefined) {
1096 const failed = SubTestOutcome(result.isError, text, rt.compiled.failure) === 'fail';
1097 SubPerform(rt, $, controller, controller.onTestRun({ command, failed, text }).actions);
1098 return;
1099 }
1100 if (SubIsRead(e.tool, e, rt.compiled.readOnly)) SubPerform(rt, $, controller, controller.onRead(text).actions);
1101}
1102
1103// The session's own tool that sets the main chat's effort, by its listed name (mainEffortToolOf); undefined when none
1104// is listed, or when the list fails or does not answer within TOOL_LIST_WAIT_MS, so a button call is never held. The
1105// call's abort ends the wait at once, with no timeout logged, and however the wait ends its timer is cancelled.
1106async function SubMainEffortTool(rt: SubRuntime, $: EngineInterface, signal: AbortSignal): Promise<string | undefined> {
1107 const waiting = new AbortController();
1108 const stop = () => waiting.abort();
1109 signal.addEventListener('abort', stop);
1110 if (signal.aborted) stop();
1111 try {
1112 const deadline = $.clock.sleep(SubTOOL_LIST_WAIT_MS, { signal: waiting.signal }).then(
1113 () => 'over' as const,
1114 (error: unknown) => {
1115 if (!waiting.signal.aborted) SubNotice(rt, $, 'warning', 'tool-list-wait-failed', `auto-effort-control: the wait for the session's tool list failed: ${SubMessage(error)}; the main chat's refusal names /effort`, 'mainEffortTool');
1116 return 'failed' as const;
1117 },
1118 );
1119 const listed = await Promise.race([$.tool.list(), deadline]);
1120 if (listed === 'failed') return undefined;
1121 if (listed === 'over') {
1122 SubNotice(rt, $, 'warning', 'tool-list-timeout', `auto-effort-control: the session's tool list did not answer within ${SubTOOL_LIST_WAIT_MS} ms; the main chat's refusal names /effort`, 'mainEffortTool');
1123 return undefined;
1124 }
1125 return SubMainEffortToolOf(listed.map((tool) => tool.name));
1126 } catch (error) {
1127 SubNotice(rt, $, 'warning', 'tool-list-failed', `auto-effort-control: listing the session's tools failed: ${SubMessage(error)}; the main chat's refusal names /effort`, 'mainEffortTool');
1128 return undefined;
1129 } finally {
1130 signal.removeEventListener('abort', stop);
1131 waiting.abort();
1132 }
1133}
1134
1135async function SubAnswerButton(rt: SubRuntime, $: EngineInterface, agentId: string | undefined, button: SubButton, signal: AbortSignal): Promise<{ result: string }> {
1136 const controller = SubControllerOf(rt, agentId);
1137 if (!controller) {
1138 SubRecord(rt, $, agentId ?? SubMAIN, 'unsteered', { event: 'button-refused', direction: button });
1139 return { result: agentId === undefined ? SubMAIN_REFUSED(await SubMainEffortTool(rt, $, signal)) : SubREFUSED };
1140 }
1141 const out = button === 'auto' ? controller.onAuto() : controller.onButton(button);
1142 SubPerform(rt, $, controller, out.actions);
1143 return { result: out.text };
1144}
1145
1146async function SubRegisterButtons(rt: SubRuntime, $: EngineInterface): Promise<void> {
1147 await SubSettleOptions(rt, $);
1148 if (!rt.config.buttons) return;
1149 const specs: Array<{ name: string; description: string; button: SubButton }> = [
1150 { name: 'effort_up', description: SubBUTTON_UP, button: 'up' },
1151 { name: 'effort_down', description: SubBUTTON_DOWN, button: 'down' },
1152 { name: 'effort_auto', description: SubBUTTON_AUTO, button: 'auto' },
1153 ];
1154 for (const spec of specs) {
1155 try {
1156 const { tool } = await $.tool.register({ name: spec.name, description: spec.description, inputSchema: { type: 'object', properties: {} } });
1157 rt.buttons.set(tool, spec.button);
1158 SubRecord(rt, $, 'main', 'plugin', { event: 'tool-registered', tool });
1159 } catch (error) {
1160 SubNotice(rt, $, 'error', 'tool-register-failed', `auto-effort-control: registering the ${spec.name} tool failed: ${SubMessage(error)}; agents have no ${spec.name} button`, 'registerButtons');
1161 SubRecord(rt, $, 'main', 'plugin', { event: 'tool-register-failed', tool: spec.name, error: SubMessage(error) });
1162 }
1163 }
1164}
1165
1166// A finished agent: its end row, and a child's final answer to its steered parent.
1167function SubFinish(rt: SubRuntime, $: EngineInterface, agentId: string, answer: string): void {
1168 try {
1169 const parentId = rt.childOf.get(agentId);
1170 const parent = parentId === undefined ? undefined : rt.steered.get(parentId);
1171 if (parent) SubPerform(rt, $, parent, parent.onChildReturn(agentId, answer).actions);
1172 const own = rt.steered.get(agentId);
1173 if (own) {
1174 SubRecord(rt, $, own.id, own.type, own.end());
1175 rt.steered.delete(agentId);
1176 for (const [child, owner] of rt.childOf) if (owner === agentId) rt.childOf.delete(child);
1177 }
1178 rt.awaiting.delete(agentId);
1179 } catch (error) {
1180 SubNotice(rt, $, 'error', 'close-steering-failed', `auto-effort-control: closing sub-agent ${agentId}'s steering failed: ${SubMessage(error)}; its pin is cleared as usual`, 'finish');
1181 }
1182}
1183
1184
1185
1186// Literal reads are the engine's static capability declarations; the resolver itself is pure.
1187async function readMergedOptions($: EngineInterface, scope: Scope, registered: Readonly<Record<string, unknown>>, rows: readonly { key: string; value: unknown; explicit?: boolean }[] = [], previousEnv: Readonly<Record<string, string | undefined>> = {}) {
1188 let snapshot: ReadValue;
1189 try { snapshot = { ok: true, value: await $.settings.read() }; }
1190 catch (error) { snapshot = { ok: false, error: message(error) }; }
1191 const asked = {
1192 'AUTO_EFFORT_CONTROL_FLOOR': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_FLOOR')),
1193 'AUTO_EFFORT_CONTROL_CEILING': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_CEILING')),
1194 'AUTO_EFFORT_CONTROL_PLAN_LEVEL': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_PLAN_LEVEL')),
1195 'AUTO_EFFORT_CONTROL_HINT_PATTERNS': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_HINT_PATTERNS')),
1196 'AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_PATTERNS': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_PATTERNS')),
1197 'AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_CHARS': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_PLAN_UPSHIFT_CHARS')),
1198 'AUTO_EFFORT_CONTROL_TURN_REMINDER': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_TURN_REMINDER')),
1199 'AUTO_EFFORT_CONTROL_ESCALATE_AFTER_FAILURES': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_ESCALATE_AFTER_FAILURES')),
1200 'AUTO_EFFORT_CONTROL_LOWER_ON_PASS': attemptEnv($.env.get('AUTO_EFFORT_CONTROL_LOWER_ON_PASS')),src/options.ts 163 lines1import { DEFAULTS as MAIN_DEFAULTS, envName, resolveSettings as mainSettings } from './settings.ts';
2import { DEFAULTS as SUBAGENT_DEFAULTS, resolveSettings as subagentSettings } from './subagent/settings.ts';
3
4export type Scope = 'main' | 'subagent';
5export type Values = Readonly<Record<string, unknown>>;
6export type ReadValue = { ok: true; value: unknown } | { ok: false; error: string };
7export const OPTION_DEFAULTS: Values = {
8 ...SUBAGENT_DEFAULTS,
9 ...MAIN_DEFAULTS,
10 models: [...MAIN_DEFAULTS.models.map((x) => `main:${x}`), ...SUBAGENT_DEFAULTS.models.map((x) => `subagent:${x}`)],
11 testCommandPatterns: [...MAIN_DEFAULTS.testCommandPatterns.map((x) => `main:${x}`), ...SUBAGENT_DEFAULTS.testCommandPatterns.map((x) => `subagent:${x}`)],
12};
13export const OPTION_KEYS = Object.keys(OPTION_DEFAULTS);
14const record = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
15const own = (x: Values, key: string): boolean => Object.prototype.hasOwnProperty.call(x, key);
16const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
17const defaultsOf = (scope: Scope): Values => scope === 'main' ? MAIN_DEFAULTS : SUBAGENT_DEFAULTS;
18const legacyOf = (scope: Scope): string => scope === 'main' ? 'auto-effort' : 'effort-live-control';
19const legacyEnv = (scope: Scope, key: string): string => envName(key).replace('AUTO_EFFORT_CONTROL_', scope === 'main' ? 'AUTO_EFFORT_' : 'EFFORT_LIVE_CONTROL_');
20const target = (key?: string): string => `pluginConfigs["auto-effort-control@…"].options${key === undefined ? '' : `.${key}`}`;
21
22/** Project one public list without widening either scope. Empty scoped lists stay empty. */
23export function projectOptions(raw: Values, scope: Scope): { options: Record<string, unknown>; errors: string[] } {
24 const options: Record<string, unknown> = {};
25 const errors: string[] = [];
26 for (const key of Object.keys(defaultsOf(scope))) {
27 if (!own(raw, key)) continue;
28 const value = raw[key];
29 if (key !== 'models' && key !== 'testCommandPatterns') { options[key] = value; continue; }
30 if (typeof value !== 'string' && !Array.isArray(value)) { options[key] = value; continue; }
31 const items = typeof value === 'string' ? value.split('\n').map((x) => x.trim()).filter(Boolean) : value;
32 if (typeof value === 'string' && items.length === 0) continue;
33 if (!items.every((x): x is string => typeof x === 'string')) {
34 errors.push(`auto-effort-control: ${target(key)} must be a list of strings; using the ${scope} default`);
35 continue;
36 }
37 if (key === 'models' && items.some((x) => !/^(main|subagent):/.test(x))) {
38 errors.push(`auto-effort-control: ${target(key)} requires main:model=allow|probation|deny or subagent:model-prefix; using the ${scope} default`);
39 continue;
40 }
41 options[key] = items.filter((x) => !/^(main|subagent):/.test(x) || x.startsWith(`${scope}:`)).map((x) => x.replace(/^(main|subagent):/, ''));
42 }
43 return { options, errors };
44}
45
46/** All I/O is in the hook module; this resolver owns source presence, precedence and migration diagnostics. */
47export function resolveOptions(scope: Scope, registered: Values, snapshot: ReadValue, reads: Readonly<Record<string, ReadValue>>, rows: readonly { key: string; value: unknown; explicit?: boolean }[] = [], previousEnv: Readonly<Record<string, string | undefined>> = {}) {
48 const errors: string[] = [];
49 const defaults = defaultsOf(scope);
50 const legacy = legacyOf(scope);
51 const canonical: Record<string, unknown> = {};
52 const old: Record<string, unknown> = {};
53 const origins: Record<string, string> = {};
54 let settings: Values = {};
55 if (!snapshot.ok) errors.push(`auto-effort-control: reading settings failed: ${snapshot.error}; pluginConfigs["auto-effort@…"].options -> ${target()} and pluginConfigs["effort-live-control@…"].options -> ${target()} could not be inspected; registered options still apply`);
56 else if (!record(snapshot.value)) errors.push(`auto-effort-control: settings snapshot is not an object; pluginConfigs["${legacy}@…"].options -> ${target()} could not be inspected`);
57 else settings = snapshot.value;
58 const configs = settings.pluginConfigs;
59 if (configs !== undefined && !record(configs)) errors.push(`auto-effort-control: pluginConfigs is not an object; pluginConfigs["${legacy}@…"].options -> ${target()} could not be inspected`);
60 if (record(configs)) for (const id of Object.keys(configs).sort()) {
61 const identity = id.split('@')[0];
62 if (identity !== 'auto-effort-control' && identity !== legacy) continue;
63 const entry = configs[id];
64 const source = `pluginConfigs[${JSON.stringify(id)}].options`;
65 if (!record(entry) || !record(entry.options)) {
66 errors.push(`auto-effort-control: ${source} -> ${target()}: expected an options object; value was not applied`);
67 continue;
68 }
69 const out = identity === legacy ? old : canonical;
70 for (const [key, value] of Object.entries(entry.options)) {
71 if (!OPTION_KEYS.includes(key) || (identity === legacy && !own(defaults, key))) {
72 errors.push(`auto-effort-control: ${source}.${key} -> ${target(key)}: unknown option; not applied`);
73 continue;
74 }
75 if (identity === legacy && Array.isArray(defaults[key]) && Array.isArray(value) && value.some((item) => typeof item !== 'string')) {
76 errors.push(`auto-effort-control: ${source}.${key} -> ${target(key)}: list contains non-string entries; preserving the legacy string conversion`);
77 }
78 if (own(out, key)) {
79 errors.push(`auto-effort-control: ${source}.${key} -> ${target(key)}: displaced by the first sorted entry of ${identity}; consolidate duplicate entries`);
80 continue;
81 }
82 out[key] = value;
83 if (identity === legacy) origins[key] = `${source}.${key}`;
84 }
85 }
86 for (const [key, value] of Object.entries(registered)) {
87 if (!OPTION_KEYS.includes(key)) errors.push(`auto-effort-control: registered option ${key} -> ${target(key)}: unknown option; not applied`);
88 else if (!same(value, OPTION_DEFAULTS[key]) && !own(canonical, key)) canonical[key] = value;
89 }
90 for (const row of rows) {
91 const match = /^auto-effort-control(?:@.+)?\.(\w+)$/.exec(row.key);
92 if (!match) continue;
93 const key = match[1]!;
94 if (!OPTION_KEYS.includes(key)) errors.push(`auto-effort-control: /config ${row.key} -> ${target(key)}: unknown option; not applied`);
95 // The menu includes defaults. Only raw presence, a changed value or our successful write proves an explicit setting.
96 else if (row.explicit || own(canonical, key) || !same(row.value, OPTION_DEFAULTS[key])) canonical[key] = row.value;
97 }
98 // Validate all legacy settings even when another source displaces them.
99 const validate = scope === 'main' ? mainSettings : subagentSettings;
100 const legacyErrors = validate(old).errors;
101 for (const line of legacyErrors) errors.push(`auto-effort-control: legacy ${legacy} -> auto-effort-control: ${line}`);
102 const base = projectOptions(registered, scope);
103 const explicit = projectOptions(canonical, scope);
104 errors.push(...base.errors, ...explicit.errors);
105 const options: Record<string, unknown> = { ...base.options, ...old, ...explicit.options };
106 for (const key of Object.keys(canonical)) if (own(defaults, key) && !own(explicit.options, key)) delete options[key];
107 const env: Record<string, string | undefined> = {};
108 for (const key of Object.keys(defaults)) {
109 const name = envName(key);
110 const oldName = legacyEnv(scope, key);
111 const current = reads[name];
112 const prior = reads[oldName];
113 if (current !== undefined && !current.ok) errors.push(`auto-effort-control: reading ${name} failed: ${current.error}; ${previousEnv[key] === undefined ? 'using the option or its default' : 'keeping its last effective value'}`);
114 if (prior !== undefined && !prior.ok) errors.push(`auto-effort-control: reading ${oldName} -> ${name} failed: ${prior.error}; keeping its last effective value when known`);
115 const text = current?.ok && typeof current.value === 'string' && current.value.trim() !== '' ? current.value : undefined;
116 const oldText = prior?.ok && typeof prior.value === 'string' && prior.value.trim() !== '' ? prior.value : undefined;
117 if (origins[key] !== undefined) errors.push(`auto-effort-control: ${origins[key]} -> ${target(key)}: ${own(canonical, key) || text !== undefined || oldText !== undefined ? 'displaced by a higher-precedence value' : `honored for ${scope}`}`);
118 if (oldText !== undefined) {
119 errors.push(`auto-effort-control: ${oldName} -> ${name}: ${text !== undefined ? 'displaced by the canonical variable' : `honored for ${scope}`}`);
120 for (const line of validate({}, { [key]: oldText }).errors) errors.push(`${oldName} -> ${name}: ${line}`);
121 }
122 let chosen = text ?? oldText;
123 if (current !== undefined && !current.ok) chosen = previousEnv[key] ?? oldText;
124 else if (text === undefined && prior !== undefined && !prior.ok) chosen = previousEnv[key];
125 if (chosen === undefined) continue;
126 if (text !== undefined && (key === 'models' || key === 'testCommandPatterns')) {
127 try {
128 const list: unknown = JSON.parse(chosen);
129 if (!Array.isArray(list) || !list.every((x) => typeof x === 'string')) throw new Error('not a JSON array of strings');
130 const projected = projectOptions({ [key]: list }, scope);
131 errors.push(...projected.errors.map((line) => `environment variable ${name}: ${line}`));
132 // An invalid scoped value must default, not expose a lower-priority legacy value.
133 env[key] = JSON.stringify(projected.options[key] ?? defaults[key]);
134 } catch (error) {
135 errors.push(`auto-effort-control: environment variable ${name} is ${JSON.stringify(chosen)}, invalid: ${error instanceof Error ? error.message : String(error)}; using the ${scope} default`);
136 env[key] = JSON.stringify(defaults[key]);
137 }
138 } else env[key] = chosen;
139 }
140 // Historical state/log trees are read-only even when an old setting still names them.
141 const effective = validate(options, env).settings;
142 for (const key of ['logFile', ...(scope === 'main' ? ['stateFile'] : [])]) {
143 const value = effective[key as keyof typeof effective];
144 if (typeof value !== 'string') continue;
145 const parts: string[] = [];
146 for (const part of value.split('/')) {
147 if (part === '..' && parts.length && parts.at(-1) !== '..') parts.pop();
148 else if (part !== '.' && part !== '') parts.push(part);
149 }
150 if (!/(^|\/)\.local\/state\/(auto-effort|effort-live-control)(\/|$)/.test(parts.join('/'))) continue;
151 errors.push(`auto-effort-control: ${key} ${JSON.stringify(value)} -> ${target(key)}: retired artifacts are read-only; using the ${scope} default ${JSON.stringify(defaults[key])}`);
152 options[key] = defaults[key];
153 delete env[key];
154 }
155 // The SDK only enumerates settings.env, not the process environment. Never claim an OS-wide inventory.
156 if (settings.env !== undefined && !record(settings.env)) errors.push('auto-effort-control: settings.env is not an object; legacy environment keys could not be inspected');
157 if (record(settings.env)) for (const key of Object.keys(settings.env)) {
158 const match = /^(AUTO_EFFORT_|EFFORT_LIVE_CONTROL_)(.*)$/.exec(key);
159 if (match && !key.startsWith('AUTO_EFFORT_CONTROL_') && !Object.prototype.hasOwnProperty.call(reads, key)) errors.push(`auto-effort-control: ${key} -> AUTO_EFFORT_CONTROL_${match[2]}: unknown legacy variable; not applied`);
160 }
161 return { options, env, errors: [...new Set(errors)] };
162}
163src/canary.ts 46 lines1// The cache canary: after an effort change, the switched request must still read the previous request's prompt from the cache.
2
3export type Usage = {
4 input_tokens: number;
5 output_tokens: number;
6 cache_read_input_tokens: number;
7 cache_creation_input_tokens: number;
8};
9
10/** The whole prompt a request carried: uncached input, cache read and cache write. */
11export function wholePrompt(usage: Usage): number {
12 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
13}
14
15export type SwitchCheck = {
16 prev: Usage | null;
17 cur: Usage | null;
18 /** From the previous request's end to this one's start. */
19 gapMs: number;
20 ttlMs: number;
21 threshold: number;
22 compacted: boolean;
23 sameModel: boolean;
24 prevMessages: number;
25 curMessages: number;
26};
27
28export type Verdict =
29 | { verdict: 'ok'; ratio: number }
30 | { verdict: 'fail'; ratio: number }
31 | { verdict: 'skip'; why: 'no-usage' | 'model' | 'compaction' | 'shrunk' | 'ttl' | 'empty-prev' };
32
33/** A check that cannot hold (a lapsed TTL, a compaction, another model) judges nothing: it is no pass and no fail. */
34export function judgeSwitch(check: SwitchCheck): Verdict {
35 const { prev, cur } = check;
36 if (prev === null || cur === null) return { verdict: 'skip', why: 'no-usage' };
37 if (!check.sameModel) return { verdict: 'skip', why: 'model' };
38 if (check.compacted) return { verdict: 'skip', why: 'compaction' };
39 if (check.curMessages < check.prevMessages) return { verdict: 'skip', why: 'shrunk' };
40 if (check.gapMs > check.ttlMs) return { verdict: 'skip', why: 'ttl' };
41 const whole = wholePrompt(prev);
42 if (whole <= 0) return { verdict: 'skip', why: 'empty-prev' };
43 const ratio = cur.cache_read_input_tokens / whole;
44 return { verdict: ratio >= check.threshold ? 'ok' : 'fail', ratio };
45}
46src/command.ts 78 lines1// `/auto-effort-control`: parsing the arguments and the status text.
2
3import type { Gate, Unsafe } from './gate.ts';
4import { DEFAULTS, PLUGIN, SETTING_KEYS, type Settings } from './settings.ts';
5
6export const USAGE = '/auto-effort-control [on|off|status] | /auto-effort-control set <key> <value> | /auto-effort-control clear [model]';
7
8export type Command =
9 | { kind: 'status' }
10 | { kind: 'on' }
11 | { kind: 'off' }
12 | { kind: 'set'; key: keyof Settings; value: string }
13 | { kind: 'clear'; model: string | undefined }
14 | { kind: 'bad'; message: string };
15
16/** The settings `set` takes: the single-valued ones; a list is edited in /config or settings.json. */
17export const SETTABLE = SETTING_KEYS.filter((key) => !Array.isArray(DEFAULTS[key]));
18
19export function parseCommand(args: string): Command {
20 const words = args.trim().split(/\s+/).filter((word) => word !== '');
21 const verb = (words[0] ?? 'status').toLowerCase();
22 if (verb === 'status' && words.length <= 1) return { kind: 'status' };
23 if (verb === 'on' && words.length === 1) return { kind: 'on' };
24 if (verb === 'off' && words.length === 1) return { kind: 'off' };
25 if (verb === 'clear' && words.length <= 2) return { kind: 'clear', model: words[1] };
26 if (verb === 'set') {
27 const key = SETTABLE.find((name) => name === words[1]);
28 const value = args.trim().replace(/^\S+\s+\S+\s*/, '');
29 if (key !== undefined && value !== '') return { kind: 'set', key, value };
30 const why = key === undefined ? `"${words[1] ?? ''}" is not a single-valued setting (${SETTABLE.join(', ')}; a list is edited in /config)` : `"${key}" needs a value`;
31 return { kind: 'bad', message: `${why}\n${USAGE}` };
32 }
33 return { kind: 'bad', message: `Unknown arguments "${args.trim()}".\n${USAGE}` };
34}
35
36export type View = {
37 enabled: boolean;
38 pin: { level: string } | null;
39 /** The main chat's own level as it is sent (clamped), set with set_effort and held until this turn ends or set_effort auto. */
40 override: { level: string; reason: string } | null;
41 /** False when the last main request carried no level or the model gate keeps its model unsteered. */
42 steered: boolean;
43 model: string | undefined;
44 gate: Gate | undefined;
45 turn: { level: string; source: string; reason: string } | null;
46 /** Clean switches counted for a probation model. */
47 probation: number | undefined;
48 unsafe: Unsafe | undefined;
49};
50
51function show(value: unknown): string {
52 return Array.isArray(value) ? `[${value.join(' | ')}]` : String(value);
53}
54
55export function statusText(input: { view: View; settings: Settings; errors: readonly string[]; version: string; settingsFrom: string }): string {
56 const { view, settings, errors, version, settingsFrom } = input;
57 const lines = [`auto-effort-control ${version} · ${view.enabled ? 'on' : 'off'} (this session)`];
58 const held = view.override;
59 if (view.pin !== null) lines.push(`level: ${view.pin.level} · pin · you set /effort; /effort auto or /auto-effort-control on resumes automatic effort`);
60 else if (held !== null && view.enabled && view.steered) lines.push(`level: ${held.level} · model, held until this turn ends or set_effort auto · ${held.reason}`);
61 else if (view.turn !== null) lines.push(`level: ${view.turn.level} · ${view.turn.source} · ${view.turn.reason}`);
62 else lines.push('level: none yet · the first request of the next turn runs at the planning level');
63 if (held !== null && !view.enabled) lines.push(`main chat's own level: ${held.level} · kept while auto-effort-control is off until this turn ends · ${held.reason}`);
64 else if (held !== null && view.pin !== null) lines.push(`main chat's own level: ${held.level} · kept behind your /effort pin until this turn ends · ${held.reason}`);
65 else if (held !== null && !view.steered) lines.push(`main chat's own level: ${held.level} · kept while this model is not steered until this turn ends · ${held.reason}`);
66 if (view.model !== undefined && view.gate !== undefined) {
67 let gate = `model: ${view.model} · ${view.gate.status}`;
68 if (view.gate.status === 'probation') gate += ` (${view.probation ?? 0} of ${settings.probationSwitches} clean switches)`;
69 if (view.unsafe !== undefined) gate += ` · cache read ${(view.unsafe.ratio * 100).toFixed(1)}% of the previous prompt at ${view.unsafe.at}; /auto-effort-control clear ${view.model} turns it back on`;
70 lines.push(gate);
71 }
72 if (errors.length > 0) lines.push('option errors:', ...errors.map((line) => ` ${line}`));
73 lines.push(`settings (${settingsFrom}): ${SETTING_KEYS.map((key) => `${key}=${show(settings[key])}`).join(' ')}`);
74 lines.push(`change one: /auto-effort-control set <key> <value>, /config, or pluginConfigs["${PLUGIN}@<marketplace>"].options in settings.json (key "${PLUGIN}@inline" when loaded with --plugin-dir)`);
75 lines.push('environment migration: checks known legacy names and settings.env only; this engine cannot enumerate other OS variables (AUTO_EFFORT_* / EFFORT_LIVE_CONTROL_* -> AUTO_EFFORT_CONTROL_*).');
76 return lines.join('\n');
77}
78src/controller.ts 567 lines1// Every decision, as plain state and methods: the adapter feeds it the hook events and does what it returns.
2// Nothing here touches the engine; a clock is passed in.
3
4import { judgeSwitch, wholePrompt, type Usage } from './canary.ts';
5import type { View } from './command.ts';
6import { cleanSwitch, clearUnsafe, gateOf, markUnsafe, type Gate, type Persisted } from './gate.ts';
7import { clamp, isEffort, levelsWithin, stepUp, type Effort } from './levels.ts';
8import { normalizeModel } from './model.ts';
9import { planFor } from './plan.ts';
10import { compile, type Compiled, type Settings } from './settings.ts';
11import { stateOf, type EffortState } from './state.ts';
12import { deescalationLine, escalationLine, handBackText, heldLine, quote, reminderLine, setText, shortReason } from './text.ts';
13
14export type Row = Record<string, unknown>;
15/** What decided a turn's level: the planning level, the main chat's own set_effort level, a failing-test rise, the user's /effort. */
16export type Source = 'plan' | 'model' | 'escalation' | 'pin';
17/** The main chat's own level, set with set_effort: it holds until this turn ends or set_effort is called with auto. */
18export type Override = { level: Effort; reason: string; turnId: string | null };
19
20/** The effort a request carries: a level, a number on models that take one, absent on models without. */
21type Carried = Effort | number | undefined;
22
23export type StepInput = { turnId: string; index: number; model: string; effort: Carried; messageCount: number };
24
25type PluginWarning = { event: 'gate-unsafe' | 'canary-failed'; msg: string; where: string };
26
27export type StepPlan = {
28 warning?: PluginWarning;
29 /** The effort to rewrite the request to; undefined leaves it as it is. */
30 send: Effort | undefined;
31 /** The level held for this request; undefined when the plugin leaves the request alone. */
32 level: Effort | undefined;
33 source: Source | 'off';
34 reason: string;
35 newTurn: boolean;
36 rows: Row[];
37};
38
39/** `persisted` and `model` come together: the marks changed for that model, to be saved. */
40export type StepEnd = { warning?: PluginWarning; rows: Row[]; persisted?: Persisted; model?: string };
41
42type Turn = {
43 id: string;
44 level: Effort;
45 source: Source;
46 reason: string;
47 /** The planning level and its reason: what `auto` gives the turn back. */
48 startLevel: Effort;
49 planReason: string;
50 steps: number;
51 switches: number;
52 /** Failing test runs in a row since the last rise or passing run. */
53 failures: number;
54 /** What the turn held before the first rise of the failing streak, for a passing run to return to; null when there is none. */
55 beforeRise: { level: Effort; source: Source; reason: string } | null;
56 escalations: number;
57 setEffortCalls: number;
58};
59
60type Sample = { model: string; wire: Carried; usage: Usage | null; endedAt: number; messages: number };
61
62type Inflight = {
63 turnId: string;
64 index: number;
65 model: string;
66 wire: Carried;
67 source: Source | 'off';
68 active: boolean;
69 gate: Gate;
70 switched: boolean;
71 prev: Sample | undefined;
72 compacted: boolean;
73 startedAt: number;
74 messages: number;
75};
76
77/** A harness note that the command went on in the background: its result says nothing about the tests yet. */
78const BACKGROUND = /^Command running in background with ID: \S+\.[^\r\n]*$/i;
79
80/**
81 * What a tool result says about the tests. A call of a `failureTools` tool whose command matches `testCommandPatterns` is a test
82 * run: it fails when its result matches `failurePatterns`, passes otherwise, and says nothing yet when it went to the background.
83 * Any other result is no test run.
84 */
85function testRunOf(settings: Settings, compiled: Compiled, input: { tool: string; command: string | undefined; text: string }): 'fail' | 'pass' | 'background' | undefined {
86 const { command, text } = input;
87 if (command === undefined || !settings.failureTools.includes(input.tool)) return undefined;
88 if (!compiled.testCommand.some((re) => re.test(command))) return undefined;
89 if (BACKGROUND.test(text)) return 'background';
90 return compiled.failure.some((re) => re.test(text)) ? 'fail' : 'pass';
91}
92
93/** What the host keeps per session so a plugin reload does not lose it. */
94export type SessionState = { enabled: boolean; pin: Effort | null; override: Override | null };
95
96/** The saved state as it was written, a value saved before `override` existed holding none; undefined for anything else. */
97export function readSessionState(raw: unknown): SessionState | undefined {
98 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
99 const { enabled, pin, override } = raw as Record<string, unknown>;
100 if (typeof enabled !== 'boolean') return undefined;
101 if (pin !== null && !isEffort(pin)) return undefined;
102 if (override === undefined || override === null) return { enabled, pin, override: null };
103 if (typeof override !== 'object' || Array.isArray(override)) return undefined;
104 const { level, reason, turnId } = override as Record<string, unknown>;
105 if (!isEffort(level) || typeof reason !== 'string' || (turnId !== undefined && turnId !== null && typeof turnId !== 'string')) return undefined;
106 return { enabled, pin, override: { level, reason, turnId: turnId ?? null } };
107}
108
109export class Controller {
110 /** The set_effort tool's full name once the engine registered it. */
111 toolName: string | undefined;
112 private settingsNow: Settings;
113 private compiled: Compiled;
114 private store: Persisted;
115 private enabled = true;
116 private pin: { level: Effort } | null = null;
117 private override: Override | null = null;
118 /** False after a request that carried no level: a model without an effort setting. */
119 private takesLevel = true;
120 private lastSeen: { model: string; effort: Effort } | undefined;
121 private turn: Turn | null = null;
122 private turnId: string | undefined;
123 private pendingText: string | undefined;
124 private lastModel: string | undefined;
125 private inflight: Inflight | undefined;
126 private last: Sample | undefined;
127 private compacted = false;
128 private told = new Set<string>();
129
130 constructor(
131 settings: Settings,
132 persisted: Persisted,
133 private readonly now: () => number,
134 ) {
135 this.settingsNow = settings;
136 this.compiled = compile(settings);
137 this.store = persisted;
138 }
139
140 get settings(): Settings {
141 return this.settingsNow;
142 }
143
144 get persisted(): Persisted {
145 return this.store;
146 }
147
148 applySettings(settings: Settings): void {
149 this.settingsNow = settings;
150 this.compiled = compile(settings);
151 }
152
153 loadPersisted(persisted: Persisted): void {
154 this.store = persisted;
155 }
156
157 setEnabled(on: boolean): void {
158 this.enabled = on;
159 if (on) this.pin = null;
160 }
161
162 /** Read the user's built-in command after the host has handled it. */
163 onEffortCommand(args: string): { rows: Row[] } {
164 const level = args.trim().toLowerCase();
165 if (level === 'auto') {
166 const released = this.pin?.level ?? null;
167 this.pin = null;
168 this.lastSeen = undefined;
169 return { rows: this.settingsNow.userEffortPins ? [{ event: 'unpin', via: 'command', level: released }] : [] };
170 }
171 if (!this.settingsNow.userEffortPins || !isEffort(level)) return { rows: [] };
172 const previous = this.pin?.level ?? this.lastSeen?.effort ?? null;
173 this.pin = { level };
174 return { rows: [{ event: 'pin', via: 'command', level, previous }] };
175 }
176
177 /** What a plugin reload keeps within one session: on or off, the user's /effort pin and the main chat's own level. */
178 sessionState(): SessionState {
179 return { enabled: this.enabled, pin: this.pin === null ? null : this.pin.level, override: this.override === null ? null : { ...this.override } };
180 }
181
182 /** The state of a session a reload interrupted, given to a controller built afresh. */
183 restoreSession(state: SessionState): void {
184 this.enabled = state.enabled;
185 this.pin = state.pin === null ? null : { level: state.pin };
186 this.override = state.override === null ? null : { ...state.override };
187 }
188
189 /** Lifts the cache mark from one model, or every model when none is named. */
190 clear(model: string | undefined): void {
191 this.store = clearUnsafe(this.store, model);
192 if (model === undefined) this.told.clear();
193 else this.told.delete(normalizeModel(model));
194 }
195
196 /** A new session in the same process (after /clear): on again, the user pin kept, no level of the main chat's, no turn, nothing to compare with; the marks stay. */
197 resetSession(): void {
198 this.enabled = true;
199 this.override = null;
200 this.takesLevel = true;
201 this.lastSeen = undefined;
202 this.turn = null;
203 this.turnId = undefined;
204 this.pendingText = undefined;
205 this.lastModel = undefined;
206 this.inflight = undefined;
207 this.last = undefined;
208 this.compacted = false;
209 this.told.clear();
210 }
211
212 noteCompaction(): void {
213 this.compacted = true;
214 }
215
216 private gate(model: string): Gate {
217 return gateOf(model, this.settingsNow, this.compiled, this.store);
218 }
219
220 /**
221 * A prompt arrives. One typed between turns, or typed mid-turn to wait for the running turn, starts a turn: that turn is
222 * planned from it. One delivered into the running turn gets no reminder; its text only plans a next turn no prompt announces.
223 */
224 onPrompt(input: { text: string; model: string | undefined; midTurn: boolean; wait?: boolean }): { reminder?: string; rows: Row[] } {
225 if (input.midTurn && input.wait !== true) {
226 this.pendingText ??= input.text;
227 return { rows: [] };
228 }
229 this.pendingText = input.text;
230 if (input.model !== undefined) this.lastModel = input.model;
231 const s = this.settingsNow;
232 if (!s.turnReminder || !this.enabled || this.pin !== null || this.toolName === undefined || input.model === undefined) return { rows: [] };
233 if (!this.gate(input.model).active) return { rows: [] };
234 const plan = planFor(s, this.compiled, input.text);
235 return { reminder: reminderLine({ ...plan, tool: this.toolName, chars: input.text.length, hintLevel: clamp('xhigh', s.floor, s.ceiling) }), rows: [] };
236 }
237
238 /** The mode the main chat's effort is in now, its level and why: what the state file carries. */
239 stateNow(): EffortState {
240 const { floor, ceiling } = this.settingsNow;
241 return stateOf({
242 enabled: this.enabled,
243 steered: this.steered(),
244 pin: this.pin === null ? null : this.pin.level,
245 override: this.override === null ? null : { level: clamp(this.override.level, floor, ceiling), reason: this.override.reason },
246 turn: this.turn === null ? null : { level: clamp(this.turn.level, floor, ceiling), reason: this.turn.reason },
247 });
248 }
249
250 /** False when the last main request carried no level, or the model gate keeps its model unsteered; true before any. */
251 private steered(): boolean {
252 return this.takesLevel && (this.lastModel === undefined || this.gate(this.lastModel).active);
253 }
254
255 private inactive(input: StepInput, wire: Carried, reason: string, gate: Gate, newTurn: boolean, rows: Row[], source: Source | 'off' = 'off'): StepPlan {
256 this.turn = null;
257 this.inflight = { turnId: input.turnId, index: input.index, model: input.model, wire, source, active: false, gate, switched: false, prev: this.last, compacted: this.takeCompacted(), startedAt: this.now(), messages: input.messageCount };
258 if (newTurn) rows.push({ event: 'decision', turnId: input.turnId, model: input.model, level: source === 'pin' ? wire ?? null : null, source, reason, hints: null, sessionEffort: wire ?? null });
259 return { send: undefined, level: undefined, source, reason, newTurn, rows };
260 }
261
262 private takeCompacted(): boolean {
263 const was = this.compacted;
264 this.compacted = false;
265 return was;
266 }
267
268 /** A main-loop request is about to go out. */
269 beginStep(input: StepInput): StepPlan {
270 const s = this.settingsNow;
271 const rows: Row[] = [];
272 const name = normalizeModel(input.model);
273 const newTurn = this.turnId !== input.turnId;
274 if (newTurn && this.override?.turnId !== input.turnId) this.override = null;
275 this.turnId = input.turnId;
276 this.lastModel = input.model;
277 const text = newTurn ? this.pendingText : undefined;
278 if (newTurn) this.pendingText = undefined;
279 const gate = this.gate(input.model);
280 const effort = input.effort;
281
282 // A level outside the five is none this plugin can steer or show: the model is left as one that takes no level.
283 if (effort === undefined || typeof effort === 'number' || !isEffort(effort)) {
284 this.takesLevel = false;
285 const why = typeof effort === 'string' ? `${input.model} carries effort ${quote(effort)}, no level auto-effort-control sets` : `${input.model} takes no effort level`;
286 return this.inactive(input, effort, why, gate, newTurn, rows);
287 }
288 this.takesLevel = true;
289
290 // The user's own /effort between turns is a pin: the session effort moved while the model stood still.
291 if (newTurn && s.userEffortPins && this.pin === null && this.lastSeen !== undefined && this.lastSeen.model === name && this.lastSeen.effort !== effort) {
292 this.pin = { level: effort };
293 rows.push({ event: 'pin', via: 'carried', turnId: input.turnId, model: input.model, level: effort, previous: this.lastSeen.effort });
294 } else if (newTurn && this.pin !== null) {
295 // A pinned session's requests go at the effort they carry: the pin shows that level, after another /effort too.
296 this.pin = { level: effort };
297 }
298 this.lastSeen = { model: name, effort };
299
300 if (!this.enabled) return this.inactive(input, effort, 'auto-effort-control is off for this session', gate, newTurn, rows);
301 if (!gate.active) {
302 const notice = gate.status === 'unsafe' && !this.told.has(name) ? `auto-effort-control is off for ${input.model}: a cache check failed on it earlier. /auto-effort-control clear ${name} turns it back on.` : undefined;
303 if (notice !== undefined) this.told.add(name);
304 const inactive = this.inactive(input, effort, `auto-effort-control does not change effort on ${input.model} (${gate.status})`, gate, newTurn, rows);
305 return { ...inactive, ...(notice === undefined ? {} : { warning: { event: 'gate-unsafe', msg: notice, where: 'src/controller.ts#beginStep' } as PluginWarning }) };
306 }
307 if (this.pin !== null) {
308 const plan = this.inactive(input, effort, 'you set /effort', gate, newTurn, rows, 'pin');
309 return { ...plan, level: this.pin.level };
310 }
311
312 if (this.turn === null || this.turn.id !== input.turnId) {
313 const plan = planFor(s, this.compiled, text ?? '');
314 // The main chat's own level outranks the plan: the turn is still planned and logged, and runs at the level it set.
315 const held = this.override;
316 this.turn = {
317 id: input.turnId,
318 level: held?.level ?? plan.level,
319 source: held === null ? 'plan' : 'model',
320 reason: held?.reason ?? plan.reason,
321 startLevel: plan.level,
322 planReason: plan.reason,
323 steps: 0,
324 switches: 0,
325 failures: 0,
326 beforeRise: null,
327 escalations: 0,
328 setEffortCalls: 0,
329 };
330 rows.push({ event: 'decision', turnId: input.turnId, model: input.model, level: plan.level, source: 'plan', reason: plan.reason, upshift: plan.upshift, hints: plan.hintSources.length ? plan.hintSources : null, sessionEffort: effort, override: held?.level ?? null });
331 }
332 const turn = this.turn;
333 turn.steps += 1;
334 const level = clamp(turn.level, s.floor, s.ceiling);
335 const send = level === effort ? undefined : level;
336 const prev = this.last;
337 const switched = prev !== undefined && prev.model === name && prev.wire !== level;
338 if (switched && prev !== undefined) {
339 turn.switches += 1;
340 rows.push({ event: 'switch', turnId: input.turnId, index: input.index, model: input.model, from: prev.wire ?? null, to: level, source: turn.source, reason: turn.reason });
341 }
342 this.inflight = { turnId: input.turnId, index: input.index, model: input.model, wire: level, source: turn.source, active: true, gate, switched, prev, compacted: this.takeCompacted(), startedAt: this.now(), messages: input.messageCount };
343 return { send, level, source: turn.source, reason: turn.reason, newTurn, rows };
344 }
345
346 /** The request answered (or carried no usage). */
347 endStep(usage: Usage | null): StepEnd {
348 const inf = this.inflight;
349 this.inflight = undefined;
350 if (inf === undefined) return { rows: [] };
351 const s = this.settingsNow;
352 const endedAt = this.now();
353 const rows: Row[] = [
354 {
355 event: 'step',
356 turnId: inf.turnId,
357 index: inf.index,
358 model: inf.model,
359 sent: inf.wire ?? null,
360 source: inf.source,
361 active: inf.active,
362 input: usage?.input_tokens ?? null,
363 output: usage?.output_tokens ?? null,
364 cacheRead: usage?.cache_read_input_tokens ?? null,
365 cacheWrite: usage?.cache_creation_input_tokens ?? null,
366 ms: endedAt - inf.startedAt,
367 },
368 ];
369 let notice: string | undefined;
370 let persisted: Persisted | undefined;
371 const model = normalizeModel(inf.model);
372 if (inf.switched && inf.active && inf.prev !== undefined) {
373 const prev = inf.prev;
374 const verdict = judgeSwitch({
375 prev: prev.usage,
376 cur: usage,
377 gapMs: inf.startedAt - prev.endedAt,
378 ttlMs: s.cacheTtlSeconds * 1000,
379 threshold: s.canaryThreshold,
380 compacted: inf.compacted,
381 sameModel: prev.model === normalizeModel(inf.model),
382 prevMessages: prev.messages,
383 curMessages: inf.messages,
384 });
385 rows.push({ event: 'canary', turnId: inf.turnId, index: inf.index, model: inf.model, from: prev.wire ?? null, to: inf.wire ?? null, ...verdict });
386 if (verdict.verdict === 'ok' && inf.gate.status === 'probation') {
387 const counted = cleanSwitch(this.store, inf.model, s.probationSwitches);
388 this.store = counted.persisted;
389 persisted = this.store;
390 if (counted.graduated) rows.push({ event: 'gate', model: inf.model, status: 'allow', why: 'graduated' });
391 }
392 if (verdict.verdict === 'fail' && usage !== null && prev.usage !== null) {
393 this.store = markUnsafe(this.store, inf.model, {
394 at: new Date(endedAt).toISOString(),
395 ratio: verdict.ratio,
396 prevPrompt: wholePrompt(prev.usage),
397 cacheRead: usage.cache_read_input_tokens,
398 from: String(prev.wire),
399 to: String(inf.wire),
400 });
401 persisted = this.store;
402 this.told.add(normalizeModel(inf.model));
403 rows.push({ event: 'gate', model: inf.model, status: 'unsafe', why: 'cache canary', ratio: verdict.ratio });
404 notice = `auto-effort-control stopped changing effort on ${inf.model}: after the change from ${String(prev.wire)} to ${String(inf.wire)} the cache read was ${(verdict.ratio * 100).toFixed(1)}% of the previous prompt (the check wants ${(s.canaryThreshold * 100).toFixed(0)}%). /auto-effort-control clear ${normalizeModel(inf.model)} turns it back on.`;
405 }
406 }
407 this.last = usage === null ? undefined : { model: normalizeModel(inf.model), wire: inf.wire, usage, endedAt, messages: inf.messages };
408 return { rows, ...(notice === undefined ? {} : { warning: { event: 'canary-failed', msg: notice, where: 'src/controller.ts#endStep' } as PluginWarning }), ...(persisted === undefined ? {} : { persisted, model }) };
409 }
410
411 /** The request failed before it answered: nothing to compare the next one with. */
412 failStep(): void {
413 this.inflight = undefined;
414 this.last = undefined;
415 }
416
417 /**
418 * The model's set_effort call. A level is the main chat's own: it holds within its turn over the plan and the failing-test
419 * rise until a call with `auto` hands the effort back to them. Off, the user's pin and the model gate outrank it: a level
420 * is refused there, while `auto` is always taken.
421 */
422 setEffort(input: { input: Record<string, unknown>; agent: boolean }): { text: string; rows: Row[] } {
423 const s = this.settingsNow;
424 const refuse = (text: string): { text: string; rows: Row[] } => ({ text, rows: [{ event: 'set_effort', turnId: this.turn?.id ?? null, refused: text }] });
425 if (input.agent) return refuse('Not applied: set_effort applies to the main chat only.');
426 const requested = input.input.level ?? input.input.effort;
427 if (requested === 'auto') return this.handBack(shortReason(input.input.reason));
428 if (!this.enabled) return refuse('Not applied: auto-effort-control is off for this session; /auto-effort-control on turns it back on.');
429 if (this.pin !== null) return refuse(`Not applied: the effort is pinned at ${this.pin.level} by your /effort command; /effort auto or /auto-effort-control on resumes automatic effort.`);
430 const gate = this.lastModel === undefined ? undefined : this.gate(this.lastModel);
431 if (gate !== undefined && !gate.active) return refuse(`Not applied: auto-effort-control does not change effort on ${this.lastModel} (${gate.status}).`);
432 if (!this.takesLevel) return refuse(`Not applied: ${this.lastModel ?? 'this model'} takes no effort level.`);
433 if (!isEffort(requested)) return refuse(`Not applied: level must be auto or one of ${levelsWithin(s.floor, s.ceiling).join(', ')}; got ${quote(requested ?? null)}.`);
434 const applied = clamp(requested, s.floor, s.ceiling);
435 const reason = shortReason(input.input.reason);
436 const turn = this.turn;
437 const from = turn === null ? null : turn.level;
438 this.override = { level: applied, reason, turnId: this.turn?.id ?? this.turnId ?? null };
439 if (turn !== null) {
440 turn.level = applied;
441 turn.source = 'model';
442 turn.reason = reason;
443 turn.setEffortCalls += 1;
444 // The model's level stands: a passing test run no longer undoes a rise it chose over.
445 turn.beforeRise = null;
446 }
447 return { text: setText(applied, requested, s.floor, s.ceiling), rows: [{ event: 'set_effort', turnId: turn?.id ?? null, requested, applied, from, reason }] };
448 }
449
450 /** set_effort with `auto`: the main chat's level is dropped, and the turn runs at its planning level again. */
451 private handBack(reason: string): { text: string; rows: Row[] } {
452 const s = this.settingsNow;
453 const held = this.override;
454 this.override = null;
455 const turn = this.turn;
456 if (turn !== null) {
457 turn.setEffortCalls += 1;
458 if (held !== null) {
459 turn.level = turn.startLevel;
460 turn.source = 'plan';
461 turn.reason = turn.planReason;
462 turn.beforeRise = null;
463 }
464 }
465 const text = handBackText({ held: held !== null, level: turn === null ? null : clamp(turn.level, s.floor, s.ceiling), ...this.outranked() });
466 return { text, rows: [{ event: 'set_effort', turnId: turn?.id ?? null, requested: 'auto', applied: 'auto', from: held?.level ?? null, reason }] };
467 }
468
469 /** What decides the effort over the automatic rules: off, the user's pin, the model gate, a model taking no level; none while the rules apply. */
470 private outranked(): { outranked?: string } {
471 if (!this.enabled) return { outranked: 'auto-effort-control is off for this session until /auto-effort-control on' };
472 if (this.pin !== null) return { outranked: `your /effort pin at ${this.pin.level} still applies until /auto-effort-control on` };
473 const gate = this.lastModel === undefined ? undefined : this.gate(this.lastModel);
474 if (gate !== undefined && !gate.active) return { outranked: `auto-effort-control does not change effort on ${this.lastModel} (${gate.status})` };
475 if (!this.takesLevel) return { outranked: `${this.lastModel ?? 'this model'} takes no effort level, so none is steered` };
476 return {};
477 }
478
479 /**
480 * A main-loop tool result. Failing test runs in a row raise the level one step at `escalateAfterFailures`; a passing run resets
481 * the count and, with `lowerOnPass`, returns a raised level to what the failing streak found. While the main chat's own level
482 * holds, the threshold raises nothing and says so. Any other result changes nothing.
483 */
484 onToolResult(input: { tool: string; command: string | undefined; text: string }): { context?: string; rows: Row[] } {
485 const s = this.settingsNow;
486 const turn = this.turn;
487 if (turn === null || !this.enabled || this.pin !== null) return { rows: [] };
488 if (this.lastModel !== undefined && !this.gate(this.lastModel).active) return { rows: [] };
489 const run = testRunOf(s, this.compiled, input);
490 if (run === undefined || run === 'background') return { rows: [] };
491 if (run === 'pass') return this.testsPass(turn);
492 turn.failures += 1;
493 if (turn.failures < s.escalateAfterFailures) return { rows: [] };
494 const failures = turn.failures;
495 turn.failures = 0;
496 const from = clamp(turn.level, s.floor, s.ceiling);
497 if (this.override !== null) {
498 return { context: heldLine(failures, from, this.toolName ?? 'set_effort'), rows: [{ event: 'escalation', turnId: turn.id, from, to: from, failures, held: 'override' }] };
499 }
500 const to = stepUp(from, s.ceiling);
501 if (to === from) return { rows: [{ event: 'escalation', turnId: turn.id, from, to, failures, held: 'ceiling' }] };
502 turn.beforeRise ??= { level: from, source: turn.source, reason: turn.reason };
503 turn.level = to;
504 turn.source = 'escalation';
505 turn.reason = `${failures} failing test runs`;
506 turn.escalations += 1;
507 return { context: escalationLine(failures, from, to), rows: [{ event: 'escalation', turnId: turn.id, from, to, failures }] };
508 }
509
510 /** A passing test run ends the failing streak; with `lowerOnPass` the turn gets back what it held before the streak's first rise. */
511 private testsPass(turn: Turn): { context?: string; rows: Row[] } {
512 const s = this.settingsNow;
513 const before = turn.beforeRise;
514 turn.failures = 0;
515 turn.beforeRise = null;
516 if (before === null || !s.lowerOnPass) return { rows: [] };
517 const from = clamp(turn.level, s.floor, s.ceiling);
518 const to = clamp(before.level, s.floor, s.ceiling);
519 turn.level = to;
520 turn.source = before.source;
521 turn.reason = before.reason;
522 if (to === from) return { rows: [] };
523 return { context: deescalationLine(from, to), rows: [{ event: 'deescalation', turnId: turn.id, from, to }] };
524 }
525
526 /** The turn ended: its summary row. */
527 completeTurn(input: { turnId: string; durationMs: number; usage: Usage | undefined }): Row[] {
528 const turn = this.turn;
529 if (turn === null || turn.id !== input.turnId) return [];
530 return [
531 {
532 event: 'turn',
533 turnId: turn.id,
534 startLevel: turn.startLevel,
535 endLevel: clamp(turn.level, this.settingsNow.floor, this.settingsNow.ceiling),
536 steps: turn.steps,
537 switches: turn.switches,
538 setEffortCalls: turn.setEffortCalls,
539 escalations: turn.escalations,
540 durationMs: input.durationMs,
541 input: input.usage?.input_tokens ?? null,
542 output: input.usage?.output_tokens ?? null,
543 cacheRead: input.usage?.cache_read_input_tokens ?? null,
544 cacheWrite: input.usage?.cache_creation_input_tokens ?? null,
545 },
546 ];
547 }
548
549 /** What `/auto-effort-control status` shows. */
550 view(): View {
551 const model = this.lastModel;
552 const gate = model === undefined ? undefined : this.gate(model);
553 const name = model === undefined ? undefined : normalizeModel(model);
554 return {
555 enabled: this.enabled,
556 pin: this.pin,
557 override: this.override === null ? null : { level: clamp(this.override.level, this.settingsNow.floor, this.settingsNow.ceiling), reason: this.override.reason },
558 steered: this.steered(),
559 model,
560 gate,
561 turn: this.turn === null ? null : { level: this.turn.level, source: this.turn.source, reason: this.turn.reason },
562 probation: name === undefined ? undefined : this.store.probation[name],
563 unsafe: name === undefined ? undefined : this.store.unsafe[name],
564 };
565 }
566}
567src/gate.ts 101 lines1// The model gate (allow / probation / deny, `unknownModels` for the rest) and the marks that persist across sessions.
2
3import { normalizeModel } from './model.ts';
4import type { Compiled, Settings } from './settings.ts';
5
6export { normalizeModel };
7
8/** The cache canary's finding: the model's cache did not survive an effort change. */
9export type Unsafe = { at: string; ratio: number; prevPrompt: number; cacheRead: number; from: string; to: string };
10
11export type Persisted = {
12 unsafe: Record<string, Unsafe>;
13 /** Clean switches counted so far, per probation model. */
14 probation: Record<string, number>;
15 /** Probation models that earned `allow`. */
16 graduated: string[];
17};
18
19export const EMPTY_PERSISTED: Persisted = { unsafe: {}, probation: {}, graduated: [] };
20
21export type GateStatus = 'allow' | 'probation' | 'deny' | 'off' | 'unsafe';
22export type Gate = { model: string; status: GateStatus; active: boolean };
23
24export function gateOf(model: string, settings: Settings, compiled: Compiled, persisted: Persisted): Gate {
25 const name = normalizeModel(model);
26 const configured = compiled.models[name] ?? settings.unknownModels;
27 if (configured === 'deny') return { model: name, status: 'deny', active: false };
28 if (configured === 'off') return { model: name, status: 'off', active: false };
29 if (persisted.unsafe[name] !== undefined) return { model: name, status: 'unsafe', active: false };
30 if (configured === 'probation' && persisted.graduated.includes(name)) return { model: name, status: 'allow', active: true };
31 return { model: name, status: configured, active: true };
32}
33
34/** One clean switch on a probation model; the last one it needs graduates it. */
35export function cleanSwitch(persisted: Persisted, model: string, needed: number): { persisted: Persisted; graduated: boolean } {
36 const name = normalizeModel(model);
37 const count = (persisted.probation[name] ?? 0) + 1;
38 if (count >= needed) {
39 const { [name]: _done, ...rest } = persisted.probation;
40 return { persisted: { ...persisted, probation: rest, graduated: [...new Set([...persisted.graduated, name])] }, graduated: true };
41 }
42 return { persisted: { ...persisted, probation: { ...persisted.probation, [name]: count } }, graduated: false };
43}
44
45export function markUnsafe(persisted: Persisted, model: string, mark: Unsafe): Persisted {
46 return { ...persisted, unsafe: { ...persisted.unsafe, [normalizeModel(model)]: mark } };
47}
48
49/** The stored marks with this session's entries laid over them for the models it changed, an absent entry included; every other model keeps what is stored. */
50export function mergeModels(stored: Persisted, mine: Persisted, models: readonly string[]): Persisted {
51 const unsafe = { ...stored.unsafe };
52 const probation = { ...stored.probation };
53 const graduated = new Set(stored.graduated);
54 for (const name of new Set(models.map(normalizeModel))) {
55 const mark = mine.unsafe[name];
56 if (mark === undefined) delete unsafe[name];
57 else unsafe[name] = mark;
58 const count = mine.probation[name];
59 if (count === undefined) delete probation[name];
60 else probation[name] = count;
61 if (mine.graduated.includes(name)) graduated.add(name);
62 else graduated.delete(name);
63 }
64 return { unsafe, probation, graduated: [...graduated] };
65}
66
67/** Lifts the mark from one model, or from every model when none is named. */
68export function clearUnsafe(persisted: Persisted, model: string | undefined): Persisted {
69 if (model === undefined) return { ...persisted, unsafe: {} };
70 const { [normalizeModel(model)]: _gone, ...rest } = persisted.unsafe;
71 return { ...persisted, unsafe: rest };
72}
73
74const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
75
76/** The stored value, tolerant of anything a hand edit or an older version left behind. */
77export function readPersisted(raw: unknown): Persisted {
78 if (!isRecord(raw)) return { unsafe: {}, probation: {}, graduated: [] };
79 const unsafe: Record<string, Unsafe> = {};
80 if (isRecord(raw.unsafe)) {
81 for (const [model, mark] of Object.entries(raw.unsafe)) {
82 if (isRecord(mark) && typeof mark.ratio === 'number') {
83 unsafe[model] = {
84 at: String(mark.at ?? ''),
85 ratio: mark.ratio,
86 prevPrompt: Number(mark.prevPrompt ?? 0),
87 cacheRead: Number(mark.cacheRead ?? 0),
88 from: String(mark.from ?? ''),
89 to: String(mark.to ?? ''),
90 };
91 }
92 }
93 }
94 const probation: Record<string, number> = {};
95 if (isRecord(raw.probation)) {
96 for (const [model, count] of Object.entries(raw.probation)) if (typeof count === 'number' && count >= 0) probation[model] = count;
97 }
98 const graduated = Array.isArray(raw.graduated) ? raw.graduated.filter((model): model is string => typeof model === 'string') : [];
99 return { unsafe, probation, graduated };
100}
101src/levels.ts 29 lines1// The five effort levels and the arithmetic on them: every level the plugin sends is clamped to [floor, ceiling].
2
3export const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
4export type Effort = (typeof LEVELS)[number];
5
6export function isEffort(value: unknown): value is Effort {
7 return typeof value === 'string' && (LEVELS as readonly string[]).includes(value);
8}
9
10export function rank(level: Effort): number {
11 return LEVELS.indexOf(level);
12}
13
14export function clamp(level: Effort, floor: Effort, ceiling: Effort): Effort {
15 if (rank(level) < rank(floor)) return floor;
16 if (rank(level) > rank(ceiling)) return ceiling;
17 return level;
18}
19
20/** One level up, never past the ceiling. */
21export function stepUp(level: Effort, ceiling: Effort): Effort {
22 if (rank(level) >= rank(ceiling)) return level;
23 return LEVELS[rank(level) + 1] ?? level;
24}
25
26export function levelsWithin(floor: Effort, ceiling: Effort): Effort[] {
27 return LEVELS.filter((level) => rank(level) >= rank(floor) && rank(level) <= rank(ceiling));
28}
29src/log.ts 52 lines1// The decision log: JSONL, one file per session, a new numbered file after every SEGMENT_ROWS rows. Chats share the
2// configured `logFile`, so each session writes its own files beside it; a resumed session appends to its newest file.
3
4/** `~/` expanded to the home directory; a path that does not start with it is returned as it is. */
5export function expandHome(path: string, home: string | undefined): string {
6 if (path === '~' || path.startsWith('~/')) {
7 if (home === undefined || home === '') throw new Error(`auto-effort-control: the path ${path} needs the home directory, which is not set`);
8 return home.replace(/\/+$/, '') + path.slice(1);
9 }
10 return path;
11}
12
13/**
14 * File `segment` (1, 2, ...) of the session's own log: the session id goes before the extension of `logFile`, and from
15 * the second file on a number follows it (`decisions.<session>.2.jsonl`).
16 */
17export function sessionLogPath(logFile: string, sessionId: string, segment = 1): string {
18 if (sessionId === '') throw new Error('auto-effort-control: empty session id; cannot name the log file');
19 const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, '_');
20 const part = segment > 1 ? `${safe}.${segment}` : safe;
21 const slash = logFile.lastIndexOf('/');
22 const dir = logFile.slice(0, slash + 1);
23 const name = logFile.slice(slash + 1);
24 const dot = name.lastIndexOf('.');
25 if (dot <= 0) return `${dir}${name}.${part}`;
26 return `${dir}${name.slice(0, dot)}.${part}${name.slice(dot)}`;
27}
28
29/** Rows one log file holds before the next file starts. */
30export const SEGMENT_ROWS = 1000;
31
32/**
33 * The files to write after `rows` join the newest file `segment` (its text is `existing`): that file filled to `cap`
34 * rows, then as many new files as the rest needs; a file already at or past the cap is left as it is. `$.fs` has no
35 * append, so a write rewrites its whole file; the cap bounds that. `added` counts the part's rows taken from `rows`.
36 * Blank lines are dropped.
37 */
38export function fillSegments(existing: string, segment: number, rows: string[], cap: number): Array<{ segment: number; text: string; added: number }> {
39 const kept = existing.split('\n').filter((line) => line.trim() !== '');
40 const fresh = rows.filter((line) => line.trim() !== '');
41 const full = kept.length >= cap;
42 const all = full ? fresh : [...kept, ...fresh];
43 const first = full ? segment + 1 : segment;
44 const old = full ? 0 : kept.length;
45 const out: Array<{ segment: number; text: string; added: number }> = [];
46 for (let at = 0; at < all.length; at += cap) {
47 const part = all.slice(at, at + cap);
48 out.push({ segment: first + out.length, text: part.join('\n') + '\n', added: at + part.length - Math.max(at, old) });
49 }
50 return out;
51}
52src/model.ts 8 lines1/** A model id as the settings and the stored marks name it: without a context-window suffix (`[1m]`) or a date stamp (`-20261001`). */
2export function normalizeModel(model: string): string {
3 return model
4 .trim()
5 .replace(/\[[^\]]*\]$/, '')
6 .replace(/-\d{8}$/, '');
7}
8src/settings.ts 255 lines1// Every knob is a plugin option, and each also reads the environment variable AUTO_EFFORT_CONTROL_{OPTION} (the option in upper snake case),
2// which wins over the option, which wins over the default. Defaults agree in three places: here, plugin.json's userConfig and the README table.
3// A bad value is reported by key and value (a bad variable by its name), and the default applies.
4
5import { isEffort, LEVELS, rank, type Effort } from './levels.ts';
6import { normalizeModel } from './model.ts';
7import { clip, quote } from './text.ts';
8
9export const PLUGIN = 'auto-effort-control';
10
11export type ModelStatus = 'allow' | 'probation' | 'deny';
12export type UnknownModels = 'off' | ModelStatus;
13
14export type Settings = {
15 floor: Effort;
16 ceiling: Effort;
17 planLevel: Effort;
18 planUpshiftPatterns: string[];
19 hintPatterns: string[];
20 planUpshiftChars: number;
21 turnReminder: boolean;
22 escalateAfterFailures: number;
23 lowerOnPass: boolean;
24 failurePatterns: string[];
25 failureTools: string[];
26 testCommandPatterns: string[];
27 userEffortPins: boolean;
28 models: string[];
29 unknownModels: UnknownModels;
30 probationSwitches: number;
31 canaryThreshold: number;
32 cacheTtlSeconds: number;
33 statusLine: boolean;
34 logFile: string;
35 stateFile: string;
36};
37
38export const DEFAULTS: Settings = {
39 floor: 'medium',
40 ceiling: 'xhigh',
41 planLevel: 'high',
42 planUpshiftPatterns: [],
43 hintPatterns: ['\\bdesign', '\\barchitect', '\\btrade-?offs?\\b', '\\broot[- ]?causes?\\b', '\\bspec(s|ced|ification)?\\b', '\\bthink (hard|harder|deeply|carefully|it through)\\b', '\\bultrathink\\b'],
44 planUpshiftChars: 1500,
45 turnReminder: true,
46 escalateAfterFailures: 2,
47 lowerOnPass: true,
48 failurePatterns: ['--- FAIL', 'FAIL(ED)?', '(?<!\\d)[1-9]\\d* (failed|failing)', 'AssertionError', 'panic:', 'Traceback'],
49 failureTools: ['Bash'],
50 testCommandPatterns: [
51 '\\bgo\\s+(-C\\s+\\S+\\s+)?test\\b',
52 '\\b(npm|pnpm|yarn|bun)\\s+(run\\s+)?test\\b',
53 '\\bvitest\\b',
54 '\\bjest\\b',
55 '\\bpytest\\b',
56 '\\bcargo\\s+(test|nextest)\\b',
57 '\\bmake\\b[^;&|\\n]*?\\b(test|check|verify)\\b',
58 '\\bjust\\b[^;&|\\n]*?\\b(test|check)\\b',
59 '\\b(mvn|gradle|gradlew)\\b[^;&|\\n]*?\\btest\\b',
60 '\\bdotnet\\s+test\\b',
61 '\\bdeno\\s+test\\b',
62 '\\bswift\\s+test\\b',
63 '\\bmix\\s+test\\b',
64 '\\b(rspec|phpunit|ctest|tox|nox)\\b',
65 '\\bscripts?/test[\\w.-]*\\.sh\\b',
66 ],
67 userEffortPins: true,
68 models: ['claude-opus-5-5=allow', 'claude-fable-5-1=allow', 'claude-sonnet-5-5=probation', 'claude-haiku-5-5=probation', 'claude-sonnet-5=deny'],
69 unknownModels: 'off',
70 probationSwitches: 5,
71 canaryThreshold: 0.9,
72 cacheTtlSeconds: 300,
73 statusLine: true,
74 logFile: '~/.local/state/auto-effort-control/decisions.jsonl',
75 stateFile: '~/.local/state/auto-effort-control/state.json',
76};
77
78export const SETTING_KEYS = Object.keys(DEFAULTS) as Array<keyof Settings>;
79
80/** The variable an option reads: AUTO_EFFORT_CONTROL_ and the option in upper snake case (`planLevel` is AUTO_EFFORT_CONTROL_PLAN_LEVEL). */
81export const envName = (key: string): string => `AUTO_EFFORT_CONTROL_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}`;
82
83/** The text of each option's variable, as the environment holds it; undefined where it is unset. */
84export type EnvValues = Readonly<Partial<Record<keyof Settings, string | undefined>>>;
85
86type Parsed<T> = { ok: true; value: T } | { ok: false; why: string };
87const good = <T>(value: T): Parsed<T> => ({ ok: true, value });
88const bad = (why: string): Parsed<never> => ({ ok: false, why });
89
90const parseLevel = (raw: unknown): Parsed<Effort> => (isEffort(raw) ? good(raw) : bad(`not a level (${LEVELS.join(', ')})`));
91
92function parseBool(raw: unknown): Parsed<boolean> {
93 if (typeof raw === 'boolean') return good(raw);
94 if (raw === 'true') return good(true);
95 if (raw === 'false') return good(false);
96 return bad('not true or false');
97}
98
99function toNumber(raw: unknown): number {
100 if (typeof raw === 'number') return raw;
101 return typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : Number.NaN;
102}
103
104const parseInt1 = (raw: unknown): Parsed<number> => {
105 const n = toNumber(raw);
106 return Number.isInteger(n) && n >= 1 ? good(n) : bad('not a whole number of at least 1');
107};
108
109const parseRatio = (raw: unknown): Parsed<number> => {
110 const n = toNumber(raw);
111 return Number.isFinite(n) && n > 0 && n <= 1 ? good(n) : bad('not a number above 0 and at most 1');
112};
113
114function parseUnknownModels(raw: unknown): Parsed<UnknownModels> {
115 return raw === 'off' || raw === 'allow' || raw === 'probation' || raw === 'deny' ? good(raw) : bad('not one of off, allow, probation, deny');
116}
117
118const parseText = (raw: unknown): Parsed<string> => (typeof raw === 'string' ? good(raw) : bad('not text'));
119
120/** A list option: an array of strings, or text with one entry per line. Empty text means the default. */
121function listOf(check: (item: string) => string | undefined): (raw: unknown) => Parsed<string[] | 'default'> {
122 return (raw) => {
123 let items: string[];
124 if (Array.isArray(raw)) items = raw.map(String);
125 else if (typeof raw === 'string') items = raw.split('\n').map((line) => line.trim()).filter((line) => line !== '');
126 else return bad('not a list');
127 if (typeof raw === 'string' && items.length === 0) return good('default');
128 for (const item of items) {
129 const problem = check(item);
130 if (problem !== undefined) return bad(problem);
131 }
132 return good(items);
133 };
134}
135
136const regexItem = (item: string): string | undefined => {
137 try {
138 new RegExp(item);
139 return undefined;
140 } catch (error) {
141 return `${quote(item)} is not a regular expression (${clip(error instanceof Error ? error.message : String(error), 72)})`;
142 }
143};
144
145const MODEL_ENTRY = /^([^=\s]+)=(allow|probation|deny)$/;
146const modelItem = (item: string): string | undefined => (MODEL_ENTRY.test(item) ? undefined : `${quote(item)} is not model=allow, model=probation or model=deny`);
147const nameItem = (item: string): string | undefined => (/^\S+$/.test(item) ? undefined : `${quote(item)} is not a tool name`);
148
149const PARSERS: { [K in keyof Settings]: (raw: unknown) => Parsed<Settings[K] | 'default'> } = {
150 floor: parseLevel,
151 ceiling: parseLevel,
152 planLevel: parseLevel,
153 planUpshiftPatterns: listOf(regexItem),
154 hintPatterns: listOf(regexItem),
155 planUpshiftChars: parseInt1,
156 turnReminder: parseBool,
157 escalateAfterFailures: parseInt1,
158 lowerOnPass: parseBool,
159 failurePatterns: listOf(regexItem),
160 failureTools: listOf(nameItem),
161 testCommandPatterns: listOf(regexItem),
162 userEffortPins: parseBool,
163 models: listOf(modelItem),
164 unknownModels: parseUnknownModels,
165 probationSwitches: parseInt1,
166 canaryThreshold: parseRatio,
167 cacheTtlSeconds: parseInt1,
168 statusLine: parseBool,
169 logFile: parseText,
170 stateFile: parseText,
171};
172
173function errorLine(key: string, raw: unknown, why: string, from?: string): string {
174 return `auto-effort-control: option "${key}"${from === undefined ? '' : ` (${from})`} is ${quote(raw)}, ${why}; using the default ${JSON.stringify(DEFAULTS[key as keyof Settings])}`;
175}
176
177/** A variable's text as the option's own parser reads it: 1|0|true|false for a boolean, a JSON array of strings for a list. */
178function parseEnv(key: keyof Settings, text: string): Parsed<unknown> {
179 const parser = PARSERS[key] as (raw: unknown) => Parsed<unknown>;
180 if (typeof DEFAULTS[key] === 'boolean') {
181 const word = text.toLowerCase();
182 if (word === '1' || word === 'true') return good(true);
183 if (word === '0' || word === 'false') return good(false);
184 return bad('not 1, 0, true or false');
185 }
186 if (Array.isArray(DEFAULTS[key])) {
187 let list: unknown;
188 try {
189 list = JSON.parse(text);
190 } catch {
191 return bad('not a JSON array of strings');
192 }
193 if (!Array.isArray(list) || !list.every((item) => typeof item === 'string')) return bad('not a JSON array of strings');
194 return parser(list);
195 }
196 return parser(text);
197}
198
199/** Every option read, validated and defaulted: its variable (`env`), else the option (`raw`), else the default. `errors` holds one line per rejected value. */
200export function resolveSettings(raw: Readonly<Record<string, unknown>>, env: EnvValues = {}): { settings: Settings; errors: string[]; fromEnv: Array<keyof Settings> } {
201 const settings: Record<string, unknown> = { ...DEFAULTS };
202 const errors: string[] = [];
203 /** The options a valid variable decided: the ones a bad variable or a floor/ceiling reset gave back to the default are not among them. */
204 let fromEnv: Array<keyof Settings> = [];
205 for (const key of SETTING_KEYS) {
206 const text = env[key]?.trim() ?? '';
207 if (text !== '') {
208 const parsed = parseEnv(key, text);
209 if (parsed.ok) {
210 settings[key] = parsed.value;
211 fromEnv.push(key);
212 } else errors.push(errorLine(key, text, parsed.why, `environment variable ${envName(key)}`));
213 continue;
214 }
215 const value = raw[key];
216 if (value === undefined) continue;
217 const parsed = (PARSERS[key] as (raw: unknown) => Parsed<unknown>)(value);
218 if (!parsed.ok) errors.push(errorLine(key, value, parsed.why));
219 else if (parsed.value !== 'default') settings[key] = parsed.value;
220 }
221 const out = settings as Settings;
222 if (rank(out.floor) > rank(out.ceiling)) {
223 const origin = (key: 'floor' | 'ceiling'): string => (fromEnv.includes(key) ? `, from ${envName(key)}` : '');
224 errors.push(`auto-effort-control: option "floor" (${JSON.stringify(out.floor)}${origin('floor')}) is above option "ceiling" (${JSON.stringify(out.ceiling)}${origin('ceiling')}); using the defaults ${JSON.stringify(DEFAULTS.floor)} and ${JSON.stringify(DEFAULTS.ceiling)}`);
225 out.floor = DEFAULTS.floor;
226 out.ceiling = DEFAULTS.ceiling;
227 fromEnv = fromEnv.filter((key) => key !== 'floor' && key !== 'ceiling');
228 }
229 return { settings: out, errors, fromEnv };
230}
231
232export type Compiled = {
233 upshift: RegExp[];
234 hints: RegExp[];
235 failure: RegExp[];
236 testCommand: RegExp[];
237 models: Record<string, ModelStatus>;
238};
239
240/** The lists as regular expressions (a prompt pattern ignores case, a failure or test command pattern does not) and the model entries as a map. */
241export function compile(settings: Settings): Compiled {
242 const models: Record<string, ModelStatus> = {};
243 for (const entry of settings.models) {
244 const match = MODEL_ENTRY.exec(entry);
245 if (match?.[1] !== undefined && match[2] !== undefined) models[normalizeModel(match[1])] = match[2] as ModelStatus;
246 }
247 return {
248 upshift: settings.planUpshiftPatterns.map((source) => new RegExp(source, 'i')),
249 hints: settings.hintPatterns.map((source) => new RegExp(source, 'gi')),
250 failure: settings.failurePatterns.map((source) => new RegExp(source)),
251 testCommand: settings.testCommandPatterns.map((source) => new RegExp(source)),
252 models,
253 };
254}
255src/state.ts 57 lines1// The state file: one JSON line per session naming the mode the main chat's effort is in, its level, the reason and the
2// keeper vouching for it, for a reader outside the plugin such as a status line. The adapter rewrites it whole when its
3// text, `updatedAt` aside, changes; README § The state file is its contract.
4
5import type { Effort } from './levels.ts';
6import { shortReason } from './text.ts';
7
8export const STATE_VERSION = 2;
9
10/** The last argument of the keeper's argv: a reader checks a process carries it before it trusts the pid. */
11export const KEEPER_MARK = 'auto-effort-control-keeper';
12
13/**
14 * The keeper, a child the adapter starts for the plugin's life: the host kills it when the plugin's hooks unload, and
15 * it exits within two seconds of the process that started it. Its first output is its own pid.
16 */
17export const KEEPER_ARGV: readonly string[] = ['/bin/sh', '-c', 'echo $$; while kill -0 "$PPID" 2>/dev/null; do sleep 2; done', KEEPER_MARK];
18
19/**
20 * `auto`: the automatic rules (the planning level, test-failure rises); `model`: the main chat's own level, set with
21 * `set_effort`; `pin`: the user's `/effort`; `off`: the plugin sends no level (switched off, or the model gate keeps
22 * the model unsteered).
23 */
24export type Mode = 'auto' | 'model' | 'pin' | 'off';
25
26/** `level` is null when the plugin holds none: `off`, or `auto` before the first request. */
27export type EffortState = { version: typeof STATE_VERSION; mode: Mode; level: Effort | null; reason: string };
28
29export type StateInput = {
30 enabled: boolean;
31 /** False when the model gate keeps the last model seen unsteered; true when it steers or no model is seen yet. */
32 steered: boolean;
33 pin: Effort | null;
34 /** The main chat's own level, held until this turn ends or it calls `set_effort` with `auto`. */
35 override: { level: Effort; reason: string } | null;
36 /** The level the current turn's requests go at, and why; null before the first request. */
37 turn: { level: Effort; reason: string } | null;
38};
39
40/** The session's state, highest rule first: off, not steered, the user's pin, the main chat's level, the rules. */
41export function stateOf(input: StateInput): EffortState {
42 if (!input.enabled) return { version: STATE_VERSION, mode: 'off', level: null, reason: 'auto-effort-control is off; /auto-effort-control on resumes' };
43 if (!input.steered) return { version: STATE_VERSION, mode: 'off', level: null, reason: 'this model is not steered' };
44 if (input.pin !== null) return { version: STATE_VERSION, mode: 'pin', level: input.pin, reason: 'you set /effort; /effort auto or /auto-effort-control on resumes' };
45 if (input.override !== null) return { version: STATE_VERSION, mode: 'model', level: input.override.level, reason: shortReason(input.override.reason) };
46 if (input.turn === null) return { version: STATE_VERSION, mode: 'auto', level: null, reason: 'the next turn starts at its planned level' };
47 return { version: STATE_VERSION, mode: 'auto', level: input.turn.level, reason: shortReason(input.turn.reason) };
48}
49
50/**
51 * The file's whole text: one JSON line, keys in a fixed order. `keeper` is the pid of the keeper vouching for the line,
52 * 0 when none does (the session ended, the plugin left this path, or no keeper runs); `updatedAt` the time of writing.
53 */
54export function stateText(state: EffortState, keeper: number, now: Date): string {
55 return `${JSON.stringify({ version: state.version, mode: state.mode, level: state.level, reason: state.reason, keeper, updatedAt: now.toISOString() })}\n`;
56}
57src/text.ts 95 lines1// Every sentence the model or the user reads. The tool description and the turn reminder are what the pilot tunes.
2
3import { LEVELS, rank, type Effort } from './levels.ts';
4
5const GUIDE: Record<Effort, string> = {
6 low: 'lookups, status reports, formatting, one-line edits',
7 medium: 'edits whose every line the plan already fixes (a rename, a move, a known fix), running and reading tests',
8 high: 'debugging a known symptom, writing non-trivial code from scratch, explaining unfamiliar code',
9 xhigh: 'a decision others will build on (architecture, design trade-offs, a spec, a review verdict), or a root cause one attempt did not find',
10 max: 'what xhigh already failed to settle',
11};
12
13/** The tool's description for the levels the clamp lets through: the floor's line also covers the lighter work, the ceiling's the heavier. */
14export function toolDescription(floor: Effort, ceiling: Effort): string {
15 const lines = [...LEVELS]
16 .reverse()
17 .filter((level) => rank(level) >= rank(floor) && rank(level) <= rank(ceiling))
18 .map((level) => {
19 let text = GUIDE[level];
20 if (level === floor && rank(floor) > 0) text += `; and anything lighter (${LEVELS.filter((l) => rank(l) < rank(floor)).map((l) => GUIDE[l]).join('; ')})`;
21 if (level === ceiling && rank(ceiling) < rank('max')) text += `; and anything heavier, which this setup caps at ${ceiling}`;
22 return `- ${level}: ${text}.`;
23 });
24 return [
25 'set_effort: set your reasoning effort for the requests after this call. A level you set holds until this turn ends, over the automatic rules; the next turn starts at its planned level again.',
26 'auto hands the effort back to the automatic rules for the rest of this turn: its planned level, and failing test runs raise it.',
27 'Main chat only: a sub-agent never calls it.',
28 'Call it in your first response of a user turn whose work needs another level than the one in effect, alongside your first tool calls.',
29 ...lines,
30 'Raise it the moment the work turns harder than planned: a test fails for a reason you cannot name, or a fix you were sure of did not hold. Lower it once only mechanical steps remain.',
31 'If the answer itself needs deep thought and there is no tool work, call this first and answer after its result.',
32 ].join('\n');
33}
34
35/** The one line added to each prompt while the automatic rules hold: the level the turn starts at, and the tool. */
36export function reminderLine({ level, tool, upshift, chars, hints, hintLevel }: { level: Effort; tool: string; upshift: 'length' | 'pattern' | null; chars: number; hints: string[]; hintLevel: Effort }): string {
37 const why = upshift === 'length' ? ` because the prompt is long (${chars} characters)` : upshift === 'pattern' ? ' because the prompt matches planUpshiftPatterns' : '';
38 const words = hints.map((word) => JSON.stringify(word));
39 const named = words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`;
40 const hint = hints.length ? ` This prompt names ${named}: the ${tool} guide places that kind of work at ${hintLevel}; whether this turn is that work is your call.` : '';
41 return `Your reasoning effort starts this turn at ${level}${why}, and this first response runs at ${level}. Call ${tool} first, with the level the work ahead needs; a level you set lasts until this turn ends. With no call, the turn stays at ${level}.${hint}`;
42}
43
44/** The answer to a level set with set_effort, with why a clamped level differs from the one asked. */
45export function setText(applied: Effort, requested: Effort, floor: Effort, ceiling: Effort): string {
46 const clamped = applied === requested ? '' : ` (${requested} is ${rank(requested) < rank(floor) ? `below the floor ${floor}` : `above the ceiling ${ceiling}`})`;
47 return `Effort set to ${applied} from your next request${clamped}; it holds until this turn ends, or until you call set_effort with auto.`;
48}
49
50/**
51 * The answer to set_effort with auto. `held`: a level of the main chat's was held; `level`: what the current turn runs at
52 * again, null with no turn steered; `outranked`: what still decides the effort instead (a pin, off, the model gate).
53 */
54export function handBackText(input: { held: boolean; level: Effort | null; outranked?: string }): string {
55 if (input.outranked !== undefined) return `Effort handed back to the automatic rules; ${input.outranked}.`;
56 if (!input.held) return 'Nothing to hand back: your reasoning effort already follows the automatic rules.';
57 if (input.level === null) return 'Effort handed back to the automatic rules: each turn starts at its planned level.';
58 return `Effort handed back to the automatic rules: this turn runs at ${input.level} again from your next request, and each turn starts at its planned level.`;
59}
60
61/** The one line a rise adds to the tool result that caused it. */
62export function escalationLine(failures: number, from: Effort, to: Effort): string {
63 return `auto-effort-control: ${failures} failing test runs this turn, so your reasoning effort rises from ${from} to ${to} for the requests that follow.`;
64}
65
66/** The one line the rise's threshold adds while the main chat's own level holds: no rise, and the way up. */
67export function heldLine(failures: number, level: Effort, tool: string): string {
68 return `auto-effort-control: ${failures} failing test runs this turn; your reasoning effort stays at ${level}, the level you set. Call ${tool} with a higher level if the work needs more, or with auto to let failing tests raise it.`;
69}
70
71/** The one line a passing test run adds when it undoes the rises of the failing streak before it. */
72export function deescalationLine(from: Effort, to: Effort): string {
73 return `auto-effort-control: the tests pass again, so your reasoning effort is back to ${to} from ${from} for the requests that follow.`;
74}
75
76const REASON_MAX = 60;
77
78/** Text a message carries from outside (a raw value, an error): at most `max` characters. */
79export function clip(text: string, max = REASON_MAX): string {
80 return text.length > max ? `${text.slice(0, max - 1)}…` : text;
81}
82
83/** A raw value as a message quotes it: its JSON, at most 60 characters. */
84export function quote(value: unknown): string {
85 return clip(JSON.stringify(value) ?? String(value));
86}
87
88/** A reason as the state file and the log carry it: one line, at most 60 characters. */
89export function shortReason(reason: unknown): string {
90 if (typeof reason !== 'string') return 'no reason given';
91 const text = reason.replace(/\s+/g, ' ').trim();
92 if (text === '') return 'no reason given';
93 return text.length > REASON_MAX ? `${text.slice(0, REASON_MAX - 1)}…` : text;
94}
95