SLOPSHOPPER

seek

Has Claude look around your code in few, lean steps. Every request re-reads the whole conversation, and whatever Claude reads stays in it for every later…

newguardpromptagents
v0.3.3MITupdated 2026-10-16HarzerHeribert/claude-modpack/seek
A shopper browsing a rack in a slop shop
README

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">

<h3>Install in Claude Code</h3>

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.

Release License: MIT Claude Code 2.1.296 730 graded runs, all correct 8 mods Lab notes

Claude Code with fewer wasted steps, and a clear view of what it's doing.

Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.

Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions

<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">

Why it saves

Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.

The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.

What you get

Always know where your plan stands

<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">

One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.

These are the statusline, eta and smart-compact mods.

Several changes in one step, and you can still undo them

<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">

Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.

That's the cells mod.

A cheaper model builds what you approved

<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">

When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.

That's the delegate mod.

Feature work goes to the cheaper model by itself (opt-in)

A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.

That's the route mod.

Straight answers about your code

When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.

That's the lsp-plus mod.

Fewer, leaner steps when Claude looks around

Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.

That's the seek mod.

You stay in control

<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">

/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).

That's the modpack plugin, which installs the other eight.

Install

In Claude Code, type:

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:

/modpack:setup

It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.

By hand instead: SETUP.md has every step.

Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.

Honest numbers

We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.

Cost per jobWithoutWith the pack
In a long session (Claude has already read the whole project)$0.44$0.29−33%
Bigger jobs in a long session (a feature, tests, a rename)$0.91$0.57−37%
In a fresh session$0.37$0.35−4%
Bigger jobs in a fresh session$0.55$0.51−8%, and 15% faster
Quick questions in a fresh session$0.23$0.24+2%, about even
  • Where it doesn't help: quick questions in a brand-new session. The pack's tools come along with every step, and a three-step lookup has little to save: 5% more one day, 4% less the next, about even.
  • Writing tests: the pack alone saves nothing there (−2%, 12 runs each way): every step waits on the last test run. route hands it to the worker instead (−36% to −46%, below).
  • Nothing got worse: all 224 runs behind this table produced a correct result, with the pack and without.
  • The 95% ranges: long session −39% to −27%; fresh −7% to −1%; bigger jobs fresh −12% to −3%; quick questions −1% to +6%. The long session is two runs per job each day, and the days differ: −26% on one, −40% on the other, mostly from the feature (plain needed 12 requests one day, 16 the next).
  • The same on other code: on a TypeScript library (chrono) the pack cost 7.6% less (−12% to −3%) and took fewer steps on all eight jobs. On 30 real bug reports from SWE-bench Verified it fixed 30 against 29, with 23% fewer steps; the cost, −12%, is not certain at one attempt per bug (−33% to +12%).
  • A plan handed to a cheaper model: −22% on a feature, −26% on tests, against Opus doing it alone with the pack (16 runs, all correct).
  • With route turned on: a further −14% on click's eight jobs (route 0.1). With 0.6.0's verified hand-offs, against the pack in the same batch on TypeScript: a feature −51% and 42% faster, writing tests −36%; Claude itself takes 2 steps instead of 6 to 11 (24 runs, all correct).
  • Corrected 16 Oct: worker costs were undercounted. A worker's transcript records its output as each response began, and our bench counted it from there, so every number with a hand-off in it came out too good. Corrected from Claude Code's own totals: a planned feature −22% (was −27%), tests −26% (was −33%), route on click −14% (was −17%), the TypeScript feature −51% (was −57%). Every saving is still a saving, a smaller one. The table above has no hand-offs and did not change. Each claim, old and new: CORRECTION-1016.md.
  • With seek (new in 0.6.0, measured against the pack rather than plain Claude Code, so not in the table above): −12% fresh on click, −16% on TypeScript, −10% to −16% in long sessions (seek, below).
  • Two days, two versions: the 13 October runs used the pack as of 0.3.0 and Claude Code 2.1.296, the 15 October runs 0.4.0 and 2.1.299. What changed in the pack between them (on/off switches, setup, messages shown when a worker is refused) never reached the model in these runs: its first request was 26.7k tokens plain and 30.6k with the pack on both days. The second day's plain runs were done twice, because the first try had the pack installed by mistake: lab notes 8 tells how we caught it.

<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">

<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">

<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">

How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.

Questions

Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.

Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).

Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.

I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.

Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.

How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.

Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.

What does it cost? Nothing. It's free and open source.

The mods in depth

One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:

⏵⏵ auto mode on       Opus 5.5 xhigh   412k ━━━━━━──────   5h 34% ↺2h14   7d 12% ↺3d   ● ready · cold in 44m
  • Its own segments: the model and its effort, the context against the auto-compact point (a highlight sweeps along it while Claude works), and how much of the plan's 5-hour and 7-day windows is used, with when each resets: quiet under half, plain past it, amber from 80%.
  • Other mods' segments: a mod calls $.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off.
  • Narrow terminals drop the least important first: the model, then the windows' resets, the windows, the gauge, and another mod's countdown before its own words.
  • Published pages: a pill on the left lists the pages this session published with the Artifact tool.
  • Phone and IDE: where no line is drawn, the same line is the session's status text, in a session the phone or IDE hosts. A terminal session watched over Remote Control (/rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.
OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
placementlinetail: appended dim to Claude Code's hint. band: above the prompt. off.
limitsalwayshalf: a plan window only once it is half used. off: never.
animatetrueThe highlight along the gauge.

An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:

OptionCost
Let it expire, re-cache on return$3.20
One keep-alive (re-read the cached prefix; a read resets the TTL)$0.09
Compact before it expires~$0.87, plus detail lost to the summary

A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:

  1. Sends keep-alives while they cost less than compacting would, counting lost detail as compactPenaltyUsd. It stops after maxIdleHours.
  2. Then compacts while the cache is still warm.
  3. Leaves small sessions alone: below minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.

It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.

On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.

OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
strategysmartcompact: compact at the first expiry. keepalive: never compact. off: status line only.
cacheTtlauto1h or 5m to override detection.
minSavingsUsd0.25Below this re-cache cost, the cache is left to expire.
compactPenaltyUsd0.5Higher keeps the cache warm longer before compacting.
minCompactTokens100000Never compacts a smaller context.
maxIdleHours6Stops keep-alives after this long idle.
compactInstructionsemptyPassed to idle compactions, like /compact <text>.

Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.

Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.

  1. Where it reaches. For each focus entry the mod asks the language server for references and callers (with lsp-plus, the docs that mention it come along) and tells the worker.
  2. The worker. A subagent, read-only for read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.
  3. The evidence. The diff against a git snapshot taken at the start, however the worker edited; the check command, run by the mod itself; references outside the changed files; what the work cost against the same steps on the main model.
  4. The review. The main model judges the diff and the check, not the report.
  5. The log. Each hand-off goes to ~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.

Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.

