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

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.
/serpiff [<target>] opens a review session in serpiff by running serp session add <target> --open.<target> is the session's working directory, so the current worktree is opened.#12), a branch name, or <base>...<head>.serp without a shell, so the argument is never interpreted as a command.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>.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./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.serp session status runs at a time. Requests that arrive during a run are folded into one more run after it.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.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.serp on the PATH of the Claude Code session. It starts serpiff if it is not running./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./serpiff both), a serp whose session status --json prints language; with an older one the OS language is used.serp with $.process.run, which is available there only).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).
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.
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.
MIT. See LICENSE.
hooks/register.tsx 270 lines1import 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}
270hooks/messages.ts 50 lines1// 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