SLOPSHOPPER

cheap-route

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…

newguardcommandstatusagents
v0.1.0Apache-2.0updated 2026-10-15soumyadebroy3/gpress/.claude/mods/cheap-route
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cheap-route
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cheap-route ⎿ cheap-route: cheap-route: 0 read-only task(s) sent to haiku, 0 switched up to the session model, 0 kept on the session mode ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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 &middot; 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> &middot; <a href="#install">Install</a> &middot; <a href="#use-it-from-code">From code</a> &middot; <a href="#benchmarks">Benchmarks</a> &middot; <a href="docs/reference.md">Reference</a> &middot; <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).

Two outputs

Normal PDFgpress archive (.gpz)
Calloptimizecompress / expand (pack / unpack on the command line)
Takesa PDFany file, a PDF above all
Givesa 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
Howstreams 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 streamsa PDF-aware context-mixing model
Checkedthe output is read back and compared with the input before it is returned; anything uncertain is left as it wasthe 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 inputa file it can't safely touch comes back unchanged, with the reasona damaged archive is refused with an error, or restored exactly when the damage misses what it needs; it is never restored wrongly

Quick start

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

Install

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 levels

--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%.">

EffortWhat runs (normal PDF)CPU time, first measuredCPU time, build 37bd3f3Output
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 s16.8 s87.02%
5The bridge: closer parses for images23.0 s86.70%
10The bridge, plus the strong encoder's quick settings for the streams that take the most room, within a budget that doubles every point40.7 s86.11%
15111 s85.74%
19183 s85.59%
20The strong encoder, its settings in steps of 20, the strongest that fits a work budget proportional to the file's size244 s85.43%
50334 s176 s85.17%
100 (default)The strong encoder at its strongest983 s512 s85.01%
qpdf 11.9.0, strongest lossless settingFor comparison25.3 s87.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 it from code

Rust

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:

CodeMeaningCodeMeaning
1input too large6time budget ran out
2damaged archive7declared length over the limit
3unsupported archive version8needs the existing library
4internal9declared decoded total over the limit
5invalid options10needs 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).

Options

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:

OptionFlagDefaultWhat it does
effort--effort N, --quick1000 (fastest) to 100 (smallest), see above.
threads--threads N00: every core the machine offers, at most 8; at most 64. The output is the same for any count.
time_budget--time-budget MSnoneGive up after this much wall time: an error and no output, the input unchanged.
max_input--max-input BYTES1 GiBLarger inputs are refused at once.
max_len--max-len BYTES1 GiBExpand: an archive declaring a larger original is refused before anything is decoded.
max_decoded--max-decoded BYTES256 MiBArchive: the most decoded bytes the parts of one archive may claim, both ways.
respect_conformance--ignore-conformanceonNormal 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-jpegonNormal 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.

Benchmarks

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%.">

WhatgpressCompared with
Archive, 907 files, effort 100, built with --features existing (Lepton)60.9% of the original sizethe 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 effort85.01% of the inputqpdf 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.

  • Normal PDF on all 907 files, maximum effort: 16.0% smaller on the 697 files it rewrote; qpdf (object streams, Flate at level 9) made the same files 9.3% smaller. Every one of those 697 outputs was compared with its original by tools we didn't write: qpdf, pikepdf, PDFium, Poppler and pdf.js.
  • Flate streams on their own, maximum effort (7,280 distinct streams from the test files, each decoding to at most 4 MiB): gpress 41.05 MB; Zopfli (15 iterations) 0.67% larger, libdeflate 12 1.40% larger, zlib 9 6.95% larger.

bench/benchmark.py measures any build.

Safety

  • No data loss. Every transformation of a normal PDF is checked before it is used, and anything uncertain leaves that stream or file untouched. A damaged archive is refused with an error, or restored exactly when the damage misses what it needs; it is never restored wrongly.
  • Untrusted input. Sizes are bounded by 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).
  • Deterministic. The same bytes for any thread count and with or without a time budget (tested); the archive's context-mixing model uses integer arithmetic only, its constants are fixed in code and never read from the environment, and the WebAssembly module is checked against the native library's bytes.
  • Licences. Libraries under MIT, Apache-2.0, BSD, ISC, Zlib, Unicode or MPL-2.0 only (the licence rule): 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.

What's inside

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

Source 3 files
hooks/register.ts 110 lines
1import 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}
110
hooks/classify.ts 37 lines
1// 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}
37
hooks/escalate.ts 32 lines
1// 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