A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d

Source 5 files
hooks/register.ts 156 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { OrderClock, budgetTokens, orderText } from './economy'
4import { hitMap, outlinable } from './outline'
5import type { FileHits } from './outline'
6import { MAX_OUTPUT_LINES, candidatePaths, groupHits, isEmpty, normalize, parseBash, parseHits, parseOutput, parseToolInput } from './search'
7import type { BashSearch, Group, Search } from './search'
8import { SUBAGENT_ORDER, reaches, withSetting } from './subagents'
9
10type Engine = EngineInterface
11
12/** The person's own prompts: typed, from the phone, or a `claude -p` turn. */
13const PERSON = new Set(['composer', 'bridge', 'sdk'])
14/** Shorter than this a prompt is a reply ("yes", "go on"), not new work to look around for. */
15const MIN_PROMPT = 20
16/** A search over more files than this is a survey, not a place to look; files read to find the hits at most. */
17const MAX_FILES = 30
18const MAX_CANDIDATES = 60
19/** Below this many tokens no map row fits that says anything. */
20const MIN_BUDGET = 30
21
22let minContext = 150_000
23let orderFrom = 0
24let later = 140
25let saveRate = 0.1
26let ordering = true
27let mapping = false
28// Whether a subagent's task carries the order: the person's prompts, which carry it otherwise, never reach a subagent.
29let subagents = true
30
31const number = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
32
33/** The context the next request re-reads: the last response's input, else the local estimate /context makes. */
34async function contextTokens($: Engine): Promise<number> {
35  const plain = await $.session.usage().catch(() => undefined)
36  const t = plain?.context.tokens
37  if (typeof t === 'number' && t > 0) return t
38  const est = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
39  return est?.context.breakdown?.totalTokens ?? 0
40}
41
42/** A file's text; undefined where it cannot be read or is binary. */
43async function readText($: Engine, cwd: string, path: string): Promise<string | undefined> {
44  const text = await $.fs.read(path.startsWith('/') ? path : normalize(path, cwd)).catch(() => undefined)
45  return typeof text === 'string' && !text.includes('\0') ? text : undefined
46}
47
48/** What a map may cost here, in tokens; 0 where the session is small or nothing pays. */
49async function budget($: Engine): Promise<number> {
50  const context = await contextTokens($)
51  if (context < minContext) return 0
52  const b = budgetTokens(context, saveRate, later)
53  return b >= MIN_BUDGET ? b : 0
54}
55
56function mapOf(groups: readonly Group[], files: Map<string, string>, tokens: number): string | undefined {
57  if (groups.length === 0 || groups.length > MAX_FILES) return undefined
58  const hits: FileHits[] = []
59  for (const g of groups) {
60    const text = files.get(g.path)
61    if (text !== undefined) hits.push({ path: g.path, lines: g.lines, text })
62  }
63  return hits.length === 0 ? undefined : hitMap(hits, tokens)
64}
65
66/** The map for a Bash command line with searches in it: the hits are read from its output. */
67async function mapBash($: Engine, b: BashSearch, text: string): Promise<string | undefined> {
68  if (isEmpty(text) || text.split('\n').length > MAX_OUTPUT_LINES) return undefined
69  const tokens = await budget($)
70  if (tokens === 0) return undefined
71  const sessionCwd = await $.session.cwd()
72  const cwd = b.cd === undefined ? sessionCwd : normalize(b.cd, sessionCwd)
73  const only = b.searches.length === 1 && b.searches[0]?.operands.length === 1 ? (b.searches[0]?.operands[0] as string).replace(/^(\.\/)+/, '') : undefined
74  const paths = new Set(candidatePaths(text).filter(outlinable))
75  if (only !== undefined && outlinable(only)) paths.add(only)
76  if (paths.size === 0 || paths.size > MAX_CANDIDATES) return undefined
77  const files = new Map<string, string>()
78  await Promise.all([...paths].map(async p => {
79    const t = await readText($, cwd, p)
80    if (t !== undefined) files.set(p, t)
81  }))
82  const parsed = parseOutput(text, p => files.has(p), only !== undefined && files.has(only) ? only : undefined)
83  if (parsed.cut || parsed.hits.length === 0) return undefined
84  return mapOf(groupHits(parsed.hits, []), files, tokens)
85}
86
87/** The map for a Grep tool search in content mode. */
88async function mapGrep($: Engine, search: Search, text: string): Promise<string | undefined> {
89  if (isEmpty(text)) return undefined
90  const hits = parseHits(text, search.operands)
91  if (hits === undefined || hits.length === 0) return undefined
92  const groups = groupHits(hits, search.operands).filter(g => outlinable(g.path))
93  if (groups.length === 0 || groups.length > MAX_FILES) return undefined
94  const tokens = await budget($)
95  if (tokens === 0) return undefined
96  const cwd = await $.session.cwd()
97  const files = new Map<string, string>()
98  await Promise.all(groups.map(async g => {
99    const t = await readText($, cwd, g.path)
100    if (t !== undefined) files.set(g.path, t)
101  }))
102  return mapOf(groups, files, tokens)
103}
104
105type Result = { deny?: string; text?: string; isError?: boolean; context?: string[] }
106
107async function attach<R>(result: R, run: ((text: string) => Promise<string | undefined>) | undefined): Promise<R> {
108  const r = result as unknown as Result
109  if (run === undefined || r.deny !== undefined || r.isError) return result
110  const note = await run(r.text ?? '').catch(() => undefined)
111  return note === undefined ? result : ({ ...r, context: [...(r.context ?? []), note] } as unknown as R)
112}
113
114export const register: Register = (on, options) => {
115  if (options.enabled === false) return
116  ordering = options.order !== false
117  mapping = options.map === true
118  subagents = options.subagents !== false
119  minContext = number(options.minContext, 150_000)
120  orderFrom = number(options.orderFrom, 0)
121  later = number(options.laterRequests, 140)
122  saveRate = Math.min(1, number(options.saveRate, 0.1))
123  const clock = new OrderClock()
124
125  // The person's prompt: the order to look around in few, lean steps, once per stretch of context.
126  on('prompt.submit', async ($, e, next) => {
127    if (!ordering || !PERSON.has(e.origin?.kind as string)) return next(e)
128    const text = e.text.trim()
129    if (text.length < MIN_PROMPT || text.startsWith('/')) return next(e)
130    const context = await contextTokens($).catch(() => 0)
131    if (!clock.due(context, orderFrom)) return next(e)
132    clock.mark(context)
133    return next({ ...e, context: [...(e.context ?? []), orderText(context)] })
134  }).catch(($, e, next) => next(e))
135
136  // A subagent's task: the same order, in the person's name. A subagent starts on a fresh context, so it comes where
137  // the person's prompts get it from the first one (orderFrom 0).
138  on('agent.spawn', async ($, e, next) => {
139    if (!subagents || !ordering || orderFrom > 0 || !reaches(e)) return next(e)
140    return next({ ...e, prompt: withSetting(e.prompt, SUBAGENT_ORDER) })
141  }).catch(($, e, next) => next(e))
142
143  // A search's result in a long session: where its hits sit, within what a saved request is worth.
144  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
145    const r = await next(e)
146    const b = mapping && typeof e.command === 'string' ? parseBash(e.command) : undefined
147    return attach(r, b === undefined ? undefined : text => mapBash($, b, text))
148  }).catch(($, e, next) => next(e))
149
150  on('tool.call', { tool: /^Grep$/ }, async ($, e, next) => {
151    const r = await next(e)
152    const search = mapping ? parseToolInput(e as unknown as Record<string, unknown>) : undefined
153    return attach(r, search === undefined ? undefined : text => mapGrep($, search, text))
154  }).catch(($, e, next) => next(e))
155}
156
hooks/economy.ts 71 lines
1// What a step of looking around costs in a long session, and the order that says so. Pure: no I/O.
2//
3// From 979 of the person's sessions (pilot/explore-mining, round 3): every model request re-reads the whole context
4// (Opus: $0.20 per million tokens from the cache), and whatever a request adds stays in the context until it is
5// compacted: a median of 143 later requests (interactive) re-read it, so a token added costs $8/M to write and
6// 143 x $0.20/M = $28.6/M more to carry, about $37/M over its life. Replayed against the recorded next steps, seek
7// 0.2's code windows (46-85 lines) held the next read in 3-8% of searches and cost more than they saved at every
8// context size; that is why 0.3 attaches only a few lines, only where a request is dear, and orders lean looking.
9
10/** List prices in dollars per million tokens (Opus, as pilot/delegate-bench/bench.py prices them). */
11export const PRICE = { read: 0.2, write: 8.0, output: 20.0 }
12/** The median output of an exploration request (286-333 tokens in the sessions above). */
13export const OUTPUT_PER_REQUEST = 300
14
15/** What one more model request costs at this context, in millionths of a dollar per token-million (so: per token, x1e6). */
16export function requestWorth(context: number): number {
17  return context * PRICE.read + OUTPUT_PER_REQUEST * PRICE.output
18}
19
20/** What one token added to the context costs over its life: written once, re-read by `later` requests. */
21export function tokenLife(later: number): number {
22  return PRICE.write + PRICE.read * later
23}
24
25/**
26 * The most tokens an attachment may have so that it pays: the request it saves `saveRate` of the time is worth at
27 * least what its tokens cost over their life. 0 where nothing pays.
28 */
29export function budgetTokens(context: number, saveRate: number, later: number): number {
30  if (!(context > 0) || !(saveRate > 0)) return 0
31  return Math.max(0, Math.floor((saveRate * requestWorth(context)) / tokenLife(Math.max(0, later))))
32}
33
34/** Tokens in a text of code and paths, as the tokenizer counts them roughly (3.5 characters a token). */
35export function tokensOf(text: string): number {
36  return Math.ceil(text.length / 3.5)
37}
38
39/**
40 * The order beside the person's prompt in a long session. An order in the person's name is followed (24 of 24
41 * hand-off orders); advice from a tool is not (0 of 45). It names the person's setting, which is what it is.
42 */
43export function orderText(context: number): string {
44  const k = Math.round(context / 1000)
45  return (
46    `The person's setting for looking around in this session (about ${k}k tokens of context so far): every response you make re-reads all of it, ` +
47    `and whatever you read stays in it, re-read by every later response until it is compacted. So look around in few, lean steps: ` +
48    `put the searches and reads you can already name into one command; when a search shows where to look, read those places in the same step; ` +
49    `read only the definitions or lines you need (grep -n first, then a range around them), not whole files or long ranges; ` +
50    `and do not read again what this session already holds unless it changed.`
51  )
52}
53
54/**
55 * When the order is due: once the context is past `from`, then again each time it has grown by another `every`,
56 * and after a compaction (the context fell to under half) once it is past `from` again. The order stays in the
57 * context until it is compacted, so it needs no repeating on every prompt.
58 */
59export class OrderClock {
60  private last: number | undefined
61  due(context: number, from: number, every = 150_000): boolean {
62    if (!(context >= from) || context <= 0) return false
63    if (this.last === undefined) return true
64    if (context < this.last / 2) return true
65    return context >= this.last + Math.max(every, 1)
66  }
67  mark(context: number): void {
68    this.last = context
69  }
70}
71
hooks/outline.ts 221 lines
1// Where a search's hits sit: the definitions (functions, methods, classes) that hold them, with their line ranges,
2// from the file's own text. A few tokens per definition instead of the code itself: Claude learns which function
3// each hit is in and where it ends, so it can read exactly that, or nothing, in its next step. Pure: no I/O.
4
5import { tokensOf } from './economy'
6
7export type Def = { name: string; qualified: string; start: number; end: number }
8
9const PYTHON = /\.pyi?$/
10const BRACES = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|go|rs|java|kt|kts|swift|c|cc|cpp|cxx|h|hpp|cs|scala|php|dart)$/
11
12/** Whether seek can outline a file of this name. */
13export function outlinable(path: string): boolean {
14  return PYTHON.test(path) || BRACES.test(path)
15}
16
17const indentOf = (row: string) => {
18  let n = 0
19  for (const c of row) {
20    if (c === ' ') n++
21    else if (c === '\t') n += 4
22    else break
23  }
24  return n
25}
26
27/** A row without its string literals and its trailing comment (`#` or `//`), for counting brackets. */
28function code(row: string, hash: boolean): string {
29  const bare = row.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""')
30  const cut = hash ? bare.indexOf('#') : bare.indexOf('//')
31  return cut < 0 ? bare : bare.slice(0, cut)
32}
33
34const PY_DEF = /^(\s*)(?:async\s+def|def|class)\s+([A-Za-z_]\w*)/
35
36function pythonDefs(rows: readonly string[]): Array<Omit<Def, 'qualified'>> {
37  const out: Array<Omit<Def, 'qualified'>> = []
38  for (let i = 0; i < rows.length; i++) {
39    const m = PY_DEF.exec(rows[i] as string)
40    if (!m) continue
41    const indent = indentOf(m[1] as string)
42    // The header may run over several lines: it ends at the first ':' outside brackets. Anything after that ':' on
43    // its line is a one-line body (`def f(x) -> int: ...`, an overload stub; `class A: pass`).
44    let header: { line: number; oneLine: boolean } | undefined
45    let depth = 0
46    for (let j = i; j < rows.length && j < i + 40 && header === undefined; j++) {
47      const c = code(rows[j] as string, true)
48      for (let k = 0; k < c.length; k++) {
49        const ch = c[k] as string
50        if ('([{'.includes(ch)) depth++
51        else if (')]}'.includes(ch)) depth--
52        else if (ch === ':' && depth <= 0) {
53          header = { line: j, oneLine: c.slice(k + 1).trim() !== '' }
54          break
55        }
56      }
57    }
58    if (header === undefined) continue
59    if (header.oneLine) {
60      out.push({ name: m[2] as string, start: i + 1, end: header.line + 1 })
61      continue
62    }
63    const j = header.line
64    let end = j
65    for (let k = j + 1; k < rows.length; k++) {
66      const r = rows[k] as string
67      if (r.trim() === '') continue
68      if (indentOf(r) <= indent) break
69      end = k
70    }
71    out.push({ name: m[2] as string, start: i + 1, end: end + 1 })
72  }
73  return out
74}
75
76const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'do', 'try', 'with', 'new', 'typeof', 'await', 'match', 'loop', 'unsafe', 'super', 'this', 'foreach', 'using', 'lock', 'synchronized', 'when', 'guard', 'defer', 'select', 'go', 'case'])
77const BRACE_DEFS = [
78  // function foo / export default async function* foo
79  /^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
80  // class / interface / enum / struct / trait / impl / namespace Foo
81  /^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:(?:public|private|protected|internal|abstract|final|sealed|static|open|data|pub(?:\([^)]*\))?)\s+)*(?:class|interface|enum|namespace|module|struct|trait|impl(?:<[^>]*>)?|object|record|protocol|extension)\s+([A-Za-z_$][\w$]*)/,
82  // const foo = (…) => / const foo = async function
83  /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?(?:function\b|(?:<[^>]*>)?\([^)]*\)\s*(?::[^=]+)?=>|[A-Za-z_$][\w$]*\s*=>)/,
84  // Rust fn, Go/Swift func (with a receiver), Kotlin fun
85  /^\s*(?:(?:pub(?:\([^)]*\))?|async|unsafe|const|extern|public|private|fileprivate|internal|open|override|static|final|mutating|suspend|inline|operator|infix|tailrec|abstract)\s+)*(?:fn|func|fun)\s+(?:\([^)]*\)\s*)?(?:<[^>]*>\s*)?(?:[\w.]+\.)?([A-Za-z_]\w*)/,
86  // a method or C-like function whose signature is one line ending in `{`: [modifiers] [type] name(…) [: type] {
87  /^\s*(?:(?:public|private|protected|internal|static|async|readonly|override|abstract|final|virtual|sealed|partial|synchronized|native|get|set|export|default)\s+)*(?:[\w$<>[\],.?*&]+\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>()]*>)?\s*\((?:[^()]|\([^()]*\))*\)\s*(?::\s*[^={;]+|->\s*[^={;]+|throws\s+[\w., ]+)?\s*\{\s*$/,
88]
89
90function braceDefs(rows: readonly string[]): Array<Omit<Def, 'qualified'>> {
91  const out: Array<Omit<Def, 'qualified'>> = []
92  let inComment = false
93  const clean = rows.map(r => {
94    let s = r
95    if (inComment) {
96      const e = s.indexOf('*/')
97      if (e < 0) return ''
98      s = s.slice(e + 2)
99      inComment = false
100    }
101    s = s.replace(/\/\*.*?\*\//g, '')
102    const b = s.indexOf('/*')
103    if (b >= 0) {
104      inComment = true
105      s = s.slice(0, b)
106    }
107    return code(s, false)
108  })
109  for (let i = 0; i < rows.length; i++) {
110    const row = rows[i] as string
111    let name: string | undefined
112    for (const re of BRACE_DEFS) {
113      const m = re.exec(row)
114      if (m && !KEYWORDS.has(m[1] as string)) {
115        name = m[1] as string
116        break
117      }
118    }
119    if (name === undefined) continue
120    // The block: from the first `{` within a few lines to the brace that closes it.
121    let depth = 0
122    let opened = false
123    let end = i
124    for (let k = i; k < rows.length; k++) {
125      const c = clean[k] as string
126      if (!opened && k > i + 4) break
127      if (!opened && /;\s*$/.test(c) && !c.includes('{')) break // a declaration without a body
128      for (const ch of c) {
129        if (ch === '{') {
130          depth++
131          opened = true
132        } else if (ch === '}') depth--
133      }
134      end = k
135      if (opened && depth <= 0) break
136    }
137    if (!opened) continue
138    out.push({ name, start: i + 1, end: end + 1 })
139  }
140  return out
141}
142
143/** The file's definitions with their line ranges and nested names (`Class.method`); undefined for a language seek cannot read. */
144export function outline(path: string, text: string): Def[] | undefined {
145  const rows = text.split('\n')
146  const raw = PYTHON.test(path) ? pythonDefs(rows) : BRACES.test(path) ? braceDefs(rows) : undefined
147  if (raw === undefined) return undefined
148  const stack: Def[] = []
149  const out: Def[] = []
150  for (const d of raw.sort((a, b) => a.start - b.start || b.end - a.end)) {
151    while (stack.length > 0 && (stack[stack.length - 1] as Def).end < d.start) stack.pop()
152    const outer = stack[stack.length - 1]
153    const def = { ...d, qualified: outer && d.end <= outer.end ? `${outer.qualified}.${d.name}` : d.name }
154    out.push(def)
155    stack.push(def)
156  }
157  return out
158}
159
160/** The innermost definition holding a line; undefined at the top level. */
161export function enclosing(defs: readonly Def[], line: number): Def | undefined {
162  let best: Def | undefined
163  for (const d of defs) if (d.start <= line && line <= d.end && (best === undefined || d.start >= best.start)) best = d
164  return best
165}
166
167export type FileHits = { path: string; lines: number[]; text: string }
168
169/** One row of the map: `path: Class.method 120-180 (2), helper 30-41, top level (1)`; undefined where no hit sits in a definition. */
170export type MapRow = { path: string; parts: string[]; row: string }
171
172export function mapRow(f: FileHits): MapRow | undefined {
173  const defs = outline(f.path, f.text)
174  if (defs === undefined) return undefined
175  const by = new Map<Def, number>()
176  let top = 0
177  for (const l of new Set(f.lines)) {
178    const d = enclosing(defs, l)
179    if (d === undefined) top++
180    else by.set(d, (by.get(d) ?? 0) + 1)
181  }
182  if (by.size === 0) return undefined
183  const parts = [...by.entries()].sort((a, b) => a[0].start - b[0].start).map(([d, n]) => `${d.qualified} ${d.start}-${d.end}${n > 1 ? ` (${n})` : ''}`)
184  if (top > 0) parts.push(`top level (${top})`)
185  return { path: f.path, parts, row: `${f.path}: ${parts.join(', ')}` }
186}
187
188export const MAP_HEAD = 'seek: where the hits sit (the definitions that hold them, with their lines):'
189
190/**
191 * The map of a search's hits within `budget` tokens: files with the most hits first, each a row of its definitions;
192 * rows past the budget are left out and counted, and where not even the first fits, its first definitions are kept.
193 * Undefined where nothing fits or no hit sits in a definition.
194 */
195export function hitMap(files: readonly FileHits[], budget: number): string | undefined {
196  const rows = files
197    .map((f, i) => ({ f, i }))
198    .sort((a, b) => new Set(b.f.lines).size - new Set(a.f.lines).size || a.i - b.i)
199    .map(({ f }) => mapRow(f))
200    .filter((r): r is MapRow => r !== undefined)
201  if (rows.length === 0) return undefined
202  const kept: string[] = []
203  for (const r of rows) {
204    const left = rows.length - kept.length - 1
205    const text = [MAP_HEAD, ...kept, r.row, ...(left > 0 ? [`… ${left} more files`] : [])].join('\n')
206    if (tokensOf(text) > budget) break
207    kept.push(r.row)
208  }
209  const moreFiles = (n: number) => (n > 0 ? [`… ${n} more file${n === 1 ? '' : 's'}`] : [])
210  if (kept.length === 0) {
211    const first = rows[0] as MapRow
212    for (let n = first.parts.length - 1; n >= 1; n--) {
213      const text = [MAP_HEAD, `${first.path}: ${first.parts.slice(0, n).join(', ')}, … ${first.parts.length - n} more`, ...moreFiles(rows.length - 1)].join('\n')
214      if (tokensOf(text) <= budget) return text
215    }
216    return undefined
217  }
218  const more = rows.length - kept.length
219  return [MAP_HEAD, ...kept, ...moreFiles(more)].join('\n')
220}
221
hooks/search.ts 469 lines
1// A code search (grep, rg, git grep, or the Grep tool): the command as Claude writes it (behind a cd, chained, piped)
2// and the hits in its output. Pure: no file or process is touched here.
3
4export type Search = {
5  tool: 'grep' | 'rg' | 'git grep' | 'Grep'
6  pattern: string
7  /** Files or directories searched, as written; empty where the tool searches the session's folder. */
8  operands: string[]
9  ignoreCase: boolean
10  fixed: boolean
11  /** Lines the command lets through (`| head -N`, `head_limit`); a result this long may be cut. */
12  headLimit?: number
13}
14
15export type Hit = { path?: string; line: number; text: string }
16export type Group = { path: string; lines: number[]; texts: string[]; /** Hit lines the output already shows context lines for. */ covered?: number[] }
17
18
19// ---------------------------------------------------------------- the command
20
21type Word = { text: string; redirect: boolean }
22type Op = '|' | ';' | '&&' | '||'
23
24/** A command line as words and operators (`|` only, unless `chain`: also `;`, `&&`, `||`, newline); undefined where it holds anything else. */
25function tokenize(command: string, chain = false): Array<Word | Op> | undefined {
26  if (/[`]|\$\(/.test(command) || (!chain && command.includes('\n'))) return undefined
27  const out: Array<Word | Op> = []
28  let i = 0
29  while (i < command.length) {
30    const c = command[i] as string
31    if (c === ' ' || c === '\t' || c === '\r') {
32      i++
33      continue
34    }
35    if (c === '|') {
36      if (command[i + 1] === '|') {
37        if (!chain) return undefined
38        out.push('||')
39        i += 2
40        continue
41      }
42      out.push('|')
43      i++
44      continue
45    }
46    if (chain && (c === ';' || c === '\n')) {
47      out.push(';')
48      i++
49      continue
50    }
51    if (chain && c === '&' && command[i + 1] === '&') {
52      out.push('&&')
53      i += 2
54      continue
55    }
56    // `2>&1` is part of a word; a lone `&`, and in a plain command `&&` and `;`, chain commands.
57    if (c === '&' || c === ';') return undefined
58    let text = ''
59    let redirect = false
60    while (i < command.length) {
61      const ch = command[i] as string
62      if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '|' || ch === ';' || ch === '\n') break
63      if (chain && ch === '&' && command[i + 1] === '&') break
64      if (ch === "'") {
65        const end = command.indexOf("'", i + 1)
66        if (end < 0) return undefined
67        text += command.slice(i + 1, end)
68        i = end + 1
69      } else if (ch === '"') {
70        i++
71        for (;;) {
72          const d = command[i]
73          if (d === undefined) return undefined
74          if (d === '"') {
75            i++
76            break
77          }
78          if (d === '\\' && /["\\$]/.test(command[i + 1] ?? '')) {
79            text += command[i + 1]
80            i += 2
81          } else {
82            text += d
83            i++
84          }
85        }
86      } else if (ch === '\\') {
87        text += command[i + 1] ?? ''
88        i += 2
89      } else {
90        if (ch === '<' || ch === '>') redirect = true
91        if (ch === '&' && !(command[i - 1] === '>' && /\d/.test(command[i + 1] ?? ''))) return undefined
92        text += ch
93        i++
94      }
95    }
96    out.push({ text, redirect })
97  }
98  return out
99}
100
101const OK_REDIRECT = /^(\d*>+\s*\/dev\/null|\d*>&\d)$/
102
103// Options whose value is the next word (or the rest of a short bundle), by tool.
104const SHORT_VALUE: Record<string, string> = { grep: 'mfdD', rg: 'gmtTjMEfr', 'git grep': 'mf' }
105const LONG_VALUE = /^--(include|exclude|exclude-dir|exclude-from|glob|iglob|type|type-not|type-add|max-count|max-depth|file|threads|replace|color|colors|ignore-file|encoding|sort|sortr|pre|pre-glob|path-separator|devices|directories|binary-files|label)$/
106const LONG_SKIP = /^--(context|after-context|before-context|count|files-with-matches|files-without-match|files|json|quiet|silent|count-matches|null-data)(=|$)/
107
108/** `head`, `head -5`, `head -n 5`, `head -n5`: the lines it lets through; undefined where it is anything else. */
109function headLines(args: string[]): number | undefined {
110  if (args.length === 0) return 10
111  let n: number | undefined
112  for (let i = 0; i < args.length; i++) {
113    const a = args[i] as string
114    const m = /^-n?(\d+)$/.exec(a)
115    if (m) n = Number(m[1])
116    else if (a === '-n' && /^\d+$/.test(args[i + 1] ?? '')) n = Number(args[++i])
117    else return undefined
118  }
119  return n
120}
121
122/** A stage's words without its harmless redirects (`2>/dev/null`, `2>&1`); undefined where it redirects anywhere else. */
123function stageWords(stage: readonly Word[]): string[] | undefined {
124  const words: string[] = []
125  for (let k = 0; k < stage.length; k++) {
126    const w = stage[k] as Word
127    if (!w.redirect) {
128      words.push(w.text)
129      continue
130    }
131    if (OK_REDIRECT.test(w.text)) continue
132    if (/^\d*>$/.test(w.text) && stage[k + 1]?.text === '/dev/null') {
133      k++
134      continue
135    }
136    return undefined
137  }
138  return words
139}
140
141/** A plain grep / egrep / fgrep / rg / git grep, optionally piped into head; undefined for anything else or a mode seek skips. */
142export function parseCommand(command: string): Search | undefined {
143  const toks = tokenize(command.trim())
144  if (toks === undefined) return undefined
145  const stages: Word[][] = [[]]
146  for (const t of toks) {
147    if (t === '|') stages.push([])
148    else if (typeof t !== 'string') stages[stages.length - 1]?.push(t)
149  }
150  if (stages.length > 2) return undefined
151  let headLimit: number | undefined
152  if (stages.length === 2) {
153    const second = (stages[1] as Word[]).map(w => w.text)
154    if (second[0] !== 'head' || (stages[1] as Word[]).some(w => w.redirect)) return undefined
155    headLimit = headLines(second.slice(1))
156    if (headLimit === undefined) return undefined
157  }
158  const words = stageWords(stages[0] as Word[])
159  if (words === undefined) return undefined
160  const search = parseSearchWords(words, false)
161  return search === undefined ? undefined : { ...search, headLimit }
162}
163
164/** The search a grep / rg / git grep stage runs. `lenient` lets context, count and list flags through (their output is read, not assumed). */
165function parseSearchWords(words: readonly string[], lenient: boolean): Search | undefined {
166  let tool: Search['tool']
167  let i = 1
168  if (words[0] === 'git' && words[1] === 'grep') {
169    tool = 'git grep'
170    i = 2
171  } else if (words[0] === 'rg') tool = 'rg'
172  else if (words[0] === 'grep' || words[0] === 'egrep' || words[0] === 'fgrep') tool = 'grep'
173  else return undefined
174  let fixed = words[0] === 'fgrep'
175  let ignoreCase = false
176  let recursive = tool !== 'grep'
177  const patterns: string[] = []
178  const rest: string[] = []
179  const valueChars = SHORT_VALUE[tool] ?? ''
180  for (; i < words.length; i++) {
181    const a = words[i] as string
182    if (a === '--') {
183      rest.push(...words.slice(i + 1))
184      break
185    }
186    if (a.startsWith('--')) {
187      if (LONG_SKIP.test(a)) {
188        if (!lenient) return undefined
189        if (/^--(context|after-context|before-context)$/.test(a)) i++
190        continue
191      }
192      if (a === '--ignore-case') ignoreCase = true
193      else if (a === '--fixed-strings') fixed = true
194      else if (a === '--recursive' || a === '--dereference-recursive') recursive = true
195      else if (a === '--regexp') patterns.push(words[++i] ?? '')
196      else if (a.startsWith('--regexp=')) patterns.push(a.slice(9))
197      else if (a === '--file' || a.startsWith('--file=')) return undefined
198      else if (LONG_VALUE.test(a)) i++
199      continue
200    }
201    if (a.startsWith('-') && a.length > 1) {
202      for (let k = 1; k < a.length; k++) {
203        const c = a[k] as string
204        if ('lcLqZABC'.includes(c) || /\d/.test(c)) {
205          if (!lenient) return undefined
206          if ('ABC'.includes(c)) {
207            if (a.slice(k + 1) === '') i++
208            break
209          }
210          continue
211        }
212        if (c === 'i') ignoreCase = true
213        else if (c === 'F') fixed = true
214        else if (c === 'r' && tool === 'grep') recursive = true
215        else if (c === 'R') recursive = true
216        else if (c === 'e') {
217          const v = a.slice(k + 1)
218          patterns.push(v !== '' ? v : (words[++i] ?? ''))
219          break
220        } else if (c === 'f') return undefined
221        else if (valueChars.includes(c)) {
222          if (a.slice(k + 1) === '') i++
223          break
224        }
225      }
226      continue
227    }
228    rest.push(a)
229  }
230  if (patterns.length === 0) {
231    const p = rest.shift()
232    if (p === undefined) return undefined
233    patterns.push(p)
234  }
235  if (tool === 'grep' && !recursive && rest.length === 0) return undefined // reads a pipe
236  const pattern = patterns.join('|')
237  if (pattern === '') return undefined
238  const operands = tool === 'git grep' ? rest.filter(o => !o.startsWith(':')) : rest
239  return { tool, pattern, operands, ignoreCase, fixed, headLimit: undefined }
240}
241
242// A command that writes, deletes or runs other code is no read-only search.
243const WRITERS = new Set(['rm', 'mv', 'cp', 'tee', 'touch', 'mkdir', 'rmdir', 'dd', 'chmod', 'chown', 'truncate', 'ln', 'install', 'xargs', 'eval', 'exec', 'sudo', 'sh', 'bash', 'zsh', 'python', 'python3', 'node', 'ruby', 'perl', 'curl', 'wget', 'npm', 'pip', 'make'])
244const GIT_READ = new Set(['grep', 'log', 'show', 'diff', 'status', 'ls-files', 'blame', 'rev-parse', 'branch'])
245
246/** Whether a stage (redirects already vetted) only reads. */
247function readsOnly(words: readonly string[]): boolean {
248  const cmd = (words[0] ?? '').split('/').pop() as string
249  if (WRITERS.has(cmd)) return false
250  if (cmd === 'git') return GIT_READ.has(words[1] ?? '')
251  const args = words.slice(1)
252  if (cmd === 'sed' && args.some(a => /^-[a-zA-Z]*i/.test(a) || a === '--in-place' || a.startsWith('--in-place='))) return false
253  if (cmd === 'awk' && args.some(a => a === '-i' || a === 'inplace')) return false
254  if (cmd === 'find' && args.some(a => /^-(delete|exec|execdir|ok|fprint\w*)$/.test(a))) return false
255  return true
256}
257
258export type BashSearch = {
259  /** The directory of a leading `cd <dir>`, as written. */
260  cd?: string
261  /** The search segments: a grep / rg / git grep that starts a pipeline. */
262  searches: Search[]
263}
264
265/** A read-only command line (cd prefix, `;`/`&&` chains, pipes) with at least one search in it; undefined for anything else. */
266export function parseBash(command: string): BashSearch | undefined {
267  const toks = tokenize(command.trim(), true)
268  if (toks === undefined) return undefined
269  const pipelines: Word[][][] = [[[]]]
270  for (const t of toks) {
271    const cur = pipelines[pipelines.length - 1] as Word[][]
272    if (t === '|') cur.push([])
273    else if (t === ';' || t === '&&' || t === '||') pipelines.push([[]])
274    else (cur[cur.length - 1] as Word[]).push(t)
275  }
276  let cd: string | undefined
277  const searches: Search[] = []
278  let first = true
279  for (const pipe of pipelines) {
280    if (pipe.length === 1 && (pipe[0] as Word[]).length === 0) continue
281    if (pipe.some(st => st.length === 0)) return undefined
282    const stages: string[][] = []
283    for (const st of pipe) {
284      const w = stageWords(st)
285      if (w === undefined || w.length === 0 || !readsOnly(w)) return undefined
286      stages.push(w)
287    }
288    const head = stages[0] as string[]
289    if (head[0] === 'cd') {
290      if (!first || pipe.length !== 1 || head.length !== 2) return undefined
291      const dir = head[1] as string
292      if (/[$~*?]/.test(dir) || dir === '-' || dir.startsWith('-')) return undefined
293      cd = dir
294      first = false
295      continue
296    }
297    first = false
298    if (stages.slice(1).some(st => st[0] === 'cd')) return undefined
299    if (['grep', 'egrep', 'fgrep', 'rg', 'git'].includes(head[0] as string) && (head[0] !== 'git' || head[1] === 'grep')) {
300      const search = parseSearchWords(head, true)
301      if (search === undefined) return undefined
302      for (const st of stages.slice(1)) {
303        if (st[0] !== 'head') continue
304        const n = headLines(st.slice(1))
305        if (n === undefined) return undefined
306        search.headLimit = Math.min(search.headLimit ?? n, n)
307      }
308      searches.push(search)
309    }
310  }
311  return searches.length === 0 ? undefined : { cd, searches }
312}
313
314/** The Grep tool's input, as a search; undefined for the modes seek skips. */
315export function parseToolInput(input: Record<string, unknown>): Search | undefined {
316  if (typeof input.pattern !== 'string' || input.pattern === '') return undefined
317  if (input.output_mode !== 'content') return undefined
318  if (input['-n'] === false) return undefined
319  if (input.multiline === true) return undefined
320  for (const k of ['-A', '-B', '-C', 'context']) if (typeof input[k] === 'number' && input[k] !== 0) return undefined
321  return {
322    tool: 'Grep',
323    pattern: input.pattern,
324    operands: typeof input.path === 'string' && input.path !== '' ? [input.path] : [],
325    ignoreCase: input['-i'] === true,
326    fixed: false,
327    headLimit: typeof input.head_limit === 'number' && input.head_limit > 0 ? input.head_limit : undefined,
328  }
329}
330
331// ---------------------------------------------------------------- the output
332
333/** Nothing found: no text, grep's bare exit-1 report, or the Grep tool's message. */
334export function isEmpty(text: string): boolean {
335  // Claude Code shows a grep with no hits (exit 1) as "(Bash completed with no output)", not as an error (checked 15 Oct).
336  return /^\s*(Exit code 1|No matches found\.?|No files found\.?|\(Bash completed with no output\))?\s*$/i.test(text)
337}
338
339/** The rows of a search's output as hits; undefined where any row is not a hit (a binary file, a context separator, a cut). */
340export function parseHits(output: string, operands: readonly string[]): Hit[] | undefined {
341  const rows = output.split('\n')
342  while (rows.length > 0 && rows[rows.length - 1] === '') rows.pop()
343  if (rows.length === 0) return []
344  if (operands.length === 1 && rows.every(r => /^\d+:/.test(r))) {
345    return rows.map(r => {
346      const m = /^(\d+):(.*)$/.exec(r) as RegExpExecArray
347      return { line: Number(m[1]), text: m[2] as string }
348    })
349  }
350  const hits: Hit[] = []
351  for (const r of rows) {
352    const m = /^([^:]+):(\d+):(.*)$/.exec(r)
353    if (!m) return undefined
354    hits.push({ path: m[1] as string, line: Number(m[2]), text: m[3] as string })
355  }
356  return hits
357}
358
359/** `a/./b` and `a/b/../c` flattened; relative paths are made absolute against `cwd`. */
360export function normalize(path: string, cwd: string): string {
361  const out: string[] = []
362  for (const part of (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')) {
363    if (part === '' || part === '.') continue
364    if (part === '..') out.pop()
365    else out.push(part)
366  }
367  return `/${out.join('/')}`
368}
369
370/** The hits per file, in the order the files were listed. */
371export function groupHits(hits: readonly Hit[], operands: readonly string[]): Group[] {
372  const groups = new Map<string, Group>()
373  for (const h of hits) {
374    const path = h.path ?? (operands[0] as string)
375    let g = groups.get(path)
376    if (g === undefined) groups.set(path, (g = { path, lines: [], texts: [] }))
377    if (!g.lines.includes(h.line)) {
378      g.lines.push(h.line)
379      g.texts.push(h.text)
380    }
381  }
382  return [...groups.values()]
383}
384
385// ---------------------------------------------------------------- the output of a command line
386
387export const MAX_OUTPUT_LINES = 400
388
389export type Parsed = {
390  hits: Hit[]
391  /** `path:line` of the hits whose output already holds context lines. */
392  covered: Set<string>
393  /** A row says the output was cut. */
394  cut: boolean
395}
396
397const cleanPath = (p: string) => p.replace(/^(\.\/)+/, '')
398
399/** The first `<sep>N<sep>` split of a row: what comes before it as a path. */
400function splitRow(row: string, sep: ':' | '-'): { path: string; line: number; text: string } | undefined {
401  const m = (sep === ':' ? /:(\d+):/ : /-(\d+)-/).exec(row)
402  if (!m || m.index === 0) return undefined
403  return { path: cleanPath(row.slice(0, m.index)), line: Number(m[1]), text: row.slice(m.index + m[0].length) }
404}
405
406/** The paths the rows of an output may start with (`path:12:` and `path-12-` forms). */
407export function candidatePaths(output: string): string[] {
408  const out = new Set<string>()
409  for (const row of output.split('\n')) for (const sep of [':', '-'] as const) {
410    const s = splitRow(row, sep)
411    if (s !== undefined) out.add(s.path)
412  }
413  return [...out]
414}
415
416/**
417 * The hit rows (`path:line:text`) and context rows (`path-line-text`) of an output. A path counts only where `exists` says so.
418 * Rows `line:text` / `line-text` belong to `single`, the one file the command searches, when there is one.
419 */
420export function parseOutput(output: string, exists: (path: string) => boolean, single?: string): Parsed {
421  const rows = output.split('\n')
422  const seen = new Set<string>()
423  const hits: Hit[] = []
424  const lines = new Map<string, { hit: Set<number>; ctx: Set<number> }>()
425  let cut = false
426  for (const row of rows) {
427    let found: { path: string; line: number; text: string; ctx: boolean } | undefined
428    for (const sep of [':', '-'] as const) {
429      const s = splitRow(row, sep)
430      if (s !== undefined && exists(s.path)) {
431        found = { ...s, ctx: sep === '-' }
432        break
433      }
434    }
435    if (found === undefined && single !== undefined) {
436      const m = /^(\d+)([:-])(.*)$/.exec(row)
437      if (m) found = { path: cleanPath(single), line: Number(m[1]), text: m[3] as string, ctx: m[2] === '-' }
438    }
439    if (found === undefined) {
440      if (/truncated|Showing results with pagination/i.test(row)) cut = true
441      continue
442    }
443    let l = lines.get(found.path)
444    if (l === undefined) lines.set(found.path, (l = { hit: new Set(), ctx: new Set() }))
445    ;(found.ctx ? l.ctx : l.hit).add(found.line)
446    const key = `${found.path}:${found.line}`
447    if (!found.ctx && !seen.has(key)) {
448      seen.add(key)
449      hits.push({ path: found.path, line: found.line, text: found.text })
450    }
451  }
452  // A hit is covered where a run of consecutive output lines around it holds a context line.
453  const covered = new Set<string>()
454  for (const [path, l] of lines) {
455    const all = [...new Set([...l.hit, ...l.ctx])].sort((a, b) => a - b)
456    let run: number[] = []
457    const flush = () => {
458      if (run.some(n => l.ctx.has(n))) for (const n of run) if (l.hit.has(n)) covered.add(`${path}:${n}`)
459      run = []
460    }
461    for (const n of all) {
462      if (run.length > 0 && n !== (run[run.length - 1] as number) + 1) flush()
463      run.push(n)
464    }
465    flush()
466  }
467  return { hits, covered, cut }
468}
469
hooks/subagents.ts 41 lines
1// A subagent's task comes from Claude, not the person, so the order beside the person's prompts never reaches it; the
2// task itself is the one thing that rides with it. Pure: no I/O.
3//
4// Measured in the person's transcripts: general-purpose subagents ran about a third of their Opus requests. In the
5// bench, the order saved most where it rode with the task (−12% fresh, −16% on TypeScript): an order beside the task,
6// in the person's name, is followed; advice and orders in the middle of a search are not.
7
8/**
9 * The block the pack's mods put their settings under, at the end of a subagent's task; each mod adds its own line,
10 * so either can be turned off alone. cells writes the same head.
11 */
12export const SETTINGS_HEAD = "The person's settings for this work:"
13
14/** The task with one more setting: a line under the block a mod before this one began, or a new block at the end. */
15export function withSetting(prompt: string, line: string): string {
16  if (prompt.includes(line)) return prompt
17  const at = prompt.lastIndexOf(`\n\n${SETTINGS_HEAD}\n`)
18  if (at >= 0 && !prompt.slice(at + 2).includes('\n\n')) return `${prompt.trimEnd()}\n- ${line}`
19  return `${prompt.trimEnd()}\n\n${SETTINGS_HEAD}\n- ${line}`
20}
21
22type Spawn = { subagentType: string; fork: boolean; isTeammate?: true; resume?: unknown }
23
24/**
25 * Whether a spawn starts a subagent on a task of its own: not a fork (it has the parent's context, the order
26 * included), not a resume (its task is fixed), not a teammate, not delegate's worker (a tuned prompt of its own).
27 */
28export function reaches(e: Spawn): boolean {
29  return !e.fork && e.resume === undefined && e.isTeammate !== true && !e.subagentType.startsWith('delegate:')
30}
31
32/**
33 * The order for a subagent's task. It starts with a context of its own whose size is not known here, so the order
34 * names none; the rest is the person's order as seek gives it beside their prompts.
35 */
36export const SUBAGENT_ORDER =
37  'Lean looking around: whatever you read stays in your context, and every later response re-reads all of it. ' +
38  'Put the searches and reads you can already name into one command; when a search shows where to look, read those places in the same step; ' +
39  'read only the definitions or lines you need (grep -n first, then a range around them), not whole files or long ranges; ' +
40  'and do not read again what you have already read unless it changed.'
41