/smart-compact: write custom /compact instructions from the session, then compact with them

<!-- generated by scripts/readme/build.mjs. do not edit by hand. -->
<img alt="A Firefox tab strip. Each tab you are not looking at has an idle clock; when it fills, the tab unloads and its icon fades. A tab that is pinned, playing sound, using camera, mic or screen share or on your never-unload list keeps running its clock, but when the clock fills it turns grey and the tab stays loaded. A click on an unloaded tab loads it again, and the tab you left starts a new clock. Default limit 30 minutes, checked every minute." src="assets/readme/hero-light.svg" width="100%">
A Firefox add-on that unloads tabs you have not looked at for a while (30 minutes by default). An unloaded tab keeps its place, title and icon in the tab strip, the page's memory is freed and it stops using CPU, and clicking the tab loads the page again. It uses Firefox's own tab-unloading mechanism, run on a timer.
Version 0.1.0 · Firefox 140 or later on desktop · not on addons.mozilla.org: sign it yourself, or try it now from about:debugging
Install · Using it · Settings · Limits · Development · Licence
<img alt="What the add-on can touch: 3 permissions (tabs · alarms · storage); 0 page contents read (sees tab titles, addresses); 0 requests of its own (list shows tab icons by URL); 0 data types collected (as declared to Mozilla); 38 KB in 11 source files (no runtime dependencies)." src="assets/readme/footprint-light.svg" width="100%">
Release Firefox only installs add-ons that Mozilla has signed. For your own use, sign it as an unlisted add-on on addons.mozilla.org (AMO). Unlisted means Mozilla signs the file but does not publish it on AMO.
$env:WEB_EXT_API_KEY = "user:12345:67"
$env:WEB_EXT_API_SECRET = "your secret"
web-ext reads both variables itself. Do not write the credentials into a file in this repository.
npm ci, then npm run sign. Mozilla's automated check can take a few minutes or longer. The signed .xpi is saved in web-ext-artifacts/..xpi.AMO accepts each version number once. Before signing a changed add-on, raise version in src/manifest.json.
To use it in private windows: about:addons, click Tab Unloader, and set "Run in Private Windows" to Allow.
For development only, there are two ways to skip signing:
xpinstall.signatures.required is set to false in about:config. Build with npm run build, rename the .zip in web-ext-artifacts/ to .xpi and install it the same way as above. Release Firefox ignores this setting.src/manifest.json. The add-on stays until Firefox restarts.Click the Tab Unloader button to open its list. It shows every tab in every normal Firefox window, grouped by window, with the window you opened it from marked "(current)". A filled or hollow dot shows whether each tab is loaded or unloaded, and the label on the right says "Unloaded", "Playing" (making sound) or "Current".
www.youtube.com). To cover a site and all its subdomains, add the shorter name (youtube.com) on the settings page.Once a minute the add-on checks every tab and unloads the ones you have not looked at for longer than the limit, 30 minutes by default. It skips a tab while:
A skipped tab's idle time keeps counting. A tab you have not looked at for hours is unloaded at the first check after it stops playing sound or after you unpin it. Nothing is unloaded automatically while the add-on is paused.
Firefox does not change how a tab looks when an add-on unloads it. The toolbar list always shows it, with a hollow dot and the label "Unloaded". To see it in the tab strip too, open about:config and set browser.tabs.fadeOutUnloadedTabs to true: the icons of unloaded tabs then fade to half strength, like the faded icons in the picture at the top.
When the computer wakes from sleep, a whole batch of tabs would be past the limit at once. To avoid that, if more than 3 minutes pass between two checks, the add-on unloads nothing on that check and restarts every tab's idle clock from that moment. Tabs are then unloaded one full limit after the wake, if you still have not looked at them.
Open it from the list's Settings link, or from Tab Unloader's entry in about:addons. It opens in a tab.
Unload tabs after. A whole number of minutes, 1 or more.
Never unload. Each entry is either a site name or a full page address.
youtube.com covers www.youtube.com and music.youtube.com. It matches http and https pages on any port. Type the name only, with no https://, path, port or user@. It needs at least one dot, except localhost. IPv4 addresses such as 192.168.1.10 work. Names are saved in lower case, and international names are saved in their xn-- form.http:// or https:// and keeps only that exact page loaded. The query string and # part must match too, so https://example.com/inbox does not cover https://example.com/inbox?tab=2.Other kinds of address, such as file: or about: pages, cannot be added. Remove an entry with its Remove button. Changes made from the toolbar list show up on the settings page right away.
The code is in src/. It needs Node.js and npm.
npm ci installs the tools.npm run lint runs Mozilla's add-on linter on src/.npm run build packs src/ into a .zip in web-ext-artifacts/ (ignored by git), replacing an older one with the same version.npm run readme rebuilds this README and its pictures in assets/readme/ from src/ and scripts/readme/body.md. Edit those two, never README.md; a pull request with an out-of-date README fails CI.npm run verify runs the browser checks in order (smoke, options, popup, auto, restart, cleanup) and stops at the first failure. Each one also runs alone as npm run verify:smoke, verify:options, verify:popup, verify:auto, verify:restart or verify:cleanup.The verify scripts start real Firefox windows with the add-on loaded as a temporary add-on, place them off screen at -2400,-2400 and set the volume to zero. They use Firefox at C:\Program Files\Mozilla Firefox\firefox.exe; set FIREFOX_BINARY to use another copy. The first run downloads geckodriver 0.37.1 into the system temp folder, so it needs a network connection. Screenshots go to D:\screenshots\Firefox-Efficient\, or to .tmp\screenshots\ when D: is not writable. Each script prints a JSON line with its results last and exits with code 0 only when every check passes. The restart check unloads tabs, quits Firefox, starts it again on the same profile, and checks that every unloaded tab and a new tab load without the "tab crashed" page; it does this with the add-on installed temporarily, installed permanently, and not installed. To test a Firefox update, point FIREFOX_BINARY_BEFORE at an older Firefox: the first half then runs on that copy and the second half on the installed one. The cleanup check makes the test harness fail or hang on purpose in four ways and checks that each run still ends its own Firefox, geckodriver and helper processes, deletes its temporary profile, and leaves other processes alone.
This repository does not have a licence file yet.
hooks/register.ts 86 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Asked of a fork of the session, which sees the whole transcript. Its answer is
4// passed to /compact as the custom instructions the summarizer follows.
5const REVIEW_PROMPT = `Review this whole conversation so far and write the custom instructions to pass to /compact. The summarizer reads them as directions for what to keep, so write imperatives to it.
6
7Keep, in priority order:
8
91. The user's goal for the session and any standing instructions or preferences they gave in it (style, scope limits, things to never do).
102. Current state: repo, branch, worktree path, PR URL, last commit SHA, deploy or preview URL, files created or changed.
113. Decisions made and the reason for each, including options the user rejected.
124. What is verified and how, and what is still unverified.
135. Open work: the next concrete step, pending questions to the user, background tasks still running.
146. Failed approaches and gotchas that would otherwise be retried.
157. Exact identifiers to keep verbatim: paths, commands, error strings, IDs, numbers.
16
17Tell the summarizer to drop resolved tangents, raw tool output, superseded plans, and anything re-readable from files or CLAUDE.md. Never invent a fact; if a state item is unknown, leave it out. Keep the block under about 300 words; cut from the bottom of the priority list first.
18
19Return exactly one fenced \`text\` block and nothing else. When the session's replies follow a named style or output style (caveman ultra, for example), make the first line \`Always use <that style>.\` so it survives the compaction; otherwise leave that line out.
20
21\`\`\`text
22Always use <reply style>.
23Goal: <one line>.
24Keep: <standing user instructions>.
25State: <branch, worktree, PR, SHA, files>.
26Decisions: <decision, why>; ...
27Verified: <what, how>. Unverified: <what>.
28Next: <next step>; open questions: <...>.
29Don't retry: <failed approach, cause>.
30Verbatim: <paths, commands, errors>.
31Drop tool output, resolved tangents, and superseded plans.
32\`\`\`
33
34Omit any line with nothing to say.`
35
36// The fork answers with one fenced `text` block; take its body.
37const fencedBody = (reply: string) => {
38 const match = reply.match(/```(?:text)?\r?\n([\s\S]*?)\r?\n```/)
39 return (match ? match[1] : reply).trim()
40}
41
42export const register: Register = on => {
43 on('session.start', async ($, e, next) => {
44 await $.command.register({
45 name: 'smart-compact',
46 description: 'Write custom /compact instructions from this session, then compact with them',
47 })
48 return next(e)
49 })
50
51 on('command.run', { command: 'smart-compact' }, async $ => {
52 $.ui.status('smart-compact: writing instructions')
53 const reply = await $.model.fork({ prompt: REVIEW_PROMPT })
54 if (!reply.isAnswered) {
55 $.ui.status(undefined)
56 return { text: `review failed (${reply.reason}); nothing compacted. Run /compact yourself.` }
57 }
58
59 const instructions = fencedBody(reply.text)
60 $.ui.status('smart-compact: compacting')
61 // The engine refuses to run a command from inside this hook, which still
62 // holds the command's turn; run it from a timer once the command finishes.
63 $.clock.after(500, () => void compactWhenIdle($, instructions, 10))
64
65 return { text: `compacting with these instructions:\n\n${instructions}` }
66 })
67}
68
69const compactWhenIdle = async ($: EngineInterface, instructions: string, triesLeft: number) => {
70 try {
71 // `$.session.compact` is refused in SDK sessions (the desktop app's Code
72 // tab); running `/compact` itself works in those and in the terminal.
73 await $.command.run({ command: 'compact', args: instructions })
74 $.ui.status(undefined)
75 } catch (err) {
76 // Retry only the refusal for a command run from inside a hook that still
77 // holds the turn; a cancelled or interrupted /compact stays cancelled.
78 if (triesLeft > 1 && /hook/i.test(String(err))) {
79 $.clock.after(1000, () => void compactWhenIdle($, instructions, triesLeft - 1))
80 return
81 }
82 $.ui.status(undefined)
83 $.ui.log(`smart-compact: compaction failed (${String(err)}). Paste the instructions above after /compact.`)
84 }
85}
86