SLOPSHOPPER

serpiff

Use serpiff, a local code review tool, from Claude Code sessions.

newspinnercommandprocesstimer
★ 12v0.3.0MITupdated 2026-10-26serpiff/serpiff/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · serpiff
› 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 › /serpiff ⎿ serpiff: Opened in serpiff. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

serpiff for Claude Code

English | 日本語

A Claude Code mod (a function-hooks plugin) that lets a Claude Code session use serpiff, a local code review tool.

The Claude Code mod API is in early access and may change between releases.

What it does

  • /serpiff [<target>] opens a review session in serpiff by running serp session add <target> --open.
  • With no argument (or only whitespace), <target> is the session's working directory, so the current worktree is opened.
  • With an argument, it is trimmed and passed as one value: a PR (#12), a branch name, or <base>...<head>.
  • The command runs serp without a shell, so the argument is never interpreted as a command.
  • The output of serp (stdout, then stderr) is shown in the session. Nothing is sent to the model. When serpiff runs as the app, serp returns as soon as the app accepts the request (Opening in serpiff…); notes and failures found by the app (for example, a branch that does not exist) are shown in the app. If serp succeeds without output, it shows Opened in serpiff. If serp exits with a non-zero code, the output is serp exited with <code> followed by its stderr (or stdout when stderr is empty). If serp cannot start or is still running after 60 seconds, the output is Could not run serp: <reason>.
  • The prompt footer's mode labels (the dim text at the right end, beside focus and the like) show how far the review of the session's worktree has come, from serp session status <cwd> --json (10 second timeout, <cwd> read again each time):
  • <label> · <files_viewed>/<files_total> (<viewed>/<total>) viewed (e.g. PR #42 · 12/30 (76/88) viewed: 12 of 30 files, 76 of 88 units such as declarations), in the same form when everything is Viewed (PR #42 · 30/30 (88/88) viewed), and <label> · no changes when there are none. <label> is PR #<n> for a PR session and the branch name for a branch session. It is added after the other modes (joined by & ), so it takes no extra line and has no warning mark. The label and no changes are dim like the other modes; the progress (12/30 (76/88) viewed) is the theme's suggestion color (blue), or success (green) when everything is Viewed, so they follow the light and dark themes. Only the terminal and desktop screens draw it, and the footer is drawn again only when the text changes.
  • It updates when the session starts, every 30 seconds, at the end of each turn of the main loop (not a subagent's), and right after /serpiff and again 5 seconds later (with the app, the session is created just after serp session add --open returns). Updates never hold anything back: the session start, the end of a turn and the output of /serpiff do not wait for serp.
  • Only one serp session status runs at a time. Requests that arrive during a run are folded into one more run after it.
  • If serpiff is not running, has no session for the worktree, or serp fails or times out, no mode is added. The mod never starts serpiff for this (serp session status does not), and keeps asking so the mode appears once a session exists.
  • The mod's own texts (the footer's viewed / no changes, the /serpiff description, Opened in serpiff., serp exited with <code> and Could not run serp: <reason>) follow serpiff's display language, English or Japanese. The language is read from the language field of the same serp session status <cwd> --json output (an unknown value is English), so it follows the app's language setting within 30 seconds or at the end of a turn; /serpiff is registered again with a description in the new language when it changes. When it cannot be read (serpiff is not running, serp fails, or an older serp without the field), the OS language is used (the first entry of defaults read -g AppleLanguages, asked once per load of the mod; Japanese if its language part is ja, as in ja-JP). The <label> (PR #<n> or the branch name), the argument hint and the output of serp itself are not translated.

Requirements

  • serp on the PATH of the Claude Code session. It starts serpiff if it is not running.
  • Claude Code 2.1.296 (the version this mod was checked with).
  • serp 0.1.0 or later for /serpiff; serp 0.1.3 or later for the progress mode (the first release with serp session status). With an older serp the mode is not shown and /serpiff works as before.
  • For the texts to follow serpiff's language (the footer and /serpiff both), a serp whose session status --json prints language; with an older one the OS language is used.
  • A CLI session (the mod runs serp with $.process.run, which is available there only).

Install

In a Claude Code terminal session, run:

/plugin install serpiff --marketplace serpiff/serpiff

Answer y when asked whether to add the marketplace, then choose a scope (the default is user).

Developing

To load the mod from a checkout, for one run:

claude --plugin-dir <path-to-this-folder>

For every session (the value is an absolute path, ~ allowed; separate several with the OS path separator, : on macOS and Linux), set CLAUDE_CODE_PLUGIN_DIRS in the env of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "<path-to-this-folder>"
  }
}

When an interactive session loads the mod with --plugin-dir, the engine writes type definitions to .claude-plugin/types/ and, since the mod has no tsconfig.json, a tsconfig.json that extends them. Both are generated and listed in this folder's .gitignore. Nothing is written by -p runs or by claude plugin validate / claude plugin test.

Checks

claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>

tests/serpiff.test.ts runs against the engine with serp replaced by a test hook. Optionally, after an interactive session has loaded the mod with --plugin-dir (which writes the type definitions), tsc -p <path-to-this-folder> type-checks it.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 270 lines
1import type { Hook, Register } from 'claude-code'
2import { fromAppleLanguages, fromCode, t, type Language } from './messages'
3
4type Engine = Parameters<Hook<'session.start'>>[0]
5
6type Kind = { type: 'pull_request'; number: number } | { type: 'local_branch'; name: string }
7
8// What the footer shows for the session: the parts of the text, so the render hook can
9// color each one.
10type Progress = {
11  label: string
12  state: 'progress' | 'all' | 'none'
13  viewed: number
14  total: number
15  filesViewed: number
16  filesTotal: number
17}
18
19// The footer parts for `serp session status --json`, or undefined when the output is not
20// the shape we know (nothing is added then).
21const isCount = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0
22
23const statusProgress = (data: unknown): Progress | undefined => {
24  if (typeof data !== 'object' || data === null) return undefined
25  const { kind, total, viewed, files_total: filesTotal, files_viewed: filesViewed } = data as {
26    kind?: Kind
27    total?: unknown
28    viewed?: unknown
29    files_total?: unknown
30    files_viewed?: unknown
31  }
32  if (!isCount(total) || !isCount(viewed) || !isCount(filesTotal) || !isCount(filesViewed)) return undefined
33  let label: string
34  if (kind?.type === 'pull_request' && Number.isInteger(kind.number)) {
35    label = `PR #${kind.number}`
36  } else if (kind?.type === 'local_branch' && typeof kind.name === 'string' && kind.name !== '') {
37    label = kind.name
38  } else {
39    return undefined
40  }
41  const counts = { viewed, total, filesViewed, filesTotal }
42  if (total === 0) return { label, state: 'none', ...counts }
43  // Green only when both the units and the files are done; a broken invariant (29/30 files
44  // with every unit Viewed) stays blue, like the web header's file-based completion.
45  if (viewed >= total && filesViewed >= filesTotal) return { label, state: 'all', ...counts }
46  return { label, state: 'progress', ...counts }
47}
48
49const isSame = (a: Progress | undefined, b: Progress | undefined) =>
50  a === b ||
51  (a !== undefined &&
52    b !== undefined &&
53    a.label === b.label &&
54    a.state === b.state &&
55    a.viewed === b.viewed &&
56    a.total === b.total &&
57    a.filesViewed === b.filesViewed &&
58    a.filesTotal === b.filesTotal)
59
60// Module state: a reload starts these over, together with the timers.
61let isRunning = false
62let isPending = false
63let isTimerStarted = false
64
65// What the footer shows now. A module variable is enough: only the SessionMode hook reads
66// it, and a reload starts it over (session.start comes again and refreshes it).
67let progress: Progress | undefined
68
69// The language of the mod's texts. English until the first refresh decides; after that it is
70// what `session status` said, or the OS language when that run could not say.
71// `isLanguageKnown` is true only while the last run got it from `session status`.
72let language: Language = 'en'
73let isLanguageKnown = false
74// The language the `/serpiff` command was registered with.
75let registeredLanguage: Language | undefined
76
77// The OS language, asked once per module load (concurrent askers share the Promise).
78// A failure is English.
79let osLanguage: Promise<Language> | undefined
80const askOsLanguage = ($: Engine): Promise<Language> => {
81  osLanguage ??= (async () => {
82    try {
83      const result = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], {
84        timeoutMs: 5_000,
85      })
86      return result.exitCode === 0 ? fromAppleLanguages(result.stdout) : 'en'
87    } catch {
88      return 'en'
89    }
90  })()
91  return osLanguage
92}
93
94// The language a registration is in flight for. A registration that never returns must not
95// pile up one more with every refresh: the same language is not started twice.
96let registeringLanguage: Language | undefined
97
98const registerCommand = async ($: Engine) => {
99  const wanted = language
100  if (registeringLanguage === wanted) return
101  registeringLanguage = wanted
102  try {
103    await $.command.register({
104      name: 'serpiff',
105      description: t(wanted, 'command.description'),
106      argumentHint: '[<pr>|<branch>|<base>...<head>]',
107    })
108    registeredLanguage = wanted
109  } catch {
110    // A failed (re)registration must not break the footer. `registeredLanguage` stays as it was,
111    // so every later refresh tries again until it succeeds.
112  } finally {
113    if (registeringLanguage === wanted) registeringLanguage = undefined
114  }
115}
116
117// What one `session status` run told: the language (undefined when unknown) and the progress.
118type Status = { language: Language | undefined; progress: Progress | undefined }
119
120const readStatus = (stdout: string): Status => {
121  const data: unknown = JSON.parse(stdout)
122  const code =
123    typeof data === 'object' && data !== null ? (data as { language?: unknown }).language : undefined
124  return {
125    language: typeof code === 'string' ? fromCode(code) : undefined,
126    progress: statusProgress(data),
127  }
128}
129
130// Ask serp for the Viewed progress of the session's cwd and keep it for the footer.
131// Anything that goes wrong removes the mode; the language then falls back to the OS's.
132const refreshOnce = async ($: Engine) => {
133  let status: Status = { language: undefined, progress: undefined }
134  try {
135    const cwd = await $.session.cwd()
136    const result = await $.process.run(['serp', 'session', 'status', cwd, '--json'], {
137      timeoutMs: 10_000,
138    })
139    if (result.exitCode === 0) status = readStatus(result.stdout)
140  } catch {
141    // unknown language, no mode
142  }
143  try {
144    const nextLanguage = status.language ?? (await askOsLanguage($))
145    // With no mode before and after, nothing is drawn, so a language change alone is no change.
146    const isChanged =
147      !isSame(status.progress, progress) ||
148      (nextLanguage !== language && status.progress !== undefined)
149    language = nextLanguage
150    isLanguageKnown = status.language !== undefined
151    progress = status.progress
152    // Draw the footer again, only when what it shows changed.
153    if (isChanged) $.ui.invalidate('ui.render')
154    // Not awaited: a stuck `$.command.register` must not hold the one-at-a-time refresh.
155    // Runs may overlap and finish out of order, leaving `registeredLanguage` behind the
156    // engine's registration; the next refresh sees the mismatch and registers again (this assumes
157    // the engine applies the registration called later after the one called earlier).
158    if (language !== registeredLanguage) void registerCommand($)
159  } catch {
160    // nothing more to do
161  }
162}
163
164// Only one run at a time. A request that arrives during a run only marks it pending;
165// when the run ends, one more run covers all of them.
166const refresh = async ($: Engine) => {
167  if (isRunning) {
168    isPending = true
169    return
170  }
171  isRunning = true
172  try {
173    do {
174      isPending = false
175      await refreshOnce($)
176    } while (isPending)
177  } finally {
178    isRunning = false
179  }
180}
181
182export const register: Register = on => {
183  // The footer's mode labels: draw the engine's own as it does (dim, joined by " & "), then
184  // ours. `modes` is strings only and cannot carry a color, so the tree is drawn here.
185  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
186    const current = progress
187    if (current === undefined) return next(e)
188    const { Box, Text } = $.ui.resolve(e)
189    const lead = [...e.props.modes, ''].join(' & ')
190    if (current.state === 'none') {
191      return (
192        <Box flexDirection="row" flexWrap="nowrap">
193          <Text dimColor>{`${lead}${current.label} · ${t(language, 'footer.none')}`}</Text>
194        </Box>
195      )
196    }
197    const isAll = current.state === 'all'
198    return (
199      <Box flexDirection="row" flexWrap="nowrap">
200        <Text dimColor>{`${lead}${current.label} · `}</Text>
201        <Text color={isAll ? 'success' : 'suggestion'}>
202          {t(language, 'footer.progress', current)}
203        </Text>
204      </Box>
205    )
206  })
207
208  on('session.start', async ($, e, next) => {
209    await registerCommand($)
210    // One timer per module load, even if session.start fires again.
211    if (!isTimerStarted) {
212      isTimerStarted = true
213      $.clock.every(30_000, () => {
214        void refresh($)
215      })
216    }
217    // Not awaited: session.start is awaited before the first prompt, and serp may hang.
218    void refresh($)
219    return next(e)
220  })
221
222  on('turn.complete', async ($, e, next) => {
223    // Subagent turns are not the main loop's.
224    if (e.agentId === undefined) void refresh($)
225    return next(e)
226  })
227
228  on('command.run', { command: 'serpiff' }, async ($, e) => {
229    const out = await runSerpiff($, e.args)
230    // The output is unchanged and not held back. With the app, `serp session add --open`
231    // returns once the app accepts the request and the session is made after that,
232    // so ask right away and again after 5 seconds.
233    void refresh($)
234    $.clock.after(5_000, () => {
235      void refresh($)
236    })
237    return out
238  })
239}
240
241// The language of `/serpiff`'s sentences: the one `session status` gave. Before any run has
242// decided, the OS's, as `serp`'s own stderr is written in it when it cannot read serpiff's.
243// The OS question starts before `serp` runs, but is only awaited after it: a status run that
244// decides meanwhile wins, and the wait never delays `serp`.
245const runSerpiff = async ($: Engine, args: string): Promise<{ text: string }> => {
246  const asked = isLanguageKnown ? undefined : askOsLanguage($)
247  const decide = async () => (isLanguageKnown || asked === undefined ? language : await asked)
248  try {
249    // The target is one argv element: no shell, no splitting.
250    const target = args.trim() || (await $.session.cwd())
251    const result = await $.process.run(['serp', 'session', 'add', target, '--open'], {
252      timeoutMs: 60_000,
253    })
254    const stdout = result.stdout.trim()
255    const stderr = result.stderr.trim()
256
257    if (result.exitCode === 0) {
258      const text = [stdout, stderr].filter(s => s !== '').join('\n')
259      return { text: text || t(await decide(), 'run.opened') }
260    }
261
262    const detail = stderr || stdout
263    const head = t(await decide(), 'run.exited', { code: result.exitCode })
264    return { text: detail ? `${head}\n${detail}` : head }
265  } catch (error) {
266    const reason = error instanceof Error ? error.message : String(error)
267    return { text: t(await decide(), 'run.failed', { reason }) }
268  }
269}
270
hooks/messages.ts 50 lines
1// The mod's own texts in the two languages serpiff shows (design.md §35 "文言の言語").
2// The mod does not read anything outside this folder, so the dictionary lives here.
3
4export type Language = 'en' | 'ja'
5
6const messages = {
7  en: {
8    'footer.progress': '{filesViewed}/{filesTotal} ({viewed}/{total}) viewed',
9    'footer.none': 'no changes',
10    'command.description': 'Open this worktree (or a PR / branch) as a review session in serpiff',
11    'run.opened': 'Opened in serpiff.',
12    'run.exited': 'serp exited with {code}',
13    'run.failed': 'Could not run serp: {reason}',
14  },
15  ja: {
16    'footer.progress': '{filesViewed}/{filesTotal} ({viewed}/{total})がViewed',
17    'footer.none': '変更なし',
18    'command.description': 'このワークツリー(またはPR / ブランチ)をserpiffのレビューセッションとして開く',
19    'run.opened': 'serpiffで開きました。',
20    'run.exited': 'serpが終了コード{code}で終了しました',
21    'run.failed': 'serpを実行できませんでした({reason})',
22  },
23} as const satisfies Record<Language, Record<string, string>>
24
25export type MessageKey = keyof (typeof messages)['en']
26
27export const dictionary: Record<Language, Record<MessageKey, string>> = messages
28
29// One scan over the template: a `{name}` inside an inserted value is never looked at again,
30// and a name without a value stays as written.
31export const t = (
32  language: Language,
33  key: MessageKey,
34  values: Record<string, string | number> = {},
35): string =>
36  dictionary[language][key].replace(/\{(\w+)\}/g, (whole, name: string) =>
37    Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : whole,
38  )
39
40// `ja` is Japanese; any other value, known or not, is English.
41export const fromCode = (code: string): Language => (code === 'ja' ? 'ja' : 'en')
42
43// The first element of `defaults read -g AppleLanguages` output, e.g. `(\n    "ja-JP"\n)`.
44// Like the server's `Lang::from_locale`: the part before the first `-` `_` `.` `@`, compared
45// without regard to case, is `ja` for Japanese; anything else is English.
46export const fromAppleLanguages = (stdout: string): Language => {
47  const first = /^\s*\(\s*"?([^",\s)]+)/.exec(stdout)?.[1] ?? ''
48  return first.split(/[-_.@]/)[0].toLowerCase() === 'ja' ? 'ja' : 'en'
49}
50