Runs long Claude Code jobs overnight within your usage limits: pauses just before the 5-hour or weekly limit, resumes the same session after the reset (or…

<img src="docs/banner.svg" alt="Noctis — a Claude Code plugin that pauses before the 5-hour or weekly usage limit and resumes after the reset" width="100%">
<a href="https://github.com/synex1437/noctis/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/synex1437/noctis/ci.yml?branch=main&event=push&style=flat-square&label=CI&labelColor=0d0d10&color=ff3347"></a> <a href="https://github.com/synex1437/noctis/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/synex1437/noctis?style=flat-square&label=release&labelColor=0d0d10&color=ff3347"></a> <img alt="Windows, macOS, Linux" src="https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-one%20binary-ff3347?style=flat-square&labelColor=0d0d10"> <img alt="Claude Code, Codex CLI, Antigravity CLI, Droid, Copilot CLI" src="https://img.shields.io/badge/Claude%20Code%20%C2%B7%20Codex%20%C2%B7%20Antigravity%20%C2%B7%20Droid%20%C2%B7%20Copilot-5%20tools-ff3347?style=flat-square&labelColor=0d0d10"> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-ff3347?style=flat-square&labelColor=0d0d10"></a>
<a href="#install"><b>Install</b></a> · <a href="#quick-start"><b>Quick start</b></a> · <a href="#how-it-compares"><b>Compare</b></a> · <a href="#commands"><b>Commands</b></a> · <a href="#faq"><b>FAQ</b></a> · <a href="docs/GUIDE.md"><b>Guide</b></a> · <a href="README.tr.md"><b>Türkçe</b></a>
A usage limit at 01:40 should not cost you the rest of the night. Noctis is a Claude Code plugin that pauses just before the 5-hour or weekly usage limit, waits for the reset and resumes the same session on its own, while it works through your task queue without stopping to ask.
<table align="center"> <tr> <td align="center" width="25%"><b>~5 ms</b><br><sub>per hook on Linux</sub></td> <td align="center" width="25%"><b>0 tokens</b><br><sub>per decision</sub></td> <td align="center" width="25%"><b>1 binary</b><br><sub>no Node, no Git Bash</sub></td> <td align="center" width="25%"><b>15 languages</b><br><sub>the one you type in</sub></td> </tr> </table>
Noctis takes care of all three. It stops Claude Code before the limit instead of after it, comes back when the limit resets and keeps going down your list. Along the way it keeps Claude sharp: a fresh context once the conversation grows long, your check between items, and a stronger model for the item it gets stuck on.
git status, todos and the next jobs.claude --resume from Task Scheduler, launchd or systemd, even days later and even after Claude Code was closed; Windows, and Linux with CAP_WAKE_ALARM, can wake the machine for it./noctis:start jobs.md runs the jobs in a file in order without stopping to ask; a long prompt with several tasks becomes such a list by itself; a TASKS.md you trust drives every session, with priorities, dependencies and GitHub issues. Steps marked (human) wait for you, a noctis-verify line runs your checks between items, and long builds run as background jobs the queue waits for./autocompact, or Claude Code's own); once a session's context passes 100k tokens, each next item goes to a fresh noctis:worker subagent with a brief of its own; after a compaction Claude is told the item in hand, the files changed and how the check stands; a failing check is fixed in the code, not in its tests; an item Claude keeps stopping on goes once to a stronger model; and noctis queue status shows what each item took, so you can size the next ones.noctis why). One self-contained Go binary, about 5 ms per hook on Linux; no Node, Git Bash or compiler.Inside Claude Code, about a minute:
/plugin marketplace add synex1437/noctis
/plugin install noctis@noctis
/noctis:setup
/exit
Then start Claude Code again with claude --continue, which picks the conversation up: Claude Code reads where it compacts only as it starts, and from that start on it compacts by ~280k tokens of context even in a 1M window.
[!IMPORTANT] Setup switches Claude Code to auto permission mode: Claude edits files and runs commands without asking. That is what lets it work while you sleep;
/noctis:setup --permissions keepleaves your mode as it is. Setup also sets the default model and effort and the function hooks switch lean compaction needs, and the status line points at noctis from the first session. Setup backs upsettings.jsonfirst, and every change has an undo: what it changes and how to undo it.
Setup asks one question, which model does which kind of work, and keeps the answer as your roles profile. If /noctis:setup is not found yet, run /reload-plugins first. From a terminal: claude plugin marketplace add synex1437/noctis && claude plugin install noctis@noctis, then /noctis:setup inside Claude Code and the restart. Clone and ZIP installs, the profiles and every flag: install details.
Requirements: Claude Code 2.1.251 or newer (lean compaction was tested with 2.1.281); Windows, macOS or Linux; a Pro or Max plan for the limit guard. Every profile runs on models every paid plan includes: Opus 5.5, Haiku 5.5, which needs Claude Code 2.1.293 or newer (an older one runs Haiku 4.5), and Sonnet 5.5 in Code and Balanced, which needs Claude Code 2.1.284 or newer.
∞ 5h 41%→14:35 · Wk 23%▲→Mon 21.09 09:00 · Opus 5.5/xhigh · ctx 37% shows both usage windows and when each resets, the model and effort, and how far the context is on its way to where Claude Code compacts it.- [ ] write tests for the payments module), and type /noctis:start TASKS.md. Claude works through them in order; /noctis:stop ends it early.Try it before you let it act. "mode": "observe" in ~/.claude/noctis/config.json logs every decision and enforces none of them, not even the stop at 100 %. More in the first five minutes.
Recent Claude Code versions continue a session on their own once a usage limit resets, as long as that session stays open, the machine stays awake and the reset is less than 24 hours away. Noctis covers the rest: it pauses before the limit with a checkpoint, relaunches the session from a scheduled task when Claude Code was closed or the reset is days away (the weekly limit), and keeps a queue of jobs moving. When Claude Code's own continue fires, noctis cancels its relaunch.
| noctis | usage dashboards / status lines | loop plugins ("keep going") | auto-resume scripts | |
|---|---|---|---|---|
| Stops before the limit (thresholds, burst and burn-rate projection) | ✅ | show only | — | react after the 429 |
| Resumes on its own after the reset (same session, or a relaunch days later) | ✅ | — | — | partly |
| Stops at 100 % instead of spending paid usage credits | ✅ | — | — | — |
| Keeps a task queue moving across stops: priorities, dependencies, GitHub issues | ✅ | — | ✅ (flat) | — |
Deterministic, zero tokens per decision, journaled (noctis why) | ✅ | ✅ | prompt-driven | varies |
| Overload (529/5xx) backoff, separate from limit handling | ✅ | — | — | some |
| One self-contained binary, no runtime to install | ✅ | varies | varies | varies |
| Claude Code, and with fewer features Codex CLI, Antigravity CLI, Droid and Copilot CLI | ✅ | some | Claude only | some |
Status lines such as ccstatusline and claude-powerline keep running behind noctis's, and usage dashboards such as ccusage and Claude-Code-Usage-Monitor are unaffected. Another tool that resumes sessions after a limit, such as unsnooze or claude-auto-resume, would race noctis, so use one of them. Works alongside other plugins.
| Command | What it does |
|---|---|
/noctis:setup | First setup; run it again to change which model does what |
/noctis:status | Usage, pause points, roles, pending waits and the last decisions |
/noctis:start <file> | Works through the jobs in a file without stopping and says how many it found |
/noctis:stop | Ends this session's queue |
/noctis:pause [minutes] | No limit pauses, routing or queue continuation for a while (60 minutes by default); the stop at 100 % still applies |
/noctis:resume | Ends a pause early |
!noctis why | The decisions noctis made, and why; --stats sums up the last week of them |
!noctis doctor | Checks the install and lists the neighbours it can see |
/noctis:setup, /noctis:pause, /noctis:start and /noctis:stop run only when you type them. The noctis command itself runs inside Claude Code with a leading ! (!noctis status, !noctis queue trust, !noctis off 30), because the plugin's bin/ folder is on the PATH of Claude Code's shell; in a terminal, use the full path setup prints. Every command and flag: REFERENCE.md.
No. Work stops at 100 % of a window, or just before it when the last readings jumped far enough, even when your thresholds are misconfigured or switched off and even during /noctis:pause. Set "credits": {"allowPaid": true} if you want the overflow. Noctis cannot switch off auto-reload on the account; that is in your Anthropic billing settings.
No telemetry. When the status line is not enough, it asks the usage endpoint the Claude app itself uses on api.anthropic.com, with the login token Claude Code already keeps. Once a day it fetches plugin.json from GitHub to see whether a newer version exists (update.check: false turns that off). Nothing else, except a webhook you set and GitHub through your own gh CLI when you import issues. Details.
An API key has no usage windows, so the limit guard has nothing to act on, and the status line shows waiting for limit data. The queue, the router and the 529/5xx retry still work.
Not until you trust it. A TASKS.md drives nothing until you type !noctis queue trust, and a line added or changed later stops it again until you trust it anew. When Claude runs noctis queue trust itself, the hook refuses the call. A check command the file names on a noctis-verify line runs only while your trust covers that line. Queue file.
That is what the queue is for:
claude inside tmux on the server and write the plan as a TASKS.md with priorities, dependencies and a noctis-verify check line.(human), give a hard or an easy step its model with (opus) or (sonnet), and trust the file once you have read it.noctis job jobs, hands an item it gets stuck on once to a stronger model before it sets it aside, sets aside what waits on something outside the session, and stops with the list of what is yours once nothing else is left.noctis queue status --json tells a monitoring script where the queue stands.alarm.digestAt set, your webhook sends you a digest every day: what an item takes of the weekly limit, when the rest may be done and, once the numbers say so, which profile finishes the queue for less.For a while: /noctis:pause 120. Completely, inside Claude Code, with the first line before the second, because removing the plugin deletes the binary that undoes the settings:
!noctis install --uninstall --host claude
/plugin uninstall noctis
The first line cancels pending resumes and restores settings.json; --purge also deletes ~/.claude/noctis/. Everything uninstall does.
Every push runs the Go tests on Linux, macOS and Windows, fuzzing, a lab that drives the hooks against stand-ins for Claude Code and the usage endpoint, a simulated two-day soak, a torrent of 600 jobs and a chaos monkey. CI also rebuilds the binaries in bin/ and fails unless they match the committed ones byte for byte. TESTING.md.
Bug reports with noctis doctor output and noctis why --last 20 are the most useful thing you can send; noctis report --bundle zips them with the logs (read it before you attach it). Build and test loop: CONTRIBUTING.md.
MIT — © 2026 synex
hooks/lean.js 786 lines1export const SHIPPED = Object.freeze({ lean: true, earlyAtPercent: 90, keepTurns: 6, maxToolResultChars: 2000, instructions: "Write the summary in short sections: the user's requests and intent, quoting any requirement they set; the task or queue item in hand, its acceptance criteria, what is done and what is left; each file changed or created and why; each build, test or check command run and its exit status, marking any that timed out, were killed or exited non-zero as UNVERIFIED, to be run again; errors met and how each was fixed, quoting any not fixed yet; decisions and approaches ruled out, each with its reason; open questions; the next step. Leave out file contents, tool output and search results that can be read again." })
2
3const READ_ONLY = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead"])
4const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>\s*/g
5const UNCHANGED_NOTE = "File unchanged since last read"
6const DECIMAL = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/
7const LOG_LINES = 200
8const SESSIONS_KEPT = 50
9const PRECOMPUTE_SKIP = "noctis lean compaction prunes the conversation when it compacts, so a summary computed ahead would be thrown away"
10const COMPACTION_BAND = 6
11const BUILTIN_THRESHOLDS = Object.freeze({ session5h: 92 })
12const WINDOW_THRESHOLDS = new Map([["five_hour", "session5h"]])
13const WINDOW_MIN = 100000
14const WINDOW_MAX = 1000000
15const REPLY_TOKENS = 20000
16const BUFFER_TOKENS = 13000
17const EXPONENT = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/
18const GROUPED = /^[+-]?\d{1,3}([_,\u00A0\u202F ])\d{3}(?:\1\d{3})*$/
19const GROUP_MARKS = /[_,\u00A0\u202F ]/g
20// The models Claude Code 2.1.293's catalog knows, and the three Claude 3 models it still names.
21const CATALOG_MODELS = ["claude-3-5-haiku", "claude-haiku-4-5", "claude-haiku-5-5", "claude-3-5-sonnet", "claude-3-7-sonnet", "claude-sonnet-4-0", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-sonnet-5", "claude-sonnet-5-5", "claude-opus-4-0", "claude-opus-4-1", "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-5", "claude-opus-5-5", "claude-fable-5", "claude-fable-5-1", "claude-mythos-5", "claude-mythos-5-1", "claude-3-opus", "claude-3-sonnet", "claude-3-haiku"]
22// The models Claude Code looks for inside a name it cannot parse, in its order; claude-opus-4 and
23// claude-sonnet-4 count where no minor version follows.
24const MODEL_LADDER = ["claude-fable-5-1", "claude-fable-5", "claude-mythos-5-1", "claude-mythos-5", "claude-opus-5-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-1", "claude-opus-4", "claude-sonnet-5-5", "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4", "claude-haiku-5-5", "claude-haiku-4-5", "claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus", "claude-3-sonnet", "claude-3-haiku"]
25// The models Claude Code's catalog gives its aliases, by provider ("" for any other); opusplan is sonnet and
26// best is opus (Claude Code takes fable for an account that may use Fable, which noctis cannot tell).
27const ALIAS_MODELS = { opus: { "": "claude-opus-5-5", foundry: "claude-opus-4-6" }, sonnet: { "": "claude-sonnet-5-5", bedrock: "claude-sonnet-4-5", vertex: "claude-sonnet-4-5", foundry: "claude-sonnet-4-5", mantle: "claude-sonnet-4-5", anthropicAws: "claude-sonnet-4-6" }, haiku: { "": "claude-haiku-5-5", bedrock: "claude-haiku-4-5", vertex: "claude-haiku-4-5", foundry: "claude-haiku-4-5", mantle: "claude-haiku-4-5", anthropicAws: "claude-haiku-4-5", anthropicGoogleCloud: "claude-haiku-4-5" }, fable: { "": "claude-fable-5-1" } }
28// The ids of the models ALIAS_MODELS names: on Anthropic's API (firstParty), which modelOverrides names a model
29// by, and on each provider, which an alias stands for there. Where a provider has none, as Mantle for
30// claude-sonnet-4-5, Claude Code takes claude-haiku-4-5's, the first model of its catalog it has one for. A
31// Bedrock id starts with the region's prefix in place of us.
32const MODEL_IDS = {
33 "claude-opus-5-5": { firstParty: "claude-opus-5-5", bedrock: "us.anthropic.claude-opus-5-5", vertex: "claude-opus-5-5", foundry: "claude-opus-5-5", anthropicAws: "claude-opus-5-5", anthropicGoogleCloud: "claude-opus-5-5", mantle: "anthropic.claude-opus-5-5" },
34 "claude-opus-4-6": { firstParty: "claude-opus-4-6", bedrock: "us.anthropic.claude-opus-4-6-v1", vertex: "claude-opus-4-6", foundry: "claude-opus-4-6", anthropicAws: "claude-opus-4-6", anthropicGoogleCloud: "claude-opus-4-6" },
35 "claude-sonnet-5-5": { firstParty: "claude-sonnet-5-5", bedrock: "us.anthropic.claude-sonnet-5-5", vertex: "claude-sonnet-5-5", foundry: "claude-sonnet-5-5", anthropicAws: "claude-sonnet-5-5", anthropicGoogleCloud: "claude-sonnet-5-5", mantle: "anthropic.claude-sonnet-5-5" },
36 "claude-sonnet-4-6": { firstParty: "claude-sonnet-4-6", bedrock: "us.anthropic.claude-sonnet-4-6", vertex: "claude-sonnet-4-6", foundry: "claude-sonnet-4-6", anthropicAws: "claude-sonnet-4-6", anthropicGoogleCloud: "claude-sonnet-4-6" },
37 "claude-sonnet-4-5": { firstParty: "claude-sonnet-4-5-20250929", bedrock: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", vertex: "claude-sonnet-4-5@20250929", foundry: "claude-sonnet-4-5", anthropicAws: "claude-sonnet-4-5-20250929", anthropicGoogleCloud: "claude-sonnet-4-5-20250929" },
38 "claude-haiku-5-5": { firstParty: "claude-haiku-5-5", bedrock: "us.anthropic.claude-haiku-5-5", vertex: "claude-haiku-5-5", foundry: "claude-haiku-5-5", anthropicAws: "claude-haiku-5-5", anthropicGoogleCloud: "claude-haiku-5-5", mantle: "anthropic.claude-haiku-5-5" },
39 "claude-haiku-4-5": { firstParty: "claude-haiku-4-5-20251001", bedrock: "us.anthropic.claude-haiku-4-5-20251001-v1:0", vertex: "claude-haiku-4-5@20251001", foundry: "claude-haiku-4-5", anthropicAws: "claude-haiku-4-5-20251001", anthropicGoogleCloud: "claude-haiku-4-5-20251001", mantle: "anthropic.claude-haiku-4-5" },
40 "claude-fable-5-1": { firstParty: "claude-fable-5-1", bedrock: "us.anthropic.claude-fable-5-1", vertex: "claude-fable-5-1", foundry: "claude-fable-5-1", anthropicAws: "claude-fable-5-1", anthropicGoogleCloud: "claude-fable-5-1", mantle: "anthropic.claude-fable-5-1" },
41}
42const BEDROCK_PREFIXES = ["us", "eu", "apac", "jp", "au", "global"]
43const ALIAS_VARIABLES = { opus: "ANTHROPIC_DEFAULT_OPUS_MODEL", sonnet: "ANTHROPIC_DEFAULT_SONNET_MODEL", haiku: "ANTHROPIC_DEFAULT_HAIKU_MODEL", fable: "ANTHROPIC_DEFAULT_FABLE_MODEL" }
44const PROVIDERS = [["CLAUDE_CODE_USE_BEDROCK", "bedrock"], ["CLAUDE_CODE_USE_FOUNDRY", "foundry"], ["CLAUDE_CODE_USE_ANTHROPIC_AWS", "anthropicAws"], ["CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", "anthropicGoogleCloud"], ["CLAUDE_CODE_USE_MANTLE", "mantle"], ["CLAUDE_CODE_USE_VERTEX", "vertex"]]
45const LEGACY_OPUS = ["claude-opus-4-20250514", "claude-opus-4-1-20250805", "claude-opus-4-0", "claude-opus-4-1"]
46const NATIVE_1M = ["claude-haiku-5-5", "claude-sonnet-5", "claude-sonnet-5-5", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-5", "claude-opus-5-5", "claude-fable-5", "claude-fable-5-1", "claude-mythos-5", "claude-mythos-5-1", "claude-mythos-preview"]
47const BEDROCK_REGIONS = ["us", "eu", "apac", "jp", "au", "us-gov", "global"]
48const DEFAULT_PORTS = { http: "80", https: "443", ws: "80", wss: "443", ftp: "21", file: "" }
49const ONE_MILLION = /\[1m\]/i
50const ONE_MILLION_TAIL = /(?:\[1m\])+$/i
51const PROVIDER_MODEL = /^(?:([a-z-]+)\.)?anthropic\.(claude-.*)$/
52const MODEL_TAIL = /^(?:-fast|-latest)?(?:-v\d{1,3}@\d{8}|[-@]\d{8})?(?:-v\d{1,3}(?::\d{1,3})?)?$/
53const DATE_SUFFIX = /-\d{8}$/
54
55function isPlainObject(value) {
56 return value !== null && typeof value === "object" && !Array.isArray(value)
57}
58
59function toNumber(value) {
60 if (typeof value === "number") return Number.isFinite(value) ? value : undefined
61 if (typeof value !== "string") return undefined
62 const text = value.trim()
63 return DECIMAL.test(text) ? Number(text) : undefined
64}
65
66function switchedOff(value) {
67 return value === null || value === false || toNumber(value) === 0
68}
69
70function percentOf(value) {
71 if (switchedOff(value)) return 0
72 const number = toNumber(value)
73 return number !== undefined && number > 0 && number <= 100 ? number : undefined
74}
75
76function countOf(value, least) {
77 const number = toNumber(value)
78 return number !== undefined && Number.isInteger(number) && number >= least ? number : undefined
79}
80
81const FIELDS = {
82 lean: (value) => (typeof value === "boolean" ? value : undefined),
83 earlyAtPercent: percentOf,
84 keepTurns: (value) => countOf(value, 0),
85 maxToolResultChars: (value) => countOf(value, 100),
86 instructions: (value) => (typeof value === "string" ? value : undefined),
87}
88
89function settle(base, section) {
90 const settled = {}
91 for (const [key, read] of Object.entries(FIELDS)) {
92 const own = isPlainObject(section) && key in section ? read(section[key]) : undefined
93 settled[key] = own === undefined ? base[key] : own
94 }
95 return settled
96}
97
98// carried keeps early compaction off for a config of noctis before 8.6.0 that switched it off as
99// compactAtPercent, a share of the whole window then.
100function carried(section) {
101 if (!isPlainObject(section) || !("compactAtPercent" in section) || "earlyAtPercent" in section) return section
102 return switchedOff(section.compactAtPercent) ? { ...section, earlyAtPercent: 0 } : section
103}
104
105export function policyOf(shipped, own) {
106 const base = settle(SHIPPED, carried(shipped))
107 return own === undefined ? base : settle(base, isPlainObject(own) ? carried(own) : undefined)
108}
109
110// claudeInteger reads CLAUDE_CODE_AUTO_COMPACT_WINDOW as Claude Code reads it: an integer in exponent
111// form, digits in groups of three, else the digits it starts with.
112function claudeInteger(value) {
113 const text = String(value).trim()
114 if (text.length <= 32) {
115 if (EXPONENT.test(text)) {
116 const number = Number(text)
117 return Number.isInteger(number) ? number : NaN
118 }
119 if (GROUPED.test(text)) return parseInt(text.replace(GROUP_MARKS, ""), 10)
120 }
121 return parseInt(text, 10)
122}
123
124function truthy(value) {
125 return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase())
126}
127
128// anthropicURL tells whether ANTHROPIC_BASE_URL leaves Claude Code on Anthropic's own API: unset, or a URL whose
129// host is api.anthropic.com.
130function anthropicURL(base) {
131 if (!base) return true
132 const text = String(base).replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "")
133 const parts = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/(?:[^/?#]*@)?(\[[^\]]*\]|[^/?#:]*)(?::([^/?#]*))?(?:[/?#]|$)/.exec(text)
134 if (!parts) return false
135 const scheme = parts[1].toLowerCase()
136 const special = Object.hasOwn(DEFAULT_PORTS, scheme)
137 let host = parts[2]
138 try {
139 host = decodeURIComponent(host)
140 } catch {
141 return false
142 }
143 if (special) host = host.toLowerCase()
144 if (parts[3]) {
145 const port = /^\d+$/.test(parts[3]) ? Number(parts[3]) : NaN
146 if (!special || !(port <= 65535) || String(port) !== DEFAULT_PORTS[scheme]) return false
147 }
148 return host === "api.anthropic.com"
149}
150
151// modelNaming is what Claude Code names a model by besides its id, as the environment and modelOverrides set
152// it: the models its aliases stand for, the provider, and whether [1m] and the legacy Opus ids count.
153export function modelNaming(env = {}, overrides = undefined) {
154 const provider = PROVIDERS.find(([variable]) => truthy(env[variable]))?.[1] ?? "firstParty"
155 const taken = isPlainObject(overrides) && Object.values(overrides).every((value) => typeof value === "string") ? overrides : {}
156 const naming = { provider, bedrockPrefix: bedrockPrefixOf(env), overrides: taken, aliases: {} }
157 for (const [alias, models] of Object.entries(ALIAS_MODELS)) {
158 naming.aliases[alias] = textOf(env[ALIAS_VARIABLES[alias]]) || aliasModel(naming, models[provider] ?? models[""])
159 }
160 naming.aliases.opusplan = naming.aliases.sonnet
161 naming.aliases.best = naming.aliases.opus
162 naming.remap = !["bedrock", "foundry", "mantle", "vertex"].includes(provider) && !truthy(env.CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP)
163 naming.oneMillion = !truthy(env.CLAUDE_CODE_DISABLE_1M_CONTEXT)
164 naming.firstParty = provider === "firstParty" && (truthy(env._CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL) || anthropicURL(env.ANTHROPIC_BASE_URL))
165 const bare = naming.aliases.fable.replace(/\[1m\]/gi, "")
166 if (bare !== naming.aliases.fable && naming.firstParty && naming.oneMillion && nativeOneMillion(naming, bare)) naming.aliases.fable = bare
167 return naming
168}
169
170const FIRST_PARTY_NAMING = modelNaming()
171
172function textOf(value) {
173 return typeof value === "string" ? value.trim() : ""
174}
175
176// aliasModel is the id Claude Code takes for an alias whose catalog gives it model: the one modelOverrides maps
177// the model's first-party id to, else the provider's id for it.
178function aliasModel(naming, model) {
179 const ids = MODEL_IDS[model]
180 if (Object.hasOwn(naming.overrides, ids.firstParty) && naming.overrides[ids.firstParty]) return naming.overrides[ids.firstParty]
181 const id = ids[naming.provider] ?? MODEL_IDS["claude-haiku-4-5"][naming.provider]
182 return naming.provider === "bedrock" ? id.replace("us.", `${naming.bedrockPrefix}.`) : id
183}
184
185// bedrockPrefixOf is the region prefix Claude Code gives Bedrock ids: us-gov in a GovCloud region, else the one
186// ANTHROPIC_BEDROCK_REGION_PREFIX names, else the region's (us, eu, apac or global). The region is AWS_REGION's,
187// else AWS_DEFAULT_REGION's, else us-east-1 (Claude Code reads the AWS config's region before that; noctis
188// does not).
189function bedrockPrefixOf(env) {
190 const region = [textOf(env.AWS_REGION), textOf(env.AWS_DEFAULT_REGION)].find((value) => /^[a-z]{2,}(?:-[a-z0-9]+){0,4}$/i.test(value)) ?? "us-east-1"
191 if (region.startsWith("us-gov-")) return "us-gov"
192 if (BEDROCK_PREFIXES.includes(textOf(env.ANTHROPIC_BEDROCK_REGION_PREFIX))) return textOf(env.ANTHROPIC_BEDROCK_REGION_PREFIX)
193 return region.startsWith("us-") ? "us" : region.startsWith("eu-") ? "eu" : region.startsWith("ap-") ? "apac" : "global"
194}
195
196function trimOneMillion(model) {
197 return model.replace(/\[1m\]$/i, "")
198}
199
200function withOneMillion(model, marked) {
201 return marked ? `${model.replace(ONE_MILLION_TAIL, "")}[1m]` : model
202}
203
204// parseModel parses a Claude model name as Claude Code's Hu does.
205function parseModel(model) {
206 let name = model.trim().toLowerCase()
207 if (name === "" || /\s/.test(name)) return undefined
208 name = name.replace(/\[[12]m\]$/, "")
209 name = name.slice(name.lastIndexOf("/") + 1)
210 const provider = PROVIDER_MODEL.exec(name)
211 if (provider) {
212 if (provider[1] !== undefined && !BEDROCK_REGIONS.includes(provider[1])) return undefined
213 name = provider[2]
214 }
215 let parse
216 let found = /^claude-([a-z]+)-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?/.exec(name)
217 if (found) parse = { family: found[1], major: Number(found[2]), minor: Number(found[3] ?? 0), legacy: false, base: found[0] }
218 else if ((found = /^claude-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?-([a-z]+)/.exec(name))) parse = { family: found[3], major: Number(found[1]), minor: Number(found[2] ?? 0), legacy: true, base: found[0] }
219 else return undefined
220 const rest = name.slice(parse.base.length)
221 if (rest !== "" && !/^[-@]/.test(rest)) return undefined
222 parse.trailer = !MODEL_TAIL.test(rest)
223 return parse
224}
225
226// canonicalModel is the model Claude Code's RS takes a name in lower case for.
227function canonicalModel(name) {
228 const parse = parseModel(name)
229 if (parse && !parse.trailer) {
230 const known = CATALOG_MODELS.find((id) => {
231 const own = parseModel(id)
232 return own.family === parse.family && own.legacy === parse.legacy && own.major === parse.major && own.minor === parse.minor
233 })
234 return known ?? parse.base.replace(DATE_SUFFIX, "")
235 }
236 for (const id of MODEL_LADDER) {
237 if (id === "claude-opus-4" || id === "claude-sonnet-4" ? new RegExp(`${id}(?!-\\d(?!\\d))`).test(name) : name.includes(id)) return id === "claude-opus-4" || id === "claude-sonnet-4" ? `${id}-0` : id
238 }
239 return name.replace(DATE_SUFFIX, "")
240}
241
242function knownModel(id) {
243 const bare = trimOneMillion(id)
244 return CATALOG_MODELS.includes(bare) || bare === "claude-mythos-preview"
245}
246
247// identify is the model Claude Code takes a name for: the first model modelOverrides maps back from it, else
248// its canonical model.
249function identify(naming, model) {
250 for (const [name, value] of Object.entries(naming.overrides)) {
251 if (value === model && knownModel(canonicalModel(name.toLowerCase()))) return canonicalModel(name.toLowerCase())
252 }
253 return canonicalModel(model.toLowerCase())
254}
255
256function nativeOneMillion(naming, model) {
257 const bare = trimOneMillion(model)
258 if (NATIVE_1M.includes(trimOneMillion(identify(naming, bare)))) return true
259 return bare !== model && NATIVE_1M.includes(trimOneMillion(identify(naming, model)))
260}
261
262// resolve is the model a name stands for, as Claude Code's Tt gives it.
263function resolve(naming, model) {
264 const trimmed = model.trim()
265 const lower = trimmed.toLowerCase()
266 const marked = naming.oneMillion && ONE_MILLION.test(lower)
267 const name = marked ? trimOneMillion(lower).trim() : lower
268 if (name === "fable") return marked && !naming.firstParty && !ONE_MILLION.test(naming.aliases.fable) ? `${naming.aliases.fable}[1m]` : naming.aliases.fable
269 if (["opus", "opusplan", "sonnet", "haiku"].includes(name)) return withOneMillion(naming.aliases[name], marked)
270 if (name === "best") return naming.aliases.best
271 if (naming.remap && LEGACY_OPUS.includes(name)) return withOneMillion(naming.aliases.opus, marked)
272 if (marked && naming.firstParty && name.includes("fable") && nativeOneMillion(naming, name)) return trimmed.replace(ONE_MILLION_TAIL, "").trim()
273 return marked ? `${trimmed.replace(ONE_MILLION_TAIL, "").trim()}[1m]` : trimmed
274}
275
276// modelKey is the name Claude Code 2.1.293 files a model's own settings under in modelSettings, its mV, for a
277// session's model and an entry's name alike: the id of the model an alias stands for; the catalog id of a
278// model's dated, [1m], Bedrock, Vertex and Foundry ids; the model modelOverrides maps a provider's id back to;
279// else the name in lower case without [1m] or a date. "" for none.
280export function modelKey(model, naming = FIRST_PARTY_NAMING) {
281 return typeof model === "string" ? trimOneMillion(identify(naming, resolve(naming, model))) : ""
282}
283
284// windowOf is an autoCompactWindow as Claude Code's settings take it: a whole number of tokens from
285// WINDOW_MIN to WINDOW_MAX.
286function windowOf(value) {
287 return Number.isInteger(value) && value >= WINDOW_MIN && value <= WINDOW_MAX ? value : undefined
288}
289
290// windowTable is what Claude Code's gOt finds in the settings files it lays over each other, given in its
291// order: the autoCompactWindow of the last that sets one, and the models' own windows of that file and the
292// files after it, a later file's over an earlier one's. In one file the entry under the name Claude Code files
293// a model under comes before the model's other names, and of those the first; entries Claude Code does not
294// take ("auto" and a window are taken) are passed over.
295function windowTable(layers, naming) {
296 let top
297 let models = new Map()
298 for (const settings of layers) {
299 if (!isPlainObject(settings)) continue
300 const own = new Map()
301 if (isPlainObject(settings.modelSettings)) {
302 for (const [name, entry] of Object.entries(settings.modelSettings)) {
303 if (Object.hasOwn(Object.prototype, name) || !isPlainObject(entry)) continue
304 const window = entry.autoCompactWindow
305 if (window !== "auto" && windowOf(window) === undefined) continue
306 const key = modelKey(name, naming)
307 if (key && (name === key || !own.has(key))) own.set(key, window)
308 }
309 }
310 if (windowOf(settings.autoCompactWindow) !== undefined) {
311 top = settings.autoCompactWindow
312 models = own
313 } else models = new Map([...models, ...own])
314 }
315 return { top, models }
316}
317
318// settingsWindow is the window settings give sessions on model: the model's own entry in modelSettings before
319// autoCompactWindow; "auto" there leaves the window to Claude Code. settings is a settings object, or the
320// settings files Claude Code lays over each other, in its order.
321function settingsWindow(settings, model, naming = FIRST_PARTY_NAMING) {
322 const table = windowTable(Array.isArray(settings) ? settings : [settings], naming)
323 const key = typeof model === "string" ? modelKey(model, naming) : ""
324 return windowOf(key && table.models.has(key) ? table.models.get(key) : table.top)
325}
326
327// replyTokens is how much of the window Claude Code keeps for the summary: 20000 tokens, or the fewer that
328// CLAUDE_CODE_MAX_OUTPUT_TOKENS lets a reply have.
329function replyTokens(maxOutput) {
330 const limit = maxOutput ? claudeInteger(maxOutput) : NaN
331 return !Number.isNaN(limit) && limit > 0 ? Math.min(limit, REPLY_TOKENS) : REPLY_TOKENS
332}
333
334// compactionPoint is how many tokens of context a session on model, in a window of window tokens, holds
335// when Claude Code compacts it, reckoned as Claude Code reckons it: CLAUDE_CODE_AUTO_COMPACT_WINDOW, or
336// else autoCompactWindow (the model's own entry in modelSettings first), lowers the window; 20000 tokens
337// (fewer where CLAUDE_CODE_MAX_OUTPUT_TOKENS is lower) are kept for the summary and 13000 more;
338// CLAUDE_AUTOCOMPACT_PCT_OVERRIDE's percent of the rest comes sooner when it is the smaller.
339export function compactionPoint(window, { variable, percent, settings, model, maxOutput, naming } = {}) {
340 let effective = window
341 const configured = variable ? claudeInteger(variable) : NaN
342 if (!Number.isNaN(configured) && configured > 0) effective = Math.min(window, Math.max(WINDOW_MIN, Math.min(configured, WINDOW_MAX)))
343 else {
344 const own = settingsWindow(settings, model, naming)
345 if (own !== undefined) effective = Math.min(window, own)
346 }
347 const usable = effective - replyTokens(maxOutput)
348 const share = percent ? parseFloat(percent) : NaN
349 if (!Number.isNaN(share) && share > 0 && share <= 100) return Math.min(Math.floor(usable * (share / 100)), usable - BUFFER_TOKENS)
350 return usable - BUFFER_TOKENS
351}
352
353function pausePointsOf(shipped, own) {
354 const base = isPlainObject(shipped) ? shipped : undefined
355 const thresholds = isPlainObject(own) ? { ...base, ...own } : base
356 const points = new Map()
357 for (const [key, builtin] of Object.entries(BUILTIN_THRESHOLDS)) {
358 if (!isPlainObject(thresholds) || !(key in thresholds)) continue
359 const point = percentOf(thresholds[key])
360 if (point === undefined) points.set(key, percentOf(base?.[key]) || builtin)
361 else if (point > 0) points.set(key, point)
362 }
363 return points
364}
365
366function nearPausePoint(limits, points, now) {
367 for (const limit of Array.isArray(limits) ? limits : []) {
368 const point = points.get(WINDOW_THRESHOLDS.get(limit?.kind))
369 if (point === undefined || typeof limit.percentUsed !== "number" || !(Date.parse(limit.resetsAt) > now)) continue
370 if (limit.percentUsed >= point - COMPACTION_BAND) return true
371 }
372 return false
373}
374
375function cut(text, max) {
376 if (typeof text !== "string" || text.length <= max) return text
377 let head = Math.floor(max / 2)
378 let tail = text.length - (max - head)
379 if (/[\uD800-\uDBFF]/.test(text[head - 1] ?? "")) head -= 1
380 if (/[\uDC00-\uDFFF]/.test(text[tail] ?? "")) tail += 1
381 return `${text.slice(0, head)}\n[noctis: ${tail - head} chars trimmed]\n${text.slice(tail)}`
382}
383
384function cutInput(value, max) {
385 if (typeof value === "string") return cut(value, max)
386 if (Array.isArray(value)) {
387 const items = value.map((item) => cutInput(item, max))
388 return items.some((item, index) => item !== value[index]) ? items : value
389 }
390 if (isPlainObject(value)) {
391 const entries = Object.entries(value).map(([key, item]) => [key, cutInput(item, max)])
392 return entries.some(([key, item]) => item !== value[key]) ? Object.fromEntries(entries) : value
393 }
394 return value
395}
396
397function canonical(value) {
398 if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
399 if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
400 return JSON.stringify(value) ?? "null"
401}
402
403function tailStart(rows, keepTurns) {
404 if (keepTurns <= 0) return rows.length
405 let seen = 0
406 for (let index = rows.length - 1; index >= 0; index -= 1) {
407 if (rows[index]?.role === "assistant" && ++seen === keepTurns) return index
408 }
409 return 0
410}
411
412function selfContained(answer) {
413 return answer?.isError !== true && answer?.result?.type !== "file_unchanged" && !(typeof answer?.text === "string" && answer.text.startsWith(UNCHANGED_NOTE))
414}
415
416function outcomes(rows) {
417 const answered = new Map()
418 for (const row of rows) {
419 for (const result of Array.isArray(row?.toolResults) ? row.toolResults : []) {
420 if (typeof result?.tool_use_id === "string") answered.set(result.tool_use_id, selfContained(result))
421 }
422 }
423 return answered
424}
425
426function supersededReads(rows) {
427 const answered = outcomes(rows)
428 const calls = []
429 for (const row of rows) {
430 if (row?.role !== "assistant" || !Array.isArray(row.toolUses)) continue
431 for (const use of row.toolUses) {
432 if (!READ_ONLY.has(use?.tool) || typeof use.tool_use_id !== "string") continue
433 calls.push({ id: use.tool_use_id, key: `${use.tool} ${canonical(use.input ?? {})}`, tool: use.tool, good: answered.get(use.tool_use_id) === true && selfContained(use) })
434 }
435 }
436 const superseded = new Map()
437 const laterGood = new Set()
438 for (let index = calls.length - 1; index >= 0; index -= 1) {
439 const { id, key, tool, good } = calls[index]
440 if (laterGood.has(key)) superseded.set(id, tool)
441 if (good) laterGood.add(key)
442 }
443 return superseded
444}
445
446function withoutHandle(row, changes) {
447 const { handle, ...rest } = row
448 return { ...rest, ...changes }
449}
450
451function pruneUser(row, policy, superseded) {
452 const changes = {}
453 if (typeof row.text === "string" && row.text.includes("<system-reminder>")) {
454 const stripped = row.text.replace(REMINDER, "").trimEnd()
455 const results = Array.isArray(row.toolResults) && row.toolResults.length > 0
456 if (stripped !== row.text && (stripped.trim() !== "" || results)) changes.text = stripped
457 }
458 if (Array.isArray(row.toolResults)) {
459 const results = row.toolResults.map((result) => {
460 if (typeof result?.text !== "string") return result
461 const tool = superseded.get(result.tool_use_id)
462 const text = tool ? `[noctis: dropped, the same ${tool} ran again later]` : cut(result.text, policy.maxToolResultChars)
463 if (text === result.text) return result
464 const { result: stored, ...rest } = result
465 return { ...rest, text }
466 })
467 if (results.some((result, index) => result !== row.toolResults[index])) changes.toolResults = results
468 }
469 return Object.keys(changes).length ? withoutHandle(row, changes) : row
470}
471
472function pruneAssistant(row, policy) {
473 if (!Array.isArray(row.toolUses)) return row
474 const uses = row.toolUses.map((use) => {
475 if (!isPlainObject(use?.input)) return use
476 const input = cutInput(use.input, policy.maxToolResultChars)
477 return input === use.input ? use : { ...use, input }
478 })
479 return uses.some((use, index) => use !== row.toolUses[index]) ? withoutHandle(row, { toolUses: uses }) : row
480}
481
482export function prune(rows, policy) {
483 if (!Array.isArray(rows)) return rows
484 const settled = policyOf(policy, undefined)
485 const start = tailStart(rows, settled.keepTurns)
486 const superseded = supersededReads(rows)
487 return rows.map((row, index) => {
488 if (index >= start || !isPlainObject(row)) return row
489 if (row.role === "user") return pruneUser(row, settled, superseded)
490 if (row.role === "assistant") return pruneAssistant(row, settled)
491 return row
492 })
493}
494
495function textSize(value) {
496 if (typeof value === "string") return value.length
497 if (Array.isArray(value)) return value.reduce((sum, item) => sum + textSize(item), 0)
498 if (isPlainObject(value)) return Object.values(value).reduce((sum, item) => sum + textSize(item), 0)
499 return 0
500}
501
502function rowSize(row) {
503 if (!isPlainObject(row)) return 0
504 const uses = (Array.isArray(row.toolUses) ? row.toolUses : []).reduce((sum, use) => sum + textSize(use?.input), 0)
505 const results = (Array.isArray(row.toolResults) ? row.toolResults : []).reduce((sum, result) => sum + textSize(result?.text), 0)
506 return textSize(row.text) + uses + results
507}
508
509export function measure(before, after) {
510 let changed = 0
511 let trimmed = 0
512 for (let index = 0; index < before.length; index += 1) {
513 if (after[index] === before[index]) continue
514 changed += 1
515 trimmed += rowSize(before[index]) - rowSize(after[index])
516 }
517 return { changed, trimmed }
518}
519
520function trimSeparators(path) {
521 return path.replace(/[\\/]+$/, "")
522}
523
524async function homeDir($) {
525 const windows = /^([A-Za-z]:[\\/]|\\\\)/.test($.plugin.root ?? "")
526 const home = windows ? await $.env.get("USERPROFILE") : await $.env.get("HOME")
527 return home ? trimSeparators(home) : undefined
528}
529
530async function accountDir($) {
531 const configured = await $.env.get("CLAUDE_CONFIG_DIR")
532 if (configured) return trimSeparators(configured)
533 const home = await homeDir($)
534 return home ? `${home}/.claude` : undefined
535}
536
537function parseJson(text) {
538 try {
539 return JSON.parse(text.replace(/^/, ""))
540 } catch {
541 return undefined
542 }
543}
544
545async function readJson($, path) {
546 try {
547 return parseJson(await $.fs.read(path))
548 } catch {
549 return undefined
550 }
551}
552
553function compactionOf(config) {
554 return isPlainObject(config) ? config.compaction : undefined
555}
556
557function thresholdsOf(config) {
558 return isPlainObject(config) ? config.thresholds : undefined
559}
560
561async function contextOf($) {
562 const account = await accountDir($)
563 const shipped = await readJson($, `${trimSeparators($.plugin.root ?? ".")}/config.default.json`)
564 const own = account ? await readJson($, `${account}/noctis/config.json`) : undefined
565 return { account, policy: policyOf(compactionOf(shipped), compactionOf(own)), pausePoints: pausePointsOf(thresholdsOf(shipped), thresholdsOf(own)), sid: await $.session.id() }
566}
567
568function debug($, text) {
569 try {
570 $.ui.log(`noctis lean: ${text}`, { to: "debug" })
571 } catch {
572 return
573 }
574}
575
576async function globalConfig($, account) {
577 if (account) {
578 const legacy = await $.fs.read(`${account}/.config.json`).catch(() => undefined)
579 if (typeof legacy === "string") return parseJson(legacy)
580 }
581 const configured = await $.env.get("CLAUDE_CONFIG_DIR")
582 const base = configured ? trimSeparators(configured) : await homeDir($)
583 if (!base) return undefined
584 const suffix = (await $.env.get("CLAUDE_CODE_CUSTOM_OAUTH_URL")) ? "-custom-oauth" : ""
585 return readJson($, `${base}/.claude${suffix}.json`)
586}
587
588async function ownCompactionOff($, account) {
589 if (truthy(await $.env.get("DISABLE_AUTO_COMPACT")) || truthy(await $.env.get("DISABLE_COMPACT"))) return true
590 const settings = await $.settings.read()
591 if (isPlainObject(settings) && settings.autoCompactEnabled !== undefined) return settings.autoCompactEnabled === false
592 const global = await globalConfig($, account)
593 return isPlainObject(global) && global.autoCompactEnabled === false
594}
595
596async function attempt(read) {
597 try {
598 return await read()
599 } catch {
600 return undefined
601 }
602}
603
604// settingsLayers are the settings files Claude Code lays over each other, in its order, each as
605// $.settings.read gives it; undefined where it does not give them one by one. Each source is named where it is
606// read, as each variable is.
607async function settingsLayers($) {
608 try {
609 return await Promise.all([
610 $.settings.read({ source: "user" }),
611 $.settings.read({ source: "project" }),
612 $.settings.read({ source: "local" }),
613 $.settings.read({ source: "flag" }),
614 $.settings.read({ source: "policy" }),
615 ])
616 } catch {
617 return undefined
618 }
619}
620
621// ownWindows tells whether settings give a model a window of its own.
622function ownWindows(settings) {
623 return isPlainObject(settings) && isPlainObject(settings.modelSettings) && Object.values(settings.modelSettings).some((entry) => isPlainObject(entry) && entry.autoCompactWindow !== undefined)
624}
625
626// namingOf reads the naming of models only where layers, the settings files, give a model a window of its own;
627// modelOverrides are those of merged, the settings laid together. Claude Code loads a module only when each
628// variable it reads is named where it is read, so every one is spelled out.
629async function namingOf($, layers, merged) {
630 if (!Array.isArray(layers) || !layers.some(ownWindows)) return undefined
631 const env = {
632 ANTHROPIC_DEFAULT_OPUS_MODEL: await attempt(() => $.env.get("ANTHROPIC_DEFAULT_OPUS_MODEL")),
633 ANTHROPIC_DEFAULT_SONNET_MODEL: await attempt(() => $.env.get("ANTHROPIC_DEFAULT_SONNET_MODEL")),
634 ANTHROPIC_DEFAULT_HAIKU_MODEL: await attempt(() => $.env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")),
635 ANTHROPIC_DEFAULT_FABLE_MODEL: await attempt(() => $.env.get("ANTHROPIC_DEFAULT_FABLE_MODEL")),
636 CLAUDE_CODE_USE_BEDROCK: await attempt(() => $.env.get("CLAUDE_CODE_USE_BEDROCK")),
637 CLAUDE_CODE_USE_FOUNDRY: await attempt(() => $.env.get("CLAUDE_CODE_USE_FOUNDRY")),
638 CLAUDE_CODE_USE_ANTHROPIC_AWS: await attempt(() => $.env.get("CLAUDE_CODE_USE_ANTHROPIC_AWS")),
639 CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: await attempt(() => $.env.get("CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD")),
640 CLAUDE_CODE_USE_MANTLE: await attempt(() => $.env.get("CLAUDE_CODE_USE_MANTLE")),
641 CLAUDE_CODE_USE_VERTEX: await attempt(() => $.env.get("CLAUDE_CODE_USE_VERTEX")),
642 CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP: await attempt(() => $.env.get("CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP")),
643 CLAUDE_CODE_DISABLE_1M_CONTEXT: await attempt(() => $.env.get("CLAUDE_CODE_DISABLE_1M_CONTEXT")),
644 ANTHROPIC_BASE_URL: await attempt(() => $.env.get("ANTHROPIC_BASE_URL")),
645 _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL: await attempt(() => $.env.get("_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL")),
646 AWS_REGION: await attempt(() => $.env.get("AWS_REGION")),
647 AWS_DEFAULT_REGION: await attempt(() => $.env.get("AWS_DEFAULT_REGION")),
648 ANTHROPIC_BEDROCK_REGION_PREFIX: await attempt(() => $.env.get("ANTHROPIC_BEDROCK_REGION_PREFIX")),
649 }
650 return modelNaming(env, isPlainObject(merged) ? merged.modelOverrides : undefined)
651}
652
653// shareOf is how full the context is, in percent of where Claude Code compacts it; of the whole window
654// when the engine reports no token count or window; undefined when it reports neither.
655async function shareOf($, context) {
656 const { tokens, window, percent } = isPlainObject(context) ? context : {}
657 if (typeof tokens === "number" && typeof window === "number" && window > 0) {
658 const merged = await attempt(() => $.settings.read())
659 const layers = (await settingsLayers($)) ?? [merged]
660 const point = compactionPoint(window, {
661 variable: await $.env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"),
662 percent: await $.env.get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"),
663 settings: layers,
664 model: await attempt(() => $.session.model()),
665 maxOutput: await $.env.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS"),
666 naming: await namingOf($, layers, merged),
667 })
668 if (point > 0) return (100 * tokens) / point
669 }
670 return typeof percent === "number" ? percent : undefined
671}
672
673async function remember($, context) {
674 if (!context.account || !context.sid) return
675 const file = `${context.account}/noctis/lean.json`
676 const stored = await readJson($, file)
677 const sessions = isPlainObject(stored) && isPlainObject(stored.sessions) ? stored.sessions : {}
678 if (typeof sessions[context.sid] === "number") return
679 const at = Math.floor((await $.clock.now()) / 1000)
680 const latest = Object.entries({ ...sessions, [context.sid]: at })
681 .filter(([, when]) => typeof when === "number")
682 .sort((a, b) => b[1] - a[1])
683 .slice(0, SESSIONS_KEPT)
684 await $.fs.write(file, `${JSON.stringify({ sessions: Object.fromEntries(latest) })}\n`)
685}
686
687async function record($, context, entry) {
688 if (!context.account) return
689 const file = `${context.account}/noctis/compact.log`
690 const previous = await $.fs.read(file).catch(() => "")
691 const kept = previous.split("\n").filter((line) => line.trim() !== "").slice(-(LOG_LINES - 1))
692 kept.push(JSON.stringify(entry))
693 await $.fs.write(file, `${kept.join("\n")}\n`)
694}
695
696export function register(on, options) {
697 const disarmed = new Set()
698 let early
699
700 on("session.start", async ($, e, next) => {
701 const started = await next(e)
702 try {
703 const context = await contextOf($)
704 if (context.policy.lean) await remember($, context)
705 } catch (err) {
706 debug($, `session record not written: ${err}`)
707 }
708 return started
709 })
710
711 on("session.compact", async ($, e, next) => {
712 if (!Array.isArray(e.messages)) return next(e)
713 let context
714 let trimming
715 let pruned
716 let counted
717 try {
718 context = await contextOf($)
719 trimming = context.policy.lean && truthy(await $.env.get("DISABLE_PROMPT_CACHING"))
720 if (trimming && e.trigger !== "precompute") {
721 pruned = prune(e.messages, context.policy)
722 counted = measure(e.messages, pruned)
723 }
724 } catch (err) {
725 debug($, `left the compaction as it was: ${err}`)
726 return next(e)
727 }
728 if (!context.policy.lean) return next(e)
729 const instructions = e.instructions ?? (context.policy.instructions || undefined)
730 if (!trimming) return next(instructions === e.instructions ? e : { ...e, instructions })
731 if (e.trigger === "precompute") return { skip: PRECOMPUTE_SKIP }
732 const handed = { ...e, messages: pruned }
733 if (instructions !== undefined) handed.instructions = instructions
734 const result = await next(handed)
735 if (!Array.isArray(result?.messages)) return result
736 try {
737 const entry = { at: Math.floor((await $.clock.now()) / 1000), sid: context.sid, trigger: e.trigger, rows: e.messages.length, changed: counted.changed, trimmed: counted.trimmed }
738 if (e.agentId) entry.agent = e.agentId
739 if (early && e.trigger === "plugin" && early.sid === context.sid) entry.ctx = early.percent
740 if (typeof result.tokensBefore === "number") entry.tokensBefore = result.tokensBefore
741 if (typeof result.tokensAfter === "number") entry.tokensAfter = result.tokensAfter
742 await record($, context, entry)
743 } catch (err) {
744 debug($, `compaction not logged: ${err}`)
745 }
746 return result
747 })
748
749 on("turn.complete", async ($, e, next) => {
750 const answered = await next(e)
751 if (e.agentId || e.reason !== "answer") return answered
752 try {
753 const context = await contextOf($)
754 if (!context.policy.lean) return answered
755 await remember($, context)
756 const mark = context.policy.earlyAtPercent
757 if (!mark) return answered
758 const usage = await $.session.usage()
759 const share = await shareOf($, usage?.context)
760 if (share === undefined) return answered
761 const percent = Math.round(share)
762 if (share < mark) {
763 disarmed.delete(context.sid)
764 return answered
765 }
766 if (disarmed.has(context.sid) || (await ownCompactionOff($, context.account))) return answered
767 if (nearPausePoint(usage.rateLimits, context.pausePoints, await $.clock.now())) {
768 debug($, `early compaction at ${percent}% of the compaction point put off: the 5-hour window is within ${COMPACTION_BAND} points of its pause point`)
769 return answered
770 }
771 disarmed.add(context.sid)
772 early = { sid: context.sid, percent }
773 try {
774 await $.session.compact(context.policy.instructions ? { instructions: context.policy.instructions } : {})
775 } catch (err) {
776 debug($, `early compaction at ${percent}% of the compaction point not run: ${err}`)
777 } finally {
778 early = undefined
779 }
780 } catch (err) {
781 debug($, `turn end skipped: ${err}`)
782 }
783 return answered
784 })
785}
786