Spends your Claude Code tokens where they count: moves a new task into a fresh session (a new terminal window, or its own git worktree) instead of dragging a…

<a href="assets/brag.mp4"><img src="assets/brag.gif" alt="Prompt Forge in 21 seconds: the cost per prompt climbs from $0.23 to $0.55 as the conversation piles up; a new task is held and, with one keypress, opens in a new terminal while the old session stays as it was; the small, clear task runs on Sonnet for $0.09 instead of $0.44; a six-prompt session drops from $2.20 to $1.29, −41%, all 18 steps still right" width="760"></a>
<sub>▶ <a href="assets/brag.mp4">Watch the video</a> (21 s)</sub>
Spend your Claude Code tokens where they count. Most of what a session costs is Claude re-reading the conversation. Prompt Forge moves a new task into a fresh session (a new terminal window, or a git worktree of its own) instead of dragging a long conversation along, and runs small, clear tasks on Sonnet: −41% per session in its benchmark, every step still right.
Install · See it · How it works · Benchmarks · Commands · Settings · 📖 Full guide
In a long Claude Code session, every prompt makes Claude re-read everything said so far. In the benchmark session, the cost per prompt climbed from $0.23 to $0.55 as the conversation grew, mostly from re-reading it. Prompt Forge spends your tokens where they count:
f). Your current session stays exactly as it was. One keypress, never on a follow-up.w), so it doesn't land in this branch's diff./clear.h, start a prompt with raw:, or run /forge off.A new task in a long session: Prompt Forge holds it and offers a fresh session. f starts Claude with your prompt in a new terminal window; h sends it here. This session stays as it was. Since the task is small and clear, the new session's first turn runs on Sonnet:
<img src="assets/fresh.png" alt="A new task in a long session: Prompt Forge says it would re-read 85k tokens of old conversation and offers New terminal (f) or Send here (h); after f, a toast says the task is running in a new terminal window and this session stays as it was" width="100%">
<sub>A faithful recreation of the plugin's terminal output (<code>scripts/mockups</code>, rendered by <code>scripts/render-assets.sh</code>); the wording comes straight from <code>hooks/register.tsx</code>.</sub>
Run these one at a time in Claude Code. Each is a separate command.
1. Add the marketplace
/plugin marketplace add tomikng/prompt-forge
[!TIP] If you open the Add Marketplace dialog from the
/pluginmenu instead, paste onlytomikng/prompt-forgeinto the box.
2. Install the plugin
/plugin install prompt-forge@prompt-forge
3. Just work. Nothing changes until it can save you something. /forge shows what's on.
claude plugin marketplace add tomikng/prompt-forge
claude plugin install prompt-forge@prompt-forge
[!NOTE] Requires Claude Code 2.1.289+ (function-hook plugins, an early-access API). The fresh-start offer draws in the terminal and the desktop Code tab.
Updates. Auto-update is off by default for community marketplaces. To turn it on: /plugin → Marketplaces → prompt-forge → Enable auto-update. To update by hand:
claude plugin marketplace update prompt-forge
claude plugin update prompt-forge@prompt-forge
<img src="assets/spend.svg" alt="Animated diagram: a follow-up goes straight to your model; a new task in a long session starts in a new session (f), and because it names a file and a finish line it runs on Sonnet; short replies pass straight through" width="100%">
| What happens | When |
|---|---|
| Held, with a fresh-session offer | A new task while the session re-reads 30k+ tokens of earlier conversation. f starts it in a new terminal, w (unrelated work, in a git repository) on a new worktree of its own, h sends it here; or use the buttons |
| Run on Sonnet | A clear prompt (it names a file, path, code or identifier and a finish line like run npm test, should, make sure) while the conversation is at most 10k tokens |
| Sent as typed | Everything else: follow-ups, short sessions, short replies, slash commands, prompts with an image or file, raw: prompts, and anything while /forge off |
claude "<your prompt>" in the same folder.xdg-terminal-exec), Terminal on macOS, or Windows Terminal, in the same folder./clear here, then your prompt, if neither is available.The settings can pin one of these, use your own terminal command, or turn on Superset.
w adds a git worktree next to your repository (<repo>-<task-name>) on a new branch from your default branch, and opens the new session there.newSession: superset): if the session runs in a Superset workspace, f opens a new Claude terminal in that workspace (superset agents create) and w a new Superset workspace (superset ws create), which is how Superset handles worktrees.w offered). When unsure it says related./clear can't remove. Prompt Forge takes the smallest context it has seen as that fixed part and counts only what's above it.In short: over a six-prompt session, Prompt Forge cut spend by 41% ($1.29 vs $2.20), with every step still right (18/18). Fresh starts alone gave −25%; Sonnet for small, clear tasks halved those tasks on top. Its own Haiku calls came to about $0.007 per session.
Six coding tasks on a small Node project ran in order on one Claude Code session (V1 → V2 → C2 → V3 → C1 → A1), 3 sessions with Prompt Forge and 3 without, all on the same day. Every step was checked automatically: tests pass, the behaviour asked for works, a protected API file is untouched. Every fresh start Prompt Forge offered was accepted.
<img src="assets/bench-session.svg" alt="Line chart of cumulative dollars over six session steps: without Prompt Forge ends at $2.20, with it at $1.29. Chips under each step show what the local check decided." width="100%">
| Per session | Mean | Range (3 sessions) | Steps done right | Fresh starts / Sonnet turns |
|---|---|---|---|---|
| Without Prompt Forge | $2.20 | $2.13–$2.25 | 18/18 | – |
| Fresh starts only (0.5, prompts still rewritten) | $1.64 | $1.48–$1.76 | 18/18 | 4 / – |
| Fresh starts + Sonnet (0.8+) | $1.29 | $1.11–$1.47 | 18/18 | 6 / 3 |
| Step | Task | Without | With | What Prompt Forge did |
|---|---|---|---|---|
| 1 | V1 · "can u make the orders page faster…, dont touch the api" | $0.23 | $0.24 | nothing: first prompt |
| 2 | V2 · "the cart total is wrong when ppl buy more than one…" | $0.28 | $0.25 | fresh start in 1 of 3 sessions |
| 3 | C2 · cart fix, file and test named | $0.32 | $0.28 | nothing: same topic |
| 4 | V3 · "signup lets ppl in with junk emails, fix that" | $0.38 | $0.27 | fresh start in 2 of 3 sessions |
| 5 | C1 · "In src/users.js rename getUser to fetchUser…; run npm test" | $0.44 | $0.09 | fresh start, then Sonnet, in every session |
| 6 | A1 · "rename it to something clearer, everywhere it's used" | $0.55 | $0.15 | nothing: a follow-up, on a small conversation |
f. The benchmark accepted every fresh-start offer. If you usually answer h, you'll save little.h whenever the new task needs earlier context.Prompt Forge started as a prompt rewriter: Haiku sharpened each prompt before Claude saw it. Same-day benchmarks showed the rewriting never saved money. Asking you questions (0.2) broke even but interrupted four times a session; rewriting freely (0.4.0) cost +17%, because it added work nobody asked for; rewriting only to add information (0.4.1) broke even. With fresh starts and Sonnet in place, turning rewriting off changed nothing measurable ($1.24 with it, $1.29 without, ranges overlapping) except removing a 1–2 second delay from every prompt. So 0.8 removed it. The old data and rules are kept in bench/ (*-0.2.json … *-0.6.0.json, bench/policies/).
claude -p --output-format json), at list prices.claude -p --resume on the same session; a fresh start began a new session, as f does (a new terminal and /clear start from the same empty conversation, so they cost the same); a Sonnet turn ran with --model sonnet. Each step's check runs on the working tree right after that step. If Claude stopped to ask instead of working, the task's canned answer was sent, and both calls were counted. The local check is the plugin's own hooks/classify.ts, run by Node.bench/session-results.json (this run), bench/results-route.json (Sonnet single tasks) and bench/RESULTS.md. RESULTS.md's per-task section is from 0.6, when prompts were still rewritten.python3 bench/session.py run --reps 3 --arms baseline --out session-baseline.json # without Prompt Forge
python3 bench/session.py run --reps 3 --arms guardroute-none --out session-results.json # fresh starts + Sonnet
python3 bench/session.py run --reps 3 --arms guard-none --out session-guard.json # fresh sessions only
python3 bench/bench.py run --reps 3 --only C1,C2 --arms route-none --out results-route.json # Sonnet single tasks
python3 bench/bench.py report # RESULTS.md and the charts
| Command | What it does |
|---|---|
f / w / h | While a new task is held: start it in a new terminal / on a new git worktree and branch (unrelated work) / send it here |
| /forge | Show what's on | | /forge fresh off | Never offer a fresh session (/forge fresh on turns it back on) | | /forge model off | Never switch a turn to Sonnet (/forge model on turns it back on) | | /forge off | Turn Prompt Forge off: every prompt goes out as typed, on your model (/forge on turns it back on) | | raw: <prompt> | Send this one prompt with no checks; the raw: prefix is removed |
In /config under prompt-forge, or in settings.json:
| Setting | Values | Default | What it does |
|---|---|---|---|
newSession | auto, superset, tmux, window, clear | auto | Where a new task's session opens. auto: a tmux window, else a new terminal window, else /clear. superset: Superset terminals and workspaces when the session runs in a Superset workspace, else auto. tmux, window, clear: only that one (else /clear). |
terminalCommand | a command | none | Opens new terminal windows instead of the system default. claude and your prompt are appended; {dir} becomes the session's folder. For example ghostty --working-directory={dir} -e, kitty --directory {dir} or wezterm start --cwd {dir} --. |
freshMinTokens | 5000–500000 | 30000 | How much conversation (above the fixed system prompt and tools) before a fresh session is offered. |
{
"pluginConfigs": {
"prompt-forge": { "options": { "newSession": "superset", "terminalCommand": "ghostty --working-directory={dir} -e" } }
}
}
/model sonnet turn.~/.claude/plugins/store/.Does it change my prompts? No. They go out exactly as typed. Earlier versions rewrote prompts; the benchmarks showed it never saved money, so 0.8 removed it.
Does it slow me down? Not in short sessions. In a long session the new-task check adds about a second before the prompt is sent.
What if a fresh session was the wrong call? Answer h and the prompt is sent where you are, nothing lost. And when it opens a new session, this one stays exactly as it was, so you can always go back. The check leans towards "follow-up" when unsure, and /forge fresh off turns the offer off for good.
Is Sonnet as good as Opus for those tasks? On the benchmark's clear tasks, yes: every run passed its checks. It's only used when the prompt names its file and its finish line, and only on a small context. /forge model off keeps every turn on your model.
claude --plugin-dir ./plugins/prompt-forge # run it from source
claude plugin validate ./plugins/prompt-forge # check the manifest and hooks
claude plugin test ./plugins/prompt-forge # run the tests
./scripts/render-assets.sh # regenerate the screenshot (chromium + ImageMagick)
python3 scripts/diagrams.py # regenerate the animated diagram
python3 bench/bench.py report # rebuild benchmark tables and charts
plugins/prompt-forge/
├── hooks/register.tsx # new-task check, new sessions (tmux, terminal windows, worktrees, Superset, /clear), Sonnet routing, /forge
├── hooks/classify.ts # the local "clear prompt" check
├── types/index.d.ts # state contract
└── tests/forge.test.ts
Issues and PRs are welcome, especially fresh-start offers that came at the wrong moment.
MIT © tomikng
hooks/register.tsx 413 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fresh, Workspace } from '../types'
5
6import { isClearEnough } from './classify'
7
8export { isClearEnough }
9
10// Prompt Forge spends your tokens where they count. Prompts go out as typed; two things change
11// what a turn costs: a fresh start for a new task in a long session, and Sonnet for a small,
12// clear task on a small context.
13
14const freshA = atom({ plugin: 'prompt-forge', key: 'fresh' } as const, null)
15
16/**
17 * Below this much conversation a fresh start saves too little to be worth a question. Counted
18 * above the session's floor: the system prompt, tools and MCP servers every request carries
19 * (27k on a bare install, far more with plugins), which /clear can't remove.
20 */
21export const FRESH_MIN_TOKENS = 30_000
22
23/** The plugin's settings (`userConfig` in plugin.json, rows in /config), set by register. */
24type Launcher = 'auto' | 'superset' | 'tmux' | 'window' | 'clear'
25let settings = { newSession: 'auto' as Launcher, terminalCommand: '', freshMinTokens: FRESH_MIN_TOKENS }
26const FRESH = /^(?:f|fresh|y|yes|clear)$/i
27const HERE = /^(?:h|here|n|no)$/i
28const WORKSPACE = /^(?:w|workspace)$/i
29const RAW = /^raw:\s*/i
30const MIN_WORDS = 5
31
32const TOPIC = `You decide where a developer's new prompt to an AI coding agent should run, given the recent
33conversation and the git branch the session works on.
34
35CONTINUES: the prompt refers to anything in the conversation ("it", "that", "the same", "the
36bug", "again", a file, function or result mentioned there), follows up on the last change, or
37could go wrong without the conversation's context.
38RELATED: a self-contained new task that names its own target and needs nothing said in the
39conversation, but belongs to the same piece of work: the same feature, ticket or branch.
40UNRELATED: a self-contained new task for a different piece of work, one that would belong on
41its own branch.
42
43When unsure between CONTINUES and anything else, answer CONTINUES: moving the prompt by
44mistake loses context the user needs. When unsure between RELATED and UNRELATED, answer RELATED.
45Reply with exactly one word: CONTINUES, RELATED or UNRELATED.`
46
47/** The last few text messages of the conversation, newest last, within a character budget. */
48export function recentContext(msgs: readonly { role: string; text: string }[], budget = 3000): string {
49 const out: string[] = []
50 let used = 0
51 for (const m of [...msgs].reverse()) {
52 const t = m.text.trim()
53 if (!t) continue
54 const line = `${m.role}: ${t.length > 800 ? `${t.slice(0, 799)}…` : t}`
55 if (used + line.length > budget || out.length >= 6) break
56 out.unshift(line)
57 used += line.length
58 }
59 return out.join('\n\n')
60}
61
62const kTokens = (n: number) => (n < 1000 ? `${n}` : `${Math.round(n / 1000)}k`)
63
64/** Where unrelated work can get its own branch: a Superset workspace, a git worktree, or nowhere. */
65export type Branchable = 'superset' | 'git' | null
66
67/** The drop notice of a prompt held as a new task: one line, since the engine draws it as one. */
68export function freshNote(tokens: number, topic: 'related' | 'unrelated' = 'related', branchable: Branchable = null): string {
69 const head = `✨ Prompt Forge: this looks like a${topic === 'unrelated' ? 'n unrelated' : ' new'} task, and every step here re-reads ${kTokens(tokens)} tokens of old conversation. ↳ reply`
70 const w = topic === 'unrelated' && branchable ? `"w" for ${branchable === 'superset' ? 'a new Superset workspace' : 'a new worktree on its own branch'}, ` : ''
71 return `${head} ${w}"f" for a new terminal, or "h" to send it here`
72}
73
74const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
75const norm = (s: string) => s.trim().replace(/\s+/g, ' ')
76
77async function isOn($: EngineInterface) {
78 return (await $.store.get('enabled')) !== false
79}
80
81/** The last few messages, for telling a follow-up from a new task; empty when unreadable. */
82async function conversation($: EngineInterface) {
83 try {
84 return recentContext(await $.session.messages())
85 } catch {
86 return ''
87 }
88}
89
90let floor = Infinity
91
92/**
93 * Tokens of conversation the session re-reads each step: the context less its floor, the
94 * smallest context seen (taken as the fixed part). 0 when the engine can't say.
95 */
96async function conversationTokens($: EngineInterface) {
97 try {
98 const tokens = (await $.session.usage()).context.tokens
99 if (!tokens) return 0
100 floor = Math.min(floor, tokens)
101 return tokens - floor
102 } catch {
103 return 0
104 }
105}
106
107/**
108 * Model routing: a clear, small task on a small context runs on Sonnet for that turn. Only on a
109 * small context: the prompt cache is per model, so switching a long conversation would make
110 * Sonnet read it all uncached and cost more than staying.
111 */
112export const ROUTE_MAX_TOKENS = 10_000
113const ROUTE_MODEL = 'claude-sonnet-5-5'
114let cheapNext: string | null = null
115const cheapTurns = new Set<string>()
116
117/** Marks the prompt about to be sent for Sonnet when it's clear and the context is small. */
118async function markCheap($: EngineInterface, text: string, tokens?: number) {
119 cheapNext = null
120 if (!isClearEnough(text) || (await $.store.get('route')) === false) return
121 if ((tokens ?? (await conversationTokens($))) > ROUTE_MAX_TOKENS) return
122 cheapNext = text
123}
124
125type Topic = 'continues' | 'related' | 'unrelated'
126
127/** Where the prompt belongs. Any failure answers "continues": never move a prompt by mistake. */
128async function topicOf($: EngineInterface, text: string): Promise<Topic> {
129 const convo = await conversation($)
130 if (!convo) return 'continues'
131 const branch = await currentBranch($)
132 const r = await $.model.complete({
133 model: 'haiku',
134 system: TOPIC,
135 prompt: `${branch ? `<branch>${branch}</branch>\n\n` : ''}<recent_conversation>\n${convo}\n</recent_conversation>\n\n<prompt>\n${text}\n</prompt>`,
136 maxTokens: 6,
137 timeoutMs: 8_000,
138 })
139 if (!r.isAnswered) return 'continues'
140 const w = r.text.trim().toUpperCase()
141 return w.startsWith('UNRELATED') ? 'unrelated' : w.startsWith('RELATED') ? 'related' : 'continues'
142}
143
144async function currentBranch($: EngineInterface) {
145 try {
146 const r = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5_000 })
147 return r.exitCode === 0 ? r.stdout.trim() : ''
148 } catch {
149 return ''
150 }
151}
152
153/** The Superset workspace this session runs in, matched by its worktree; null outside Superset. */
154async function supersetWorkspace($: EngineInterface): Promise<Workspace | null> {
155 try {
156 const cwd = await $.session.cwd()
157 if (!cwd) return null
158 const r = await $.process.run(['superset', 'ws', 'list', '--local', '--json'], { timeoutMs: 15_000 })
159 if (r.exitCode !== 0) return null
160 const list = JSON.parse(r.stdout) as { id: string; name: string; projectId: string; worktreePath?: string | null }[]
161 const ws = list.find(w => w.worktreePath && (cwd === w.worktreePath || cwd.startsWith(`${w.worktreePath}/`)))
162 return ws ? { id: ws.id, name: ws.name, projectId: ws.projectId } : null
163 } catch {
164 return null
165 }
166}
167
168/** A short branch and workspace name from the prompt's first words. */
169export function slugOf(text: string): string {
170 const words = text.toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/).filter(w => w && !STOP.has(w))
171 return (words.slice(0, 5).join('-') || 'new-task').slice(0, 48).replace(/-+$/, '')
172}
173const STOP = new Set(['a', 'an', 'the', 'in', 'on', 'to', 'of', 'and', 'for', 'please', 'can', 'you', 'u', 'pls', 'it', 'run'])
174
175/** Leaves a note for the new session: run this prompt's turn on Sonnet if it's small and clear. */
176async function handOff($: EngineInterface, text: string) {
177 if (isClearEnough(text) && (await $.store.get('route')) !== false) await $.store.set('handoff', text)
178}
179
180async function ran($: EngineInterface, argv: string[]) {
181 try {
182 return (await $.process.run(argv, { timeoutMs: 30_000 })).exitCode === 0
183 } catch {
184 return false
185 }
186}
187
188async function out($: EngineInterface, argv: string[]) {
189 try {
190 const r = await $.process.run(argv, { timeoutMs: 15_000 })
191 return r.exitCode === 0 ? r.stdout.trim() : ''
192 } catch {
193 return ''
194 }
195}
196
197/** Quotes for a POSIX shell, and for an AppleScript string. */
198const sh = (s: string) => `'${s.replaceAll("'", "'\\''")}'`
199const applescript = (s: string) => s.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
200
201/**
202 * Opens Claude with the prompt in a new terminal at `dir`, wherever this session runs: a tmux
203 * window, or a new terminal window on Linux (xdg-terminal-exec), macOS (Terminal) or Windows
204 * (Windows Terminal). Says where, or null when none could be opened.
205 */
206async function openTerminal($: EngineInterface, dir: string, text: string): Promise<string | null> {
207 const mode = settings.newSession
208 if (mode !== 'window' && (await ran($, ['sh', '-c', 'test -n "$TMUX"'])) && (await ran($, ['tmux', 'new-window', ...(dir ? ['-c', dir] : []), '--', 'claude', text]))) return 'a new tmux window'
209 if (mode === 'tmux') return null
210 const custom = settings.terminalCommand.trim()
211 if (custom) {
212 // the user's own terminal command, started in the background so this call returns at once
213 const argv = custom.split(/\s+/).map(w => w.replaceAll('{dir}', dir || '.'))
214 return (await ran($, ['sh', '-c', 'nohup "$@" >/dev/null 2>&1 &', 'sh', ...argv, 'claude', text])) ? 'a new terminal window' : null
215 }
216 if ((await ran($, ['sh', '-c', 'command -v xdg-terminal-exec'])) && (await ran($, ['setsid', '-f', 'xdg-terminal-exec', ...(dir ? [`--dir=${dir}`] : []), 'claude', text]))) return 'a new terminal window'
217 if (await ran($, ['sh', '-c', 'test "$(uname)" = Darwin'])) {
218 const script = applescript(`cd ${sh(dir || '.')} && claude ${sh(text)}`)
219 if (await ran($, ['osascript', '-e', `tell application "Terminal" to do script "${script}"`, '-e', 'tell application "Terminal" to activate'])) return 'a new Terminal window'
220 }
221 if ((await ran($, ['where', 'wt.exe'])) && (await ran($, ['wt.exe', '-d', dir || '.', 'claude', text]))) return 'a new Windows Terminal tab'
222 return null
223}
224
225/** A new git worktree next to the repository, on a new branch from the default one; its folder, or null. */
226async function newWorktree($: EngineInterface, repo: string, slug: string): Promise<string | null> {
227 const dir = `${repo}-${slug}`
228 const base = (await out($, ['git', '-C', repo, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) || 'HEAD'
229 return (await ran($, ['git', '-C', repo, 'worktree', 'add', '-b', slug, dir, base])) ? dir : null
230}
231
232/**
233 * Opens a new session for the held prompt and says where, or null when none could be opened.
234 * "workspace" gives unrelated work its own branch: a new Superset workspace inside Superset, a
235 * new git worktree elsewhere. Otherwise a new terminal: in the Superset workspace, a tmux
236 * window, or a terminal window.
237 */
238async function openSession($: EngineInterface, held: Fresh, where: 'terminal' | 'workspace'): Promise<string | null> {
239 const { text, ws, repo } = held
240 await handOff($, text)
241 const slug = slugOf(text)
242 if (where === 'workspace' && ws) {
243 if (await ran($, ['superset', 'ws', 'create', '--local', '--project', ws.projectId, '--name', slug, '--branch', slug, '--agent', 'claude', '--prompt', text])) return `a new Superset workspace (${slug})`
244 } else if (where === 'workspace' && repo) {
245 const dir = await newWorktree($, repo, slug)
246 if (dir) {
247 const opened = await openTerminal($, dir, text)
248 if (opened) return `${opened}, on a new worktree (branch ${slug})`
249 await ran($, ['git', '-C', repo, 'worktree', 'remove', dir])
250 await ran($, ['git', '-C', repo, 'branch', '-D', slug])
251 }
252 }
253 if (ws && (await ran($, ['superset', 'agents', 'create', '--local', '--workspace', ws.id, '--agent', 'claude', '--prompt', text]))) return `a new terminal in ${ws.name}`
254 const opened = await openTerminal($, await $.session.cwd().catch(() => ''), text)
255 if (opened) return opened
256 await $.store.set('handoff', null)
257 return null
258}
259
260/**
261 * Starts the held prompt fresh: in a new session (keeping this one as it is), or, where none can be
262 * opened or the user prefers it, with /clear here.
263 */
264async function startFresh($: EngineInterface, held: Fresh, where: 'terminal' | 'workspace' = 'terminal') {
265 await update($, freshA, () => null)
266 if (settings.newSession !== 'clear') {
267 const opened = await openSession($, held, where).catch(() => null)
268 if (opened) {
269 $.ui.toast(`✨ Prompt Forge: your new task is running in ${opened}; this session stays as it was.`)
270 return
271 }
272 }
273 const { text } = held
274 try {
275 await $.command.run({ command: 'clear' })
276 await markCheap($, text, 0)
277 await $.prompt.submit({ text, asUser: true })
278 } catch (err) {
279 $.ui.toast(`✨ Prompt Forge couldn't start fresh (${String(err)}); your prompt: ${clip(text, 200)}`)
280 }
281}
282
283/** Sends a held prompt where the user is, on the model routing would pick. */
284async function sendHere($: EngineInterface, text: string) {
285 await update($, freshA, () => null)
286 await markCheap($, text)
287 await $.prompt.submit({ text, asUser: true })
288}
289
290export const register: Register = (on, options) => {
291 const mode = String(options.newSession ?? 'auto')
292 settings = {
293 newSession: (['auto', 'superset', 'tmux', 'window', 'clear'].includes(mode) ? mode : 'auto') as Launcher,
294 terminalCommand: String(options.terminalCommand ?? ''),
295 freshMinTokens: Number(options.freshMinTokens) > 0 ? Number(options.freshMinTokens) : FRESH_MIN_TOKENS,
296 }
297
298 on('session.start', async ($, e, next) => {
299 await $.command.register({
300 name: 'forge',
301 description: 'Prompt Forge: /forge on|off, /forge fresh on|off, /forge model on|off, or /forge to see its state; more in /config',
302 argumentHint: '[on|off|fresh on|off|model on|off]',
303 })
304 return next(e)
305 })
306
307 on('command.run', { command: 'forge' }, async ($, e) => {
308 const arg = e.args.trim().toLowerCase()
309 if (arg === 'on' || arg === 'off') await $.store.set('enabled', arg === 'on')
310 if (arg === 'off') await update($, freshA, () => null)
311 if (arg === 'fresh on' || arg === 'fresh off') await $.store.set('fresh', arg === 'fresh on')
312 if (arg === 'model on' || arg === 'model off') await $.store.set('route', arg === 'model on')
313 const flag = async (key: string) => ((await $.store.get(key)) !== false ? 'ON' : 'OFF')
314 return {
315 text: (await isOn($))
316 ? `✨ Prompt Forge is ON. Fresh start for new tasks in long sessions: ${await flag('fresh')} (/forge fresh on|off), ` +
317 `opening ${({ auto: 'a tmux window or a new terminal window', superset: 'Superset terminals and workspaces', tmux: 'a tmux window', window: 'a new terminal window', clear: '/clear here' } as const)[settings.newSession]} (/config → prompt-forge). ` +
318 `Sonnet for small, clear tasks on a fresh context: ${await flag('route')} (/forge model on|off). ` +
319 'Start a prompt with "raw:" to skip both.'
320 : '✨ Prompt Forge is OFF. Prompts go out exactly as typed, on your model (/forge on).',
321 }
322 })
323
324 on('prompt.submit', async ($, e, next) => {
325 // A session opened for a new task: run its first turn on Sonnet if the task is small and clear.
326 const handoff = await $.store.get('handoff')
327 if (typeof handoff === 'string' && norm(handoff) === norm(e.text)) {
328 await $.store.set('handoff', null)
329 await markCheap($, e.text, 0)
330 return next(e)
331 }
332 if (e.origin.kind !== 'composer') return next(e)
333 const typed = e.text.trim()
334 if (typed.startsWith('/')) return next(e)
335 if (RAW.test(typed)) {
336 await update($, freshA, () => null)
337 return next({ ...e, text: typed.replace(RAW, '') })
338 }
339 // A prompt held as a new task: "f" starts fresh, "h" sends it here, anything else replaces it.
340 const held = await read($, freshA)
341 if (held) {
342 await update($, freshA, () => null)
343 if (WORKSPACE.test(typed) && (held.ws || held.repo)) {
344 void startFresh($, held, 'workspace')
345 return { drop: `✨ Prompt Forge: opening a new ${held.ws ? 'Superset workspace' : 'worktree'} for your task.` }
346 }
347 if (FRESH.test(typed)) {
348 void startFresh($, held)
349 return { drop: '✨ Prompt Forge: starting your task fresh.' }
350 }
351 if (HERE.test(typed)) {
352 await markCheap($, held.text)
353 return next({ ...e, text: held.text })
354 }
355 }
356 if (!(await isOn($))) return next(e)
357
358 // A new, unrelated task in a long session: offer a fresh start, which skips re-reading the
359 // old conversation on every step. Short sessions are never asked; it's not worth a question.
360 // An image or file would be lost by holding the prompt, so those always go out as typed.
361 if (!e.attachments?.length && typed.split(/\s+/).length >= MIN_WORDS && (await $.store.get('fresh')) !== false) {
362 const tokens = await conversationTokens($)
363 if (tokens >= settings.freshMinTokens) {
364 const topic = await topicOf($, typed)
365 if (topic !== 'continues') {
366 const ws = settings.newSession === 'superset' ? await supersetWorkspace($) : null
367 const repo = (await out($, ['git', 'rev-parse', '--show-toplevel'])) || null
368 await update($, freshA, () => ({ text: e.text, tokens, topic, ws, repo }))
369 return { drop: freshNote(tokens, topic, ws ? 'superset' : repo ? 'git' : null) }
370 }
371 }
372 }
373 await markCheap($, e.text)
374 return next(e)
375 })
376
377 // Model routing: the marked prompt's turn runs on Sonnet, every request of it but subagents'.
378 on('turn.start', async ($, e, next) => {
379 if (cheapNext !== null && norm(e.text) === norm(cheapNext)) {
380 cheapTurns.add(e.turnId)
381 $.ui.toast('⚡ Prompt Forge: small, clear task on a fresh context, so this turn runs on Sonnet (/forge model off)')
382 }
383 cheapNext = null
384 return next(e)
385 })
386
387 on('turn.step', async function* ($, e, next) {
388 if (e.agentId || !cheapTurns.has(e.turnId) || /sonnet|haiku/i.test(e.model)) return yield* next(e)
389 return yield* next({ ...e, model: ROUTE_MODEL })
390 })
391
392 on('turn.complete', async ($, e, next) => {
393 cheapTurns.delete(e.turnId)
394 return next(e)
395 })
396
397 // A prompt held as a new task: the fresh-start offer, above the prompt.
398 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
399 const fresh = await read($, freshA)
400 if (!fresh || e.props.hasSurvey) return next(e)
401 const { Box, Button, Text } = $.ui.resolve(e)
402 const branch = fresh.topic === 'unrelated' ? (fresh.ws ? 'New workspace (w)' : fresh.repo ? 'New worktree (w)' : null) : null
403 return (
404 <Box flexDirection="row" gap={1} borderStyle="round" borderColor="magenta" paddingX={1}>
405 <Text bold color="magenta">✨ {fresh.topic === 'unrelated' ? 'Unrelated task?' : 'New task?'} {kTokens(fresh.tokens)} tokens of old conversation ride along</Text>
406 {branch && <Button key="workspace" label={branch} variant="primary" onPress={() => startFresh($, fresh, 'workspace')} />}
407 <Button key="fresh" label="New terminal (f)" variant={branch ? 'secondary' : 'primary'} onPress={() => startFresh($, fresh)} />
408 <Button key="here" label="Send here (h)" onPress={() => sendHere($, fresh.text)} />
409 </Box>
410 )
411 })
412}
413hooks/classify.ts 17 lines1// A prompt that names what to touch and how to tell it's done is a small, clear task: on a
2// small context it runs on Sonnet. Pure pattern matching: free, local, instant.
3const ANCHOR = [
4 /(?:^|[\s`'"(])[\w.-]+\/[\w./-]+/, // a path: src/users.js, app/models/
5 /\b[\w-]+\.(?:[jt]sx?|mjs|cjs|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|cs|php|vue|svelte|css|scss|html|json|ya?ml|toml|md|sql|sh)\b/i, // a file name
6 /`[^`\n]+`/, // inline code
7 /\b[a-z]+[A-Z]\w*\b|\b[a-z]+_[a-z_]+\b/, // camelCase or snake_case identifier
8]
9const FINISH = /\b(?:npm (?:run )?test|pnpm test|yarn test|pytest|cargo test|go test|make test|tests? (?:should |must )?pass|run (?:the )?tests?|make sure|should|must|until|so that|verify|check that|expect(?:ed)?|returns?)\b/i
10const VAGUE = /^(?:it|that|this|those|these|the same|same)\b|\b(?:like before|as before|the other one)\b/i
11
12/** Already specific: names a concrete target and a finish line, and points at nothing unresolved. */
13export function isClearEnough(text: string): boolean {
14 const t = text.trim()
15 return ANCHOR.some(re => re.test(t)) && FINISH.test(t) && !VAGUE.test(t)
16}
17types/index.d.ts 12 lines1/** The Superset workspace a session runs in. */
2export type Workspace = { id: string; name: string; projectId: string }
3
4/** A prompt held as a new task while the fresh-start offer waits. */
5export type Fresh = { text: string; tokens: number; topic: 'related' | 'unrelated'; ws: Workspace | null; repo: string | null }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'prompt-forge': { fresh: Fresh | null }
10 }
11}
12