SLOPSHOPPER

noctis

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…

new
★ 1v8.6.4MITupdated 2026-10-08synex1437/noctis
A shopper browsing a rack in a slop shop
README

<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>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#quick-start"><b>Quick start</b></a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#how-it-compares"><b>Compare</b></a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#commands"><b>Commands</b></a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#faq"><b>FAQ</b></a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="docs/GUIDE.md"><b>Guide</b></a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="README.tr.md"><b>Türkçe</b></a>

Noctis — Claude Code that keeps working while you sleep

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>

Sound familiar?

  • You queued forty tasks, went to bed, and woke up to "You've hit your session limit", sent at 01:40.
  • The session stopped at task three with "moving on to the next thing", and nobody was there to say continue.
  • The weekly limit ran out on a Friday night, reset on Monday morning, and nothing moved until you came back.

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.

What you get

  • It pauses before the limit, not after the 429. At 92 % of the 5-hour window and 95 % of the weekly one by default, or earlier when a burst or the burn rate says the next turns would cross it, after a checkpoint of the last request, the touched files, git status, todos and the next jobs.
  • It resumes the same session by itself. A reset within about 5½ hours is waited out inside the turn, context intact. A later one, such as the weekly limit, relaunches 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.
  • Your queue keeps moving. /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.
  • Claude stays in its smart zone. Claude Code compacts by about 280k tokens of context, in a 1M window too, rather than near its end, and noctis follows any point you pick instead (a token count, a percent of each model's window, /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.
  • Your paid credits stay untouched. It stops at 100 % of a window even when the thresholds are off, and refuses a fan-out workflow that would not fit in what is left.
  • Zero tokens per decision. Fixed rules over your usage data, and every decision logged (noctis why). One self-contained Go binary, about 5 ms per hook on Linux; no Node, Git Bash or compiler.
  • The right model for each job. Code on Opus 5.5 · xhigh by default (the Code profile), or on Sonnet 5.5 · high to spend less (Balanced), file search and output digests on Haiku 5.5, and, with the router on, research and writing in a subagent of their own.
  • Lean compaction, a status line and 15 languages. It compacts between turns once the context is 90 % of the way to the compaction point, draws a status line with both usage windows and their reset times, and speaks the language you type in.
  • Not only Claude Code. It also runs in OpenAI Codex CLI, Antigravity CLI, Factory Droid and GitHub Copilot CLI, with fewer features.

Install

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 keep leaves 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 up settings.json first, 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.

Quick start

  1. Look at the bottom of the window: ∞ 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.
  2. Put a few jobs in a file, one per line (- [ ] write tests for the payments module), and type /noctis:start TASKS.md. Claude works through them in order; /noctis:stop ends it early.
  3. Go to bed. At a limit there is nothing to do: noctis pauses, waits and continues, and a desktop notification tells you when work resumes.

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.

Why not just let Claude Code continue?

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.

How it compares

noctisusage dashboards / status linesloop 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-drivenvaries
Overload (529/5xx) backoff, separate from limit handling✅——some
One self-contained binary, no runtime to install✅variesvariesvaries
Claude Code, and with fewer features Codex CLI, Antigravity CLI, Droid and Copilot CLI✅someClaude onlysome

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.

Commands

CommandWhat it does
/noctis:setupFirst setup; run it again to change which model does what
/noctis:statusUsage, 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:stopEnds 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:resumeEnds a pause early
!noctis whyThe decisions noctis made, and why; --stats sums up the last week of them
!noctis doctorChecks 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.

FAQ

Will it spend my extra usage credits?

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.

What does it send over the network?

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.

Does it work with an API key?

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.

Can a cloned repository's TASKS.md make Claude run things?

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.

Can it carry a large project on a server for days?

That is what the queue is for:

  • Run claude inside tmux on the server and write the plan as a TASKS.md with priorities, dependencies and a noctis-verify check line.
  • Mark the steps only you can do (human), give a hard or an easy step its model with (opus) or (sonnet), and trust the file once you have read it.
  • Claude works through it and commits as it goes, runs long builds as 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.
  • A relaunch after a long wait opens as a new tmux window, and noctis queue status --json tells a monitoring script where the queue stands.
  • With 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.

Large projects on a server.

How do I turn it off or uninstall it?

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.

How is it tested?

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.

Documentation

  • Guide: what it changes and how to undo it, the queue format, what it does while you are away, large projects on a server, lean compaction, the status line, install details and the other AI coding tools
  • Reference: every command, flag, configuration key and file
  • Testing: what each suite asks and what the latest runs measured
  • Release notes

Contributing

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.

License

MIT — © 2026 synex

Source 1 files
hooks/lean.js 786 lines
1export 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