Sends read-only subagent and workflow-agent tasks to a cheaper model and switches a task up to the session model when it needs to write, gets stuck or is…

<img src="docs/assets/logo.svg" width="120" alt="gpress">
<h1 align="center">gpress</h1>
<em>Lossless PDF compression: smaller PDFs that standard readers open,<br>and an archive that restores byte for byte.</em>
<a href="https://github.com/soumyadebroy3/gpress/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/soumyadebroy3/gpress/ci.yml?branch=main&style=flat-square&label=CI&color=111111" alt="CI"></a> <a href="https://github.com/soumyadebroy3/gpress/actions/workflows/codeql.yml"><img src="https://img.shields.io/github/actions/workflow/status/soumyadebroy3/gpress/codeql.yml?branch=main&style=flat-square&label=CodeQL&color=111111" alt="CodeQL"></a> <img src="https://img.shields.io/badge/version-0.1.0-111111?style=flat-square" alt="Version 0.1.0"> <img src="https://img.shields.io/badge/archive%20format-9-111111?style=flat-square" alt="Archive format 9"> <img src="https://img.shields.io/badge/Rust%20%C2%B7%20C%20%C2%B7%20WebAssembly-111111?style=flat-square" alt="Rust, C and WebAssembly"> <a href="#licence"><img src="https://img.shields.io/badge/licence-MIT%20OR%20Apache--2.0-111111?style=flat-square" alt="Licence: MIT OR Apache-2.0"></a>
<a href="#benchmarks"><img src="docs/assets/hero.svg" width="880" alt="gpress archive: 60.9% of the original size on 907 public test PDFs, against 76.3% for the best of xz, Brotli and Zstandard. Normal PDF: 85.0% of the input on the 90 files gpress rewrites from a hard 120-file sample, against 87.2% for qpdf 11.9.0 at its strongest lossless setting. Web module: 268 KB of gzipped WebAssembly for normal PDFs, 383 KB with the archive, the same output as the native library."></a>
<strong>Archives about 20% smaller than the best of xz, Brotli and Zstandard</strong><br> <strong>Normal PDFs 2.5% smaller in total than qpdf 11.9.0's strongest lossless output, on the files gpress rewrites</strong><br> <strong>Every effort level lossless · 268 KB gzipped in the browser for normal PDFs, 383 KB with the archive</strong><br> <sub>Archive: 907 public test PDFs, format 9, effort 100, built with Lepton. Normal PDF: totals on the 90 files gpress rewrites from a hard 120-file sample, default effort. One thread. Browser: the gzip sizes of the two WebAssembly modules, as recorded in bench/sizes.json on 2026-10-22, before archive format 9. <a href="#benchmarks">Details</a>.</sub>
<sub><a href="#quick-start">Quick start</a> · <a href="#install">Install</a> · <a href="#use-it-from-code">From code</a> · <a href="#benchmarks">Benchmarks</a> · <a href="docs/reference.md">Reference</a> · <a href="SECURITY.md">Security</a></sub>
gpress is a library written in Rust, with C and WebAssembly bindings and a command-line tool. It makes two kinds of output, and checks each one before it returns it, except the archive's rare fallback and a file stored as it is (below).
[!IMPORTANT] Version 0.1.0, in active development: the API may still change, and nothing is published to crates.io or npm yet, so everything below builds from this repository. The archive format is version 9: archives from earlier development versions are refused, except one a format-8 build stored uncompressed, which still restores. CI builds and tests on Linux (x86-64).
| Normal PDF | gpress archive (.gpz) | |
|---|---|---|
| Call | optimize | compress / expand (pack / unpack on the command line) |
| Takes | a PDF | any file, a PDF above all |
| Gives | a smaller PDF that standard readers open, with every reachable object's value preserved (objects the trailer can't reach, directly or through other objects, are dropped) | an archive that restores the original byte for byte |
| How | streams compressed again with gpress's Flate encoders, a better PNG predictor for images, optimal Huffman tables for JPEGs (the same coefficients), CCITT fax images as JBIG2 (the same pixels), objects in compressed object streams | a PDF-aware context-mixing model |
| Checked | the output is read back and compared with the input before it is returned; anything uncertain is left as it was | the archive is restored and compared before it is returned; if that check ever fails, gpress writes a simpler model-only archive instead (not checked again). Either way the file is stored as it is when that is smaller. expand checks every archive's restored length and a 128-bit SHA-256 prefix against its header |
| On bad input | a file it can't safely touch comes back unchanged, with the reason | a damaged archive is refused with an error, or restored exactly when the damage misses what it needs; it is never restored wrongly |
gpress optimize in.pdf out.pdf # a smaller PDF that standard readers open
gpress optimize in.pdf out.pdf --quick # the same at effort 0, the fast path
gpress pack in.pdf in.gpz # the gpress archive (works on any file)
gpress unpack in.gpz back.pdf # the original, byte for byte
optimize prints one tab-separated line: the input, SMALLER or UNTOUCHED, bytes in, bytes out, seconds and the details. pack prints the input, bytes in, bytes out, seconds, then PACKED or STORED with the archive's statistics. For tests/data/format-v9.pdf, copied to in.pdf:
in.pdf SMALLER 8791 4955 0.15 objects=6 streams=2 reencoded=2 kept=0 objstms=1 dropped=1 jpegs_retabled=0 fax_to_jbig2=0
in.pdf 8791 6963 0.10 PACKED streams=3 rebuilt=3 rebuilt_bytes=7909 lepton=0 type1=0 fax=0 decrypted=0 decoded_bytes=54664
A file optimize can't make smaller or can't safely touch (a signed file, PDF/A, a file with earlier revisions, an encrypted file that needs a password, a damaged one) is written to out.pdf unchanged, with UNTOUCHED and the reason.
gpress without arguments lists every option, and the engine's measuring commands (deflate, inflate, batch, jpeg, fax-decode, expand (it decodes streams; restoring an archive is unpack), ...) used for checks and benchmarks; the comments at the top of src/bin/gpress.rs name them. For optimize, pack and unpack the exit status is 0 when the call worked, 2 for a usage or file error, and otherwise the library's error code (for example 2 for a damaged archive, 6 when --time-budget ran out; the full list is in the same comments).
You need a recent stable Rust (rustup); CI uses the latest stable release.
The command-line tool
cargo install --locked --git https://github.com/soumyadebroy3/gpress gpress
Add --features existing to also use Lepton (Apache-2.0) for JPEG streams in the archive. An archive with parts Lepton made restores only with such a build.
git clone https://github.com/soumyadebroy3/gpress
cd gpress
cargo build --release # the tool is target/release/gpress
--features existing works here too.
[dependencies]
gpress = { git = "https://github.com/soumyadebroy3/gpress" }
cargo build --release -p gpress-c
This builds libgpress_c as a shared and a static library in target/release/ (libgpress_c.so and libgpress_c.a on Linux), with the header crates/gpress-c/include/gpress.h. On Linux:
cc -Icrates/gpress-c/include app.c target/release/libgpress_c.a -lpthread -ldl -lm -o app # static
cc -Icrates/gpress-c/include app.c -Ltarget/release -lgpress_c -o app # shared: run it with
LD_LIBRARY_PATH=target/release ./app # the library on its path
rustup target add wasm32-unknown-unknown
cd crates/gpress-wasm/js
npm ci
npm run build # dist/: gpress.js, gpress.d.ts, gpress.wasm, gpress-web.wasm
The build runs binaryen's wasm-opt on the modules when it is on PATH; install binaryen for the smallest modules.
--effort N (the effort option) runs from 0, the fastest, to 100, the smallest; the default is 100. Every level is lossless: they differ only in how hard the engine searches, and every level's output is checked the same way. The output is the same for any thread count.
<img src="docs/assets/normal-pdf.svg" width="880" alt="Normal PDF, bytes saved on the 90 files gpress rewrites from a hard 120-file sample: gpress effort 100 15.0%, effort 50 14.8%, effort 0 13.0%; qpdf 11.9.0 at its strongest lossless setting 12.8%.">
| Effort | What runs (normal PDF) | CPU time, first measured | CPU time, build 37bd3f3 | Output |
|---|---|---|---|---|
0 (--quick) | The fast path: our fast Flate encoder, image predictors chosen from an estimate, and the cheap steps (object streams, the cross-reference stream, JPEG tables, JBIG2) | 16.1 s | 16.8 s | 87.02% |
| 5 | The bridge: closer parses for images | 23.0 s | 86.70% | |
| 10 | The bridge, plus the strong encoder's quick settings for the streams that take the most room, within a budget that doubles every point | 40.7 s | 86.11% | |
| 15 | 111 s | 85.74% | ||
| 19 | 183 s | 85.59% | ||
| 20 | The strong encoder, its settings in steps of 20, the strongest that fits a work budget proportional to the file's size | 244 s | 85.43% | |
| 50 | 334 s | 176 s | 85.17% | |
| 100 (default) | The strong encoder at its strongest | 983 s | 512 s | 85.01% |
| qpdf 11.9.0, strongest lossless setting | For comparison | 25.3 s | 87.23% |
CPU time is for the whole 120-file sample (bench/sets/sample120.txt, 78.3 MB, a deliberately hard mix of the slowest, the largest and random files) on one thread; output is the total size as a share of the input on the 90 files the engine rewrites. For qpdf it counts the 85 outputs bench/verify_pdf.py finds the same as their input apart from the trailer's /ID, and the other 5 files at their input size; its setting is --object-streams=generate --compress-streams=y --recompress-flate --compression-level=9 --decode-level=specialized. On the 118 files qpdf can open (it refused 2 encrypted files) the comparison turns around: qpdf rewrites each of them, dropping earlier revisions, unreferenced objects and whatever it cannot read, while gpress returns the files it can't safely touch unchanged. Counting those as they are, an earlier build (ca8fa92) gave 90.2% of the input at effort 100 on those 118 files, against 74.7% for qpdf with object streams and Flate at level 9. The first measurement (builds 52a91f0 and 20f8e69) was made in October 2026 for the change that added the fast path, on a 4-core 2.1 GHz Xeon virtual machine. Later changes made the strong encoder faster. Build 37bd3f3 was measured again at efforts 0, 50 and 100, and at each effort its outputs total exactly the first run's bytes. It ran on a machine about 1.2 times slower by the benchmark's calibration, so the two time columns compare only roughly. At those three efforts the current code gives the same outputs; its one later change on this path, the JBIG2 decoder that reads fax images back (717bf45), was not timed. The other levels were not measured again: their times and outputs are the first run's. Both runs, with their builds and calibrations, are in bench/RESULTS.md. In the archive, data that already looks compressed is coded plainly below effort 50. The reference has the details.
use gpress::{Options, Outcome};
let opt = Options::new().effort(80).threads(4);
// A smaller PDF that standard readers open.
let r = gpress::optimize(&pdf_bytes, &opt)?;
match r.outcome {
Outcome::Smaller(_report) => std::fs::write("out.pdf", &r.data)?,
Outcome::Untouched(why) => println!("left as it was: {why}"),
}
// The gpress archive, and the original back from it.
let archive = gpress::compress(&pdf_bytes, &opt)?;
let original = gpress::expand(&archive.data, &opt)?;
assert_eq!(original, pdf_bytes);
optimize, compress and expand each run inside a panic guard, so a bug that panics never reaches the caller: it comes back as an error, usually Error::Internal, or gpress takes its fallback (after a failed restore check, for example). In a build with panic = "abort", such as the WebAssembly module below, it aborts. The engine's own types, public too for measuring (reference), have no guard.
Errors are a plain Error enum with a stable numeric code and a message without internal paths:
| Code | Meaning | Code | Meaning |
|---|---|---|---|
| 1 | input too large | 6 | time budget ran out |
| 2 | damaged archive | 7 | declared length over the limit |
| 3 | unsupported archive version | 8 | needs the existing library |
| 4 | internal | 9 | declared decoded total over the limit |
| 5 | invalid options | 10 | needs a part this build leaves out (building only what a product needs) |
Optimized.outcome says what happened: Smaller with a Report (objects, streams re-encoded and kept, object streams, JPEGs given new tables, fax images written as JBIG2, how the file is encrypted), or Untouched with the reason. Compressed carries the archive's statistics (streams rebuilt, through Lepton, Type 1 fonts opened, fax images, streams stored decrypted, decoded bytes, and stored when nothing could be made smaller).
gpress_options opt; gpress_options_default(&opt); opt.effort = 70;
gpress_result r = gpress_optimize(pdf, pdf_len, &opt);
if (r.code == GPRESS_OK) fwrite(r.output.data, 1, r.output.len, out);
else fprintf(stderr, "%s\n", r.message);
gpress_result_free(&r);
Every function is thread-safe and never reads outside the buffers it is given. It never aborts the process on bad input if the calling thread has enough stack (part of the work, which parses objects nested up to 256 deep by recursion, runs on that thread; a 128 KiB thread stack, musl's default, may be too small), and a bug that panics never reaches the host: the panic guard above applies. Like any Rust code it does abort if memory runs out, so set the size limits to what the host can afford. Every buffer it hands out is freed with gpress_result_free (or gpress_free / gpress_free_message, each given the field's address), which are safe on null and on a result already freed: a free clears what it freed, so freeing the same result twice, or a part before the whole result, is harmless by construction. An empty output is a null pointer with length 0. The options struct has the same fields as the Rust Options but store_budget, which stays at its default (fill it with gpress_options_default first; a size left at 0 means its default). crates/gpress-c/examples/roundtrip.c is a complete program, and bench/gpress_harness.c sends the security suite's hostile inputs through this API.
crates/gpress-wasm is a plain WebAssembly module (no bindgen layer) with a typed wrapper in js/gpress.ts:
import { Gpress } from "./dist/gpress.js"; // built in crates/gpress-wasm/js; not on npm yet
const g = await Gpress.load(fetch("/gpress.wasm")); // or gpress-web.wasm: no archive, smaller
// In Node, pass a file URL (or a path from the working directory) instead:
// await Gpress.load(new URL("./dist/gpress.wasm", import.meta.url))
const smaller = g.optimize(bytes, { effort: 70, jbig2: false }); // { data, outcome, reason? }
const archive = g.compress(bytes);
const original = g.expand(archive.data, { maxLenMiB: 512 }); // throws GpressError on a damaged archive
The module gives the same bytes as the native library (the web module too, for optimize; see Building only what a product needs), is single-threaded and has no clock (so no time budget: run it in a Web Worker to keep a page responsive and to end it when it has taken too long). It is built with panic = "abort" (wasm32-unknown-unknown aborts on a panic anyway), so the panic guard that turns a bug into Error::Internal natively catches nothing there: a bug, or a failed memory growth in the engine, traps the module, and the wrapper turns the trap into a GpressError with code 4 and marks the instance dead (load a fresh one). Sizes are 32-bit inside it: the wrapper refuses inputs of 2 GiB and more. Pages with a Content Security Policy need 'wasm-unsafe-eval' in its script-src.
A first load compiles the module (4 to 9 ms in Node for the module of PR #8, bench/RESULTS.md, Start-up; CI's WebAssembly job measures it again in every run). Gpress.compile(source) returns the compiled module, and Gpress.load(module) makes an instance from it without compiling again: compile once, then make one instance per worker (a module can be posted to a Web Worker) or a fresh one after a trap. Serve the file as application/wasm, with no parameters, so that it compiles while it downloads (with any other type it is compiled once it has arrived).
Everything a caller of optimize, compress and expand can tune is a field of Options, and every field is a flag of the command-line tool. The ones most callers need:
| Option | Flag | Default | What it does |
|---|---|---|---|
effort | --effort N, --quick | 100 | 0 (fastest) to 100 (smallest), see above. |
threads | --threads N | 0 | 0: every core the machine offers, at most 8; at most 64. The output is the same for any count. |
time_budget | --time-budget MS | none | Give up after this much wall time: an error and no output, the input unchanged. |
max_input | --max-input BYTES | 1 GiB | Larger inputs are refused at once. |
max_len | --max-len BYTES | 1 GiB | Expand: an archive declaring a larger original is refused before anything is decoded. |
max_decoded | --max-decoded BYTES | 256 MiB | Archive: the most decoded bytes the parts of one archive may claim, both ways. |
respect_conformance | --ignore-conformance | on | Normal PDF: leave PDF/A, PDF/X, signed, certified and usage-rights files untouched. |
jpeg_tables, jbig2, flate_over_jpeg | --no-jpeg-tables, --no-jbig2, --no-flate-over-jpeg | on | Normal PDF: turn single steps off. |
Every other option, the memory each limit bounds, the parts of the engine a build can leave out (--no-default-features --features ..., for example the smaller web build without the archive), how encrypted PDFs are handled and what each part of the engine does are in the reference.
Measured on 907 public test PDFs (130.5 MB: the pdf.js, pypdf, OCRmyPDF and pikepdf test suites, many of them deliberately broken or unusual), fetched and pinned by bench/fetch_corpus.sh, and on a deliberately hard 120-file sample of them (bench/sets/sample120.txt, 78.3 MB).
<img src="docs/assets/archive.svg" width="880" alt="Archive size as a share of the original on 907 public test PDFs: gpress 60.9%; the best of Brotli 11, xz -9e and Zstandard 19 file by file 76.3%; Brotli 11 76.5%; xz -9e 76.8%; Zstandard 19 --long 77.0%.">
| What | gpress | Compared with |
|---|---|---|
Archive, 907 files, effort 100, built with --features existing (Lepton) | 60.9% of the original size | the best of xz -9e, Brotli 11 and Zstandard 19 --long, file by file: 76.3% |
| Archive, the 120-file sample, effort 100, default build (no Lepton) | 60.0% | the best of the three, file by file: 74.5% |
| Normal PDF, the 90 files gpress rewrites from the sample, default effort | 85.01% of the input | qpdf 11.9.0, strongest lossless setting: 87.23% |
Every archive in those runs was restored by a separate process and compared byte for byte, and the competitors ran on the same files. All three rows are the current code: the archive rows are format 9, and the current code's normal-PDF outputs total exactly the bytes behind its row (see Effort levels, which also says how qpdf does when every file it opens is counted).
These come from the first full run (build d85cd7a), and bench/RESULTS.md says which build and machine each figure comes from. Later builds changed the normal-PDF output and the archive format, so they are not figures for the current code.
bench/benchmark.py measures any build.
Options, by the file's own size or by a fixed limit, decoding stops when data runs out, nothing a file claims sizes memory on its own, and no call to optimize, compress or expand panics across the library boundary. What no option bounds yet: cross-reference streams decode as far as the file's size allows, the PDF/A check reads metadata up to 16 MiB, and objects parsed from object streams take more memory than the bytes the limits count (see max_stream in the reference).cargo deny checks every dependency's licence and the RustSec advisories in CI.SECURITY.md has the threat model, the guarantees with where each is kept, how they are checked, and how to report a vulnerability privately.
Most of the engine is our own code. The parts that follow another project's code (zlib, libdeflate, the LZMA SDK) say so and keep its licence notice, as listed in [Code from other projects
hooks/register.ts 110 lines1import type { Register, TurnStepChunk } from 'claude-code'
2import { classify } from './classify'
3import { outgrown } from './escalate'
4
5type Decision = { task: string; model: string; reason: string }
6type Routed = { task: string; parentModel: string; escalated?: string; errors: number }
7
8export const register: Register = (on, options) => {
9 const enabled = options.enabled !== false
10 const cheapModel = String(options.cheapModel ?? 'haiku')
11 const maxEasyChars = Number(options.maxEasyChars ?? 2000)
12 const maxCheapSteps = Number(options.maxCheapSteps ?? 20)
13 const log: Decision[] = []
14 // Subagents running on the cheap model, by agent id.
15 const routed = new Map<string, Routed>()
16 let sent = 0
17 let kept = 0
18 let escalated = 0
19
20 const note = (d: Decision) => {
21 log.push(d)
22 if (log.length > 50) log.shift()
23 }
24
25 const statusLine = () => `cheap-route: ${sent} → ${cheapModel}, ${escalated} switched up`
26
27 const escalate = (r: Routed, reason: string) => {
28 r.escalated = reason
29 escalated++
30 note({ task: r.task, model: 'session', reason: `switched up: ${reason}` })
31 }
32
33 on('session.start', async ($, e, next) => {
34 await $.command.register({
35 name: 'cheap-route',
36 description: 'Show which subagent tasks ran on the cheaper model',
37 })
38 return next(e)
39 })
40
41 on('command.run', { command: 'cheap-route' }, () => {
42 const head = enabled
43 ? `cheap-route: ${sent} read-only task(s) sent to ${cheapModel}, ${escalated} switched up to the session model, ${kept} kept on the session model.`
44 : 'cheap-route is off (turn it on in /config).'
45 const rows = log.slice(-15).map(d => `- ${d.model.padEnd(8)} ${d.task} (${d.reason})`)
46 return { text: [head, ...rows].join('\n') }
47 })
48
49 on('agent.spawn', async ($, e, next) => {
50 // Leave alone: routing off, an explicit model choice, forks and resumes
51 // (both keep their model), and a parent already on the cheap model.
52 if (!enabled || e.model || e.fork || e.resume || (e.parentModel ?? '').includes(cheapModel)) {
53 return next(e)
54 }
55 const v = classify(e.prompt, e.subagentType, maxEasyChars)
56 const task = (e.description || e.prompt).replace(/\s+/g, ' ').slice(0, 60)
57 if (!v.easy) {
58 kept++
59 note({ task, model: 'session', reason: v.reason })
60 return next(e)
61 }
62 sent++
63 note({ task, model: cheapModel, reason: v.reason })
64 $.ui.status(statusLine())
65 const started = await next({ ...e, model: cheapModel })
66 if (started.agentId) {
67 routed.set(started.agentId, { task, parentModel: e.parentModel, errors: 0 })
68 if (routed.size > 200) routed.delete(routed.keys().next().value!)
69 }
70 return started
71 })
72
73 // Each model request of a cheap subagent is held until it is whole. If it
74 // shows the task has outgrown the cheap model, it is thrown away and the
75 // same request goes to the session's model, which keeps the rest of the task.
76 on('turn.step', async function* ($, e, next) {
77 const r = e.agentId === undefined ? undefined : routed.get(e.agentId)
78 if (!r) return yield* next(e)
79 if (r.escalated) return yield* next({ ...e, model: r.parentModel })
80
81 const stream = next(e)
82 const held: TurnStepChunk[] = []
83 let step = await stream.next()
84 while (!step.done) {
85 held.push(step.value)
86 step = await stream.next()
87 }
88 const result = step.value
89 const reason = outgrown(result, e.index, maxCheapSteps)
90 if (!reason) {
91 for (const chunk of held) yield chunk
92 return result
93 }
94 escalate(r, reason)
95 $.ui.status(statusLine())
96 return yield* next({ ...e, model: r.parentModel })
97 })
98
99 // Two failed tool calls in a cheap subagent also hand it to the session model.
100 on('tool.call', async ($, e, next) => {
101 const result = await next(e)
102 const r = e.agentId === undefined ? undefined : routed.get(e.agentId)
103 if (r && !r.escalated && result.deny === undefined && result.isError === true && ++r.errors >= 2) {
104 escalate(r, 'tool calls kept failing')
105 $.ui.status(statusLine())
106 }
107 return result
108 })
109}
110hooks/classify.ts 37 lines1// Decides from a subagent's prompt whether the task is read-only enough for a
2// cheaper model. Only lookups, summaries, status checks and reads qualify: any
3// sign of writing (code, tests, docs, commits) or judgement keeps the session's
4// model, and so does a prompt with no clear read-only signal.
5
6export type Verdict = { easy: boolean; reason: string }
7
8const HARD: Array<[RegExp, string]> = [
9 [/\b(edit|modify|change|update|rename|fix|typo|bump|write|add|create|delete|remove|replace|insert|apply|patch|commit|push|merge|rebase|move|format|reformat)\w*/i, 'changes files'],
10 [/\b(implement|build|design|architect|prototype)\w*/i, 'builds something'],
11 [/\b(debug|root[- ]cause|diagnos\w*|investigat\w*|why does|why is|race condition|deadlock)\b/i, 'debugging'],
12 [/\bfix(es|ing)?\b.*\b(bug|crash|fail\w*|regression|leak|error)\b/i, 'fixes a bug'],
13 [/\b(optimi[sz]\w*|speed ?up|faster|performance|profil\w*|benchmark\w*)\b/i, 'performance work'],
14 [/\b(refactor\w*|rewrite|migrat\w*|port (it|this|the))\b/i, 'restructures code'],
15 [/\b(review|audit|security|vulnerab\w*|verify (that|the|correct)\w*|prove)\b/i, 'needs judgement'],
16 [/\b(write|add) (unit |integration )?tests?\b/i, 'writes tests'],
17 [/\b(plan|strategy|trade-?offs?|compare approaches|propose)\b/i, 'planning'],
18]
19
20const EASY: Array<[RegExp, string]> = [
21 [/^\s*(find|locate|search|grep|look ?up|list|enumerate|count)\b/i, 'lookup'],
22 [/\b(find|locate|search for|grep for|look up|list (all|every|the))\b/i, 'lookup'],
23 [/\b(summari[sz]e|summary of|tl;?dr|skim)\b/i, 'summary'],
24 [/\b(check|report) (the )?(status|whether|if|state)\b/i, 'status check'],
25 [/\b(git (status|log|diff --stat)|ci status|is .* (green|passing|merged))\b/i, 'status check'],
26 [/\b(read|open|print|show|fetch|extract) (the |all |every )?(file|files|contents?|lines?|log|output|value)\b/i, 'read'],
27 [/\b(which files?|where is|what (is|are) the (name|path|version|value))\b/i, 'lookup'],
28]
29
30export const classify = (prompt: string, subagentType: string, maxEasyChars: number): Verdict => {
31 if (prompt.length > maxEasyChars) return { easy: false, reason: 'long prompt' }
32 for (const [re, why] of HARD) if (re.test(prompt)) return { easy: false, reason: why }
33 if (subagentType === 'Explore') return { easy: true, reason: 'read-only search agent' }
34 for (const [re, why] of EASY) if (re.test(prompt)) return { easy: true, reason: why }
35 return { easy: false, reason: 'no easy signal' }
36}
37hooks/escalate.ts 32 lines1// Decides, from one step of a cheap-model subagent, whether the task has
2// outgrown the cheap model. Once it has, the subagent finishes on the
3// session's model, starting by redoing the step that tripped the check.
4
5import type { TurnStepResult } from 'claude-code'
6
7const WRITING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
8
9// Shell commands that change the checkout or the outside world.
10const MUTATING_SHELL =
11 /(^|[;&|]\s*)(git\s+(commit|push|merge|rebase|reset|checkout|apply|am|cherry-pick|stash|tag)|rm|mv|cp|mkdir|touch|tee|patch|cargo\s+(fmt|fix|add|update|publish)|npm\s+(install|i|publish|version)|pip\s+install)\b|sed\s+-i|perl\s+-pi|\s>{1,2}\s*[^&\s]/
12
13const UNSURE =
14 /\b(i'?m not sure|not certain|i (could|can)n'?t (find|determine|tell)|could not (find|determine|locate)|unable to|i don'?t know|unclear (whether|if|what)|needs? (more|further) (investigation|analysis))\b/i
15
16export const outgrown = (r: TurnStepResult, index: number, maxCheapSteps: number): string | undefined => {
17 if (r.stopReason === null || r.stopReason === 'refusal' || r.stopReason === 'max_tokens') {
18 return `cheap model stopped (${r.stopReason ?? 'no response'})`
19 }
20 for (const use of r.toolUses) {
21 if (WRITING_TOOLS.has(use.name)) return `wants to change files (${use.name})`
22 if (use.name === 'Bash') {
23 const command = String((use.input as { command?: unknown } | null)?.command ?? '')
24 if (MUTATING_SHELL.test(command)) return 'wants to run a command that changes things'
25 }
26 }
27 if (r.toolUses.length === 0 && r.answer.trim().length === 0) return 'empty answer'
28 if (UNSURE.test(r.answer)) return 'cheap model was unsure'
29 if (index >= maxCheapSteps) return `task ran past ${maxCheapSteps} steps`
30 return undefined
31}
32