SLOPSHOPPER

prompt-forge

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…

newbandcommandtoastpromptmodel
v0.11.0MITupdated 2026-10-09tomikng/prompt-forge/plugins/prompt-forge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-forge
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /forge ⎿ prompt-forge: ✨ Prompt Forge is ON. Fresh start for new tasks in long sessions: ON (/forge fresh on|off), opening a tmux win ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

✨ Prompt Forge

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.

Version Claude Code License: MIT Benchmark Stars

Install · See it · How it works · Benchmarks · Commands · Settings · 📖 Full guide


Why

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:

  • 💸 A fresh session for new tasks. When a prompt starts something new in a long session, it offers to run it in a new terminal window instead (f). Your current session stays exactly as it was. One keypress, never on a follow-up.
  • 🧭 The right place for the work. A new task on the same feature runs in a new terminal in the same folder. An unrelated one can get its own git worktree and branch (w), so it doesn't land in this branch's diff.
  • 🖥️ Works where you work. tmux, any Linux desktop terminal, macOS Terminal and Windows Terminal, or your own terminal command; with the Superset setting on, Superset terminals and workspaces. Nowhere to open one? It falls back to /clear.
  • ⚡ Sonnet for small, clear tasks. A prompt that names its file and its finish line, on a fresh context, runs that one turn on Claude Sonnet at about half the price. Your next prompt goes back to your model.
  • ✍️ Your prompts, untouched. Everything goes out exactly as you typed it, instantly. Haiku only runs one tiny check, and only in long sessions.
  • 🎯 Measured, not guessed. −41% per six-prompt session, every step still right, against a same-day baseline. The benchmarks and their caveats are below.
  • 🖼️ Keeps your images. A prompt with a pasted image or file is never held.
  • ✋ Easy to bypass. Answer h, start a prompt with raw:, or run /forge off.

See it

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>

Install

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 /plugin menu instead, paste only tomikng/prompt-forge into 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

How it works

<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 happensWhen
Held, with a fresh-session offerA 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 SonnetA 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 typedEverything else: follow-ups, short sessions, short replies, slash commands, prompts with an image or file, raw: prompts, and anything while /forge off
  • New task or follow-up? One tiny Haiku call reads the last few messages and your prompt. Anything that says "it", "that", "again" or names something from the conversation is a follow-up, and when unsure it answers follow-up: starting fresh by mistake would lose context you need.
  • Where the new session opens. Prompt Forge uses the first that works:
  • tmux: if you're in tmux, a new window running claude "<your prompt>" in the same folder.
  • A terminal window: your default terminal on Linux (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.

  • Its own branch, for unrelated work. 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.
  • In Superset (setting 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.
  • Related or unrelated? The same Haiku check also sees the current git branch: a new task on the same feature, ticket or branch is related (new terminal), anything else unrelated (w offered). When unsure it says related.
  • Only the conversation counts. Every request also carries the system prompt, tools and MCP servers (27k tokens on a bare install, often far more with plugins), which /clear can't remove. Prompt Forge takes the smallest context it has seen as that fixed part and counts only what's above it.
  • Sonnet only on a small context. The prompt cache is per model: switching a long conversation would make Sonnet re-read all of it uncached, which costs more than staying on Opus. So routing waits for a new session: the one Prompt Forge opens for your task leaves itself a note to run that first turn on Sonnet.
  • Only prompts you type are checked. Messages from other plugins, background tasks or other agents pass straight through.

Benchmarks

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 sessionMeanRange (3 sessions)Steps done rightFresh starts / Sonnet turns
Without Prompt Forge$2.20$2.13–$2.2518/18–
Fresh starts only (0.5, prompts still rewritten)$1.64$1.48–$1.7618/184 / –
Fresh starts + Sonnet (0.8+)$1.29$1.11–$1.4718/186 / 3
StepTaskWithoutWithWhat Prompt Forge did
1V1 · "can u make the orders page faster…, dont touch the api"$0.23$0.24nothing: first prompt
2V2 · "the cart total is wrong when ppl buy more than one…"$0.28$0.25fresh start in 1 of 3 sessions
3C2 · cart fix, file and test named$0.32$0.28nothing: same topic
4V3 · "signup lets ppl in with junk emails, fix that"$0.38$0.27fresh start in 2 of 3 sessions
5C1 · "In src/users.js rename getUser to fetchUser…; run npm test"$0.44$0.09fresh start, then Sonnet, in every session
6A1 · "rename it to something clearer, everywhere it's used"$0.55$0.15nothing: a follow-up, on a small conversation
  • 💸 The saving is the conversation not riding along. After the fresh start before C1, the users rename ran on Sonnet for $0.09 and the follow-up rename cost $0.15, against $0.44 and $0.55 without Prompt Forge.
  • 🎯 No harmful clears. It never started fresh before A1, which says "it" and needs the rename just before it, and A1 was right in every session.
  • ⚡ Sonnet held up. As single tasks on a fresh context, C1 and C2 cost $0.087 and $0.099 on Sonnet against $0.169 and $0.175 on Opus, every run right; in the sessions, every Sonnet turn was right too.
  • 🧮 The checks are a rounding error: about $0.007 of Haiku per session.

Before you count on the −41%

  • Small test, small project. Three sessions of six prompts on a 12-file project. On a large codebase Sonnet may need more turns for the same task, so the saving can shrink.
  • The saving comes from you pressing f. The benchmark accepted every fresh-start offer. If you usually answer h, you'll save little.
  • The new-task check isn't fully consistent. It offered a fresh start before the cart task (V2) in only 1 of 3 sessions, and before the signup task (V3) in 2 of 3. On your own work, answer h whenever the new task needs earlier context.
  • Same-day comparisons. Every dollar figure is against a no-Prompt-Forge baseline run the same day (2026-10-09).

How we got here

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

  • Models: Claude Code with Claude Opus 5.5 does the work; Claude Haiku 4.5 runs the new-task check; Claude Sonnet 5.5 runs the routed turns. Dollar amounts come from Claude Code's own cost accounting (claude -p --output-format json), at list prices.
  • Sessions: each step ran with 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.
  • Every number is in 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

Commands

CommandWhat it does
f / w / hWhile 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 |

Settings

In /config under prompt-forge, or in settings.json:

SettingValuesDefaultWhat it does
newSessionauto, superset, tmux, window, clearautoWhere 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).
terminalCommanda commandnoneOpens 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} --.
freshMinTokens5000–50000030000How 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" } }
  }
}

Cost and privacy

  • Haiku runs only in long sessions: one tiny new-task check per prompt once the conversation passes 30k tokens, about $0.001–0.002 each (≈ $0.007 per benchmark session). Short sessions, short replies and commands cost nothing. Calls go through your own Claude Code session and are billed like the rest of your usage.
  • Sonnet turns are billed at Sonnet's price, like any /model sonnet turn.
  • What Haiku sees: the new-task check, your prompt, and the text of the last few messages (at most 6 messages and about 3,000 characters). No files, tool output or attachments.
  • Nothing leaves your machine any other way. No telemetry, no third-party services. The switches are stored in the plugin's own store under ~/.claude/plugins/store/.

FAQ

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.

Development

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.

License

MIT © tomikng

Source 3 files
hooks/register.tsx 413 lines
1import { 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}
413
hooks/classify.ts 17 lines
1// 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}
17
types/index.d.ts 12 lines
1/** 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