SLOPSHOPPER

first-prompt-model

Haiku reads the first prompt of a session and picks the main-loop model for the session

newtoaststatuspromptmodel
★ 2v0.2.0Apache-2.0updated 2026-10-06popoffvg/dotfiles/harness/plugins/first-prompt-model
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · first-prompt-model
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ first-prompt-model │ ⏺ Read(src/auth.ts) │ first-prompt-model: Haiku gave no model, │ ⎿ Read 6 lines │ the session model stays │ ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dotfiles

Personal dotfiles. Managed with GNU Stow + mise. Repo layout mirrors ~/ — stow symlinks files into place.

Structure

.
├── .config/             # XDG configs: nvim, helix, fish, git, zellij, ghostty, atuin, direnv, nix, sketchybar, worktrunk
├── .claude/             # Claude Code project-local overrides (settings.local.json)
├── .zshrc, .zshrc_*     # Modular zsh config (aliases, git, go, mise, fish, ...)
├── .tmux.conf
├── .wezterm.lua
├── .aerospace.toml
├── .mise.toml           # mise tasks (incl. unified `stow`)
├── .mcp.json            # global MCP server config
├── .stow-local-ignore   # exclusions for repo-root stow package
│
├── harness/             # Per-harness packages (separate stow targets)
│   ├── claude/          # → ~/.claude   (settings.json, hooks, agents, commands, scripts, skills, CLAUDE.md, RTK.md, agent-settings)
│   ├── plugins/         # Plugin source dirs (wm, self-improvement); NOT stowed directly
│   └── scripts/         # sync-marketplace.sh — regenerates /.claude-plugin/marketplace.json + /plugins/ symlinks
│
├── ansible/             # Package installation playbook (run in-place)
├── scripts/             # Misc shell scripts (not stowed)
├── hammerspoon/         # macOS automation Lua
├── raycast/             # Raycast extensions
├── alacritty/, vscode/  # legacy app configs
└── .notes/              # WM notes (gitignored runtime)

Stow targets

SourceTargetContents
repo root~shell, .config/*, .tmux.conf, etc. (gated by .stow-local-ignore)
harness/claude~/.claudeClaude Code user config + plugin marketplace

Install

Prerequisites: nix, mise, stow, ansible.

# 1. Nix-darwin (Mac system packages)
nix run nix-darwin -- switch --flake .config/nix-darwin

# 2. Other packages via ansible
ansible-playbook ansible/install_packages.yaml

# 3. Symlink everything into ~ (repo root + harness/claude)
mise run stow

To remove all symlinks:

mise run unstow

Claude Code plugins

Two registries, both in harness/claude/settings.json (stowed to ~/.claude/settings.json):

  • extraKnownMarketplaces — where plugins come from. This repo's own plugins ship via the local-plugins directory marketplace (source.path = repo root, so Claude reads /.claude-plugin/marketplace.json directly). External plugins point at GitHub repos.
  • enabledPlugins — which plugins are on. Each entry is "<name>@<marketplace>": true|false.

Install a plugin

// harness/claude/settings.json
"extraKnownMarketplaces": {
  "ponytail": { "source": { "source": "github", "repo": "DietrichGebert/ponytail" } }
},
"enabledPlugins": {
  "ponytail@ponytail": true,        // external plugin
  "wm@local-plugins": true          // local plugin from this repo
}

Then mise run stow (to symlink the edited settings) and /reload-plugins in Claude Code — or do both steps interactively with /plugin. After enabling, the first launch caches the plugin under ~/.claude/plugins/cache/.

Local plugins are disabled by default in committed settings (wm@local-plugins: false) — flip to true to turn one on.

Author a local plugin (in this repo)

Each plugin directory under harness/plugins/<name>/ is the plugin root — markdown only (agents, commands, hooks, skills), no claude/ wrapper, no build step. harness/scripts/sync-marketplace.sh (run via mise run harness:plugins:sync) regenerates /.claude-plugin/marketplace.json with one entry per plugin, source: ./harness/plugins/<name> pointing directly at the dir — no symlink layer. Edit the source, re-sync, then /reload-plugins.

Source 2 files
hooks/register.ts 93 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ModelPick } from '../types'
5
6const MODEL_CHOICES = [
7  { model: 'haiku', use: 'a short question, a lookup, a one-line edit, a rename, a format change' },
8  { model: 'sonnet', use: 'ordinary coding: a feature, a bug fix, tests, a refactor in a few files' },
9  { model: 'opus', use: 'hard work: architecture, design, a deep debug, a large refactor, research across many files' },
10] as const
11
12const RECOMMENDED_SUFFIX = ' (Recommended)'
13
14const pick = atom({ plugin: 'first-prompt-model', key: 'pick' } as const, null)
15const isDecided = atom({ plugin: 'first-prompt-model', key: 'isDecided' } as const, false)
16
17const ROUTER_SYSTEM = [
18  'You choose the model that will execute a coding-assistant prompt.',
19  'Choose the cheapest model that can do the task well.',
20  ...MODEL_CHOICES.map(c => `- ${c.model}: ${c.use}`),
21  `Answer with one word: ${MODEL_CHOICES.map(c => c.model).join(', ')}.`,
22].join('\n')
23
24export function parseModelAnswer(text: string): string | undefined {
25  const word = text.trim().toLowerCase().match(/[a-z]+/)?.[0]
26  return MODEL_CHOICES.find(c => c.model === word)?.model
27}
28
29export function modelOptions(suggested: string, sessionModel: string): string[] {
30  const others = MODEL_CHOICES.map(c => c.model).filter(m => m !== suggested)
31  return [suggested + RECOMMENDED_SUFFIX, ...others, `Keep ${sessionModel}`]
32}
33
34export function modelFromAnswer(answer: string): string | undefined {
35  const label = answer.endsWith(RECOMMENDED_SUFFIX) ? answer.slice(0, -RECOMMENDED_SUFFIX.length) : answer
36  return MODEL_CHOICES.find(c => c.model === label)?.model
37}
38
39export const register: Register = on => {
40  on('prompt.submit', async ($, e, next) => {
41    if (e.origin.kind !== 'composer' || (await read($, isDecided))) {
42      return next(e)
43    }
44    await update($, isDecided, () => true)
45    if ((await $.session.turns()) > 0) {
46      return next(e)
47    }
48
49    $.ui.status('first-prompt-model: Haiku is choosing a model')
50    const reply = await $.model.complete({
51      model: 'haiku',
52      system: ROUTER_SYSTEM,
53      prompt: `<prompt>\n${e.text}\n</prompt>`,
54      effort: 'low',
55      maxTokens: 16,
56      timeoutMs: 15000,
57    })
58    $.ui.status(undefined)
59    const suggested = reply.isAnswered ? parseModelAnswer(reply.text) : undefined
60    if (suggested === undefined) {
61      $.ui.toast('first-prompt-model: Haiku gave no model, the session model stays')
62      return next(e)
63    }
64
65    const sessionModel = await $.session.model()
66    const answer = await $.ui
67      .ask('Which model must run this session?', {
68        header: 'Model',
69        options: modelOptions(suggested, sessionModel),
70      })
71      .catch(() => '')
72    const model = modelFromAnswer(answer)
73    if (model === undefined) {
74      return next(e)
75    }
76
77    const chosen: ModelPick = { model, sessionModel }
78    await update($, pick, () => chosen)
79    $.ui.status(`model: ${model}`)
80    return next(e)
81  }).catch(($, e, next) => next(e))
82
83  on('turn.step', async function* ($, e, next) {
84    const chosen = await read($, pick)
85    const isMainLoop = e.agentId === undefined
86    const isModelUnchanged = chosen !== null && (await $.session.model()) === chosen.sessionModel
87    if (!isMainLoop || !isModelUnchanged) {
88      return yield* next(e)
89    }
90    return yield* next({ ...e, model: chosen.model })
91  })
92}
93
types/index.d.ts 11 lines
1export type ModelPick = {
2  model: string
3  sessionModel: string
4}
5
6declare module 'claude-code' {
7  interface PluginState {
8    'first-prompt-model': { pick: ModelPick | null; isDecided: boolean }
9  }
10}
11