Reports every Claude Code session (project, branch, prompts, tool calls, errors) to ~/.claude/overseer/sessions for the overseer terminal; colours the iTerm2…

One terminal that watches all your Claude Code sessions: which session works on which project and task, where it is stuck, and for each problem a concrete fix and next step. The overseer only reads. It never writes into a session, never stops one and never steps in. Its suggestions are shown to you alone.
It has two parts:
hooks/): a Claude Code mod that runs in every session. It writes the session's events as JSON Lines to ~/.claude/overseer/sessions/<session-id>.jsonl, colours the iTerm2 tab per project and shows a coloured line above the prompt.cli/overseer.mjs): a Node script with no dependencies that you run in its own terminal. It follows those files and draws the overview.The reporter, at the prompt of a Claude Code terminal session:
/plugin install overseer --marketplace DYAI2025/claude-overseer
Answer y to add the marketplace and choose the user scope, so the reporter runs in every session from then on. From a shell:
claude plugin install overseer --marketplace DYAI2025/claude-overseer
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with DYAI2025/claude-overseer, then Add plugin.
Sessions started after the install report automatically; a running session picks the plugin up with /reload-plugins. Uninstall: /plugin uninstall overseer@claude-overseer.
The overseer is a Node script (Node 20 or later, no dependencies). Clone the repository where the commands below expect it:
git clone https://github.com/DYAI2025/claude-overseer ~/Projects/claude-overseer
Working on the code: install from the clone instead, then edits arrive through /reload-plugins with no reinstall:
claude plugin marketplace add ~/Projects/claude-overseer
claude plugin install overseer@claude-overseer --scope user
Mods (function hooks) are an early-access part of Claude Code. Built and tested on Claude Code 2.1.310.
In a terminal of its own:
node ~/Projects/claude-overseer/cli/overseer.mjs
It redraws every 2 seconds and on each file change and fits the frame to the window (problems that do not fit are counted as + N weitere); q quits. A problem that is new, or whose count goes up, rings the terminal bell (iTerm2 marks the tab); problems already there when the overseer starts do not. --quiet turns the bell off.
| Option | Effect |
|---|---|
--once | print one snapshot and exit |
--all | also show sessions that ended over 10 min ago or went quiet over 1 h ago |
--no-color | plain text |
--dir <path> | read another sessions folder (default ~/.claude/overseer/sessions, or $OVERSEER_DIR) |
--no-mouse | leave the mouse to the terminal (its own text selection); the number keys still copy |
Copying an answer: a session that has answered shows a button ⧉1, ⧉2, … at the start of its row. A click on it, or its number key, puts that session's last answer on the clipboard (pbcopy), and a line under the sessions says what was copied. The answer is the turn's final visible text as Claude Code hands it to the mod, kept whole up to 64 KB and masked like everything else: an e-mail address or a key in it arrives as [EMAIL], [REDACTED] and the like. While the overseer has the mouse, iTerm2 selects text with ⌥ held down.
An alias helps: alias overseer='node ~/Projects/claude-overseer/cli/overseer.mjs'.
As a permanent window in iTerm2: install the profile with
sed "s#__HOME__#$HOME#g" iterm2/overseer-profile.json > "$HOME/Library/Application Support/iTerm2/DynamicProfiles/overseer.json"
iTerm2 picks it up at once and lists a profile "Overseer" (red tab) that runs the overseer with Homebrew's node@24 (change the path in the file for another Node). Then, in iTerm2:
Keeping it tidy: launchd/claude-overseer.cleanup.plist deletes session files and kept answers untouched for 14 days, daily at 4:00 and at login (log: ~/.claude/overseer/cleanup.log). A running session rewrites its file every 30 s, so only ended sessions go. Install it with
sed "s#__HOME__#$HOME#g" launchd/claude-overseer.cleanup.plist > ~/Library/LaunchAgents/claude-overseer.cleanup.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/claude-overseer.cleanup.plist
and remove it with launchctl bootout gui/$(id -u)/claude-overseer.cleanup.
Start iTerm2 from the Dock or Spotlight, never from inside a Claude Code session: launched from there it carries that session's environment, including CLAUDE_CODE_CHILD_SESSION, and Claude Code sessions in it stop saving transcripts.
OVERSEER · 2 aktiv / 3 Sessions · 2 Probleme 22:57:57
────────────────────────────────────────────────────────
⧉1 ● GELB web-store feature/login e923 Fehler vor 21 s Run the command `node --test …
↳ Bash Run login test, run 3 of 3
⧉2 ● BLAU order-api main e0bf Fehler vor 23 s Run the command `ls /does-not…
PROBLEME (2)
✖ [GELB | web-store | e923] Tests schlagen 3x fehl (seit 46 s)
Befund: node --test login.test.mjs → not ok 1 - login redirects to /dashboard
Vorschlag: …
Nächster Schritt: Schreib der Session: "Stopp. Führ nur den ersten fehlschlagenden Test aus …"
Per session: colour, project (folder name), git branch, short session id, status, last activity and the current task: the newest prompt that is not a slash command and not made only of confirmations ("ok", "ja, mach weiter bitte", "yes, go ahead").
Status
| Status | Meaning |
|---|---|
| läuft | a turn is running and moving |
| bereit | the turn is done, the session waits for your next prompt |
| wartet | a permission dialog or a question (AskUserQuestion, plan approval) waits for you |
| hängt | a turn has made no progress (no tool call, no answer) for over 10 min |
| Fehler | at least one problem of severity error |
| inaktiv | no heartbeat for over 5 min (heartbeats come every 30 s) |
| beendet | the session ended (shown for 10 min) |
Problems detected
| Problem | Rule |
|---|---|
| Tests schlagen Nx fehl | 2 or more test runs in a row failed since the last passing one (error from 3), the last within 15 min, and no newer prompt has been answered since |
| Gleicher Fehler Nx hintereinander | the newest 3+ tool results are errors with the same message (numbers folded), no success in between |
| Wiederholte Tool-Fehler | 5+ tool errors within 10 min, not counting failed test runs |
| Seit N min kein Fortschritt | a turn is running with no progress for over 10 min |
| Wartet auf deine Freigabe / Antwort | as above, while a permission dialog or a question is open |
| API-Fehler / Verweigerung | a turn ended with an API error or a refusal, and no answer has gone through since (a running turn that works again also clears it) |
| Kein Lebenszeichen | heartbeats stopped in the middle of a turn without the session ending |
A test run is recognised by the runner at the head of a command segment (pytest, npm test, ./gradlew test, uv run pytest, cd x && npm t, …), not by a word anywhere in the line, so pip install pytest or git commit -m "fix jest" are no test runs. Whether it failed comes from the runner's own verdict in the output (3 failed, not ok, # fail 2, …); the exit status counts only where it is the runner's, so npm test | tail and npm test && git commit are judged by the output alone.
A tool call you or a hook denied is never counted as an error: the reporter marks it, and the overseer also recognises the wording of a denial from the permission dialog, a PreToolUse hook or Esc. The fix suggestion is chosen from the error message only, never from the command or a file name. Each problem names the session as [COLOUR | project | id] and carries a finding, a fix and a next step; the next step is often a sentence to paste into that session. The rules are in cli/lib/analyze.mjs and cli/lib/suggest.mjs.
Events, one JSON object per line, each with v, ts (ms) and type:
| type | fields |
|---|---|
session_start | sid, short, cwd, pkey (hash of the folder), project, branch, colorIndex, color, hex, source (startup, reload, clear, resume), previous (the id before a /clear or /resume), interactive, terminal, iterm |
user_prompt | text (cut to 300 characters), command for a slash command |
tool_call | id, tool, desc (short description), cmd (Bash only), agent (subagent) |
tool_ok / tool_error | id, tool; error (head and tail, 400 characters), denied; out on tool_ok for Bash commands that may run tests (tail, 300 characters) |
turn_start / turn_end | reason, ms |
error | kind (api_error, refusal), error |
attention | kind (permission_prompt, idle_prompt, …), text |
heartbeat | every 30 s with the current branch; consecutive ones are folded into one line |
meta | branch when it changes |
session_end | reason |
Beside them, answers/<session-id>.md holds the session's last main-loop answer for the copy button, rewritten at the end of each answered turn.
tool_ok, turn_start/turn_end, attention and meta go beyond the minimum: without them "hängt" and "wartet auf Freigabe" could not be told apart from a session that is simply done.
Secrets are masked before anything is written (hooks/lib/redact.ts, cases in tests/redact.test.ts): PEM and PGP private key blocks; Anthropic, OpenAI, GitHub, GitLab, Hugging Face, Slack, npm, SendGrid, AWS, Google and Stripe tokens; JWTs; Bearer/Basic headers; credentials in URLs, webhook URLs (Slack, Discord, Telegram) and signed-URL parameters; curl -u user:pw, mysql -pPW, echo pw | sudo -S, SQL PASSWORD '…'; assignments whose name ends in password, Passwort, Kennwort, secret, token, key and the like, quoted values with spaces included; --password, --client-secret, --auth-token style flags. They become [REDACTED]; e-mail addresses become [EMAIL], in folder paths too. This is pattern matching: a secret in a format it does not know is not caught.
Never in the way: hooks do not wait for git, the store or the disk; session.start and every prompt return at once, and the file is written beside the session. Lines the overseer judges by (errors, turn ends) are written at once, the rest 750 ms later. Only session.end waits, within its own 1.5 s bound, for the last write and the tab reset. Every failure goes to the debug log (claude --debug), nothing is thrown, and a failing write cannot block a tool call (tested). Redaction runs on at most 16 KB per text, so a long output costs milliseconds. A file keeps at most 1500 lines (the first session_start and the newest). After /clear or /resume the reporter continues in a new file under the new session id.
Colours: 8 colours (BLAU, ORANGE, GRÜN, LILA, GELB, TÜRKIS, PINK, BRAUN; red is kept for errors). A project's colour comes from an FNV-1a hash of its folder path. Its first session keeps it in the plugin's store (under the hash, not the path), so it stays the same from session to session. When a running session of another project holds that colour, the session gets the next free one for its own lifetime only; the stored colour does not change.
iTerm2: when TERM_PROGRAM=iTerm.app (or LC_TERMINAL=iTerm2) and not inside tmux, the reporter sets the tab colour (proprietary OSC 6), the tab title (OSC 1, ● project) and two user variables, overseer_project and overseer_color (OSC 1337 SetUserVar). Control characters are removed from the folder name first, so a name cannot smuggle escape sequences into the terminal; the overseer cleans everything it reads the same way. When the session ends the tab colour is reset and the user variables are cleared. In any other terminal this is skipped.
Checked live in iTerm2 3.7.6: the tab takes the project colour (it stays, paler, when the tab is not the active one), and both user variables are set. The OSC 1 title stays in some sessions and is replaced by Chat in others, since Claude Code and iTerm2's own Claude Code integration set the title too. For a title that always stays, put \(user.overseer_project) into iTerm2's Profiles → General → Title (or the tab title components). A tab shows its colour only in a visible tab bar: with one tab per window, turn on Settings → Appearance → Tabs → "Show tab bar even when there is only one tab".
In iTerm2 the whole window takes the project colour too: the background is tinted (12 % of the colour on a light profile, 18 % on a dark one, read from COLORFGBG) and the project name shows as a badge. Both go back to the profile's own when the session ends. OVERSEER_TINT=off in the environment leaves background and badge alone and keeps only the tab colour.
Line above the prompt: ● project · branch · COLOUR · id in the project's colour. OVERSEER_BAND=0 in the environment hides it.
claude plugin validate . # manifest, hooks module, what it calls
claude plugin test . # reporter tests against the engine (tests/)
node --test cli/test/*.test.mjs # overseer rules and rendering
claude --plugin-dir ~/Projects/claude-overseer # a session with the working copy
OVERSEER_DIR=/some/dir points both reporter and overseer at another folder.
hooks/register.tsx 530 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { describeTool } from './lib/describe'
5import { PALETTE, fnv1a, pickIndex, tint } from './lib/palette'
6import { clip, clipEnds, redact, scrub, stripControl } from './lib/redact'
7
8// Reporter: every session writes what it does as JSON Lines to
9// ~/.claude/overseer/sessions/<session-id>.jsonl for the overseer terminal.
10// Nothing here may block or slow Claude Code: writes are buffered and
11// flushed on a timer, and every failure goes to the debug log, never up.
12
13type Dollar = EngineInterface
14
15const SCHEMA = 1
16const HEARTBEAT_MS = 30_000
17const FLUSH_MS = 750
18const MAX_LINES = 1500
19const ACTIVE_MS = 5 * 60_000
20// The longest answer kept for the overseer's copy button, the rest cut:
21// masking 64 KB of the worst text takes about 40 ms, of prose a few.
22const MAX_ANSWER = 64_000
23// Lines the overseer judges by: written at once, not on the timer, so a
24// reload that drops the timer cannot lose them.
25const URGENT = new Set(['tool_error', 'error', 'turn_end', 'attention', 'session_end'])
26// How a denial reads when it comes back as an errored result (the permission
27// dialog, a PreToolUse hook, Esc): the person's or a hook's decision, not a
28// failing tool.
29// A Bash command that may run tests: its output's end goes into tool_ok too,
30// since `npm test | tail` exits 0 and only the runner's verdict tells.
31const TESTISH = /\b(?:test|tests|spec|check|pytest|jest|vitest|mocha|rspec|phpunit|t)\b/
32const DENIED = /The user doesn't want to proceed with this tool use|The tool use was rejected|PreToolUse:\w+ hook error|^\[Request interrupted by user/
33
34const meta = atom({ plugin: 'overseer', key: 'meta' } as const, null, { shape: 'meta-1' })
35
36type Session = {
37 id?: string
38 file?: string
39 dir?: string
40 cwd?: string
41 colorIndex?: number
42 branch?: string | null
43 lines: string[]
44 queued: string[]
45 ready?: Promise<void>
46 writing?: Promise<void>
47 heartbeat?: Timer
48 flushTimer?: Timer
49 /** After a /clear or /resume: the id that ended, why, until the new one starts. */
50 handover?: { id: string; reason: string }
51 isDirty: boolean
52 isIterm: boolean
53 isTinted: boolean
54 isInteractive: boolean
55}
56
57// A reload runs this module afresh, so this starts over with it; the file on
58// disk is read back in start().
59const S: Session = {
60 lines: [],
61 queued: [],
62 isDirty: false,
63 isIterm: false,
64 isTinted: false,
65 isInteractive: true,
66}
67
68function debug($: Dollar, what: string, err: unknown): void {
69 try {
70 const message = err instanceof Error ? err.message : String(err)
71 $.ui.log(`overseer: ${what}: ${message}`, { to: 'debug' })
72 } catch {
73 // nothing left to tell
74 }
75}
76
77function line(type: string, data: Record<string, unknown>): string {
78 return JSON.stringify({ v: SCHEMA, ts: Date.now(), type, ...data })
79}
80
81function basename(path: string): string {
82 return path.replace(/\/+$/, '').split('/').at(-1) || path
83}
84
85function base64(text: string): string {
86 let binary = ''
87 for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte)
88 return btoa(binary)
89}
90
91function lastStart(text: string): Record<string, unknown> | undefined {
92 const lines = text.split('\n')
93 for (let i = lines.length - 1; i >= 0; i--) {
94 const one = lines[i]
95 if (one === undefined || !one.includes('"type":"session_start"')) continue
96 try {
97 return JSON.parse(one) as Record<string, unknown>
98 } catch {
99 return undefined
100 }
101 }
102 return undefined
103}
104
105// ---------- writing ----------
106
107function record($: Dollar, type: string, data: Record<string, unknown> = {}): void {
108 try {
109 const text = line(type, data)
110 if (S.file === undefined) {
111 S.queued.push(text)
112 if (S.queued.length > MAX_LINES) S.queued.splice(0, S.queued.length - MAX_LINES)
113 // after a /clear or /resume the surface is the one already known
114 void ensureStarted($, S.id === undefined ? undefined : S.isInteractive)
115 return
116 }
117 const last = S.lines.at(-1)
118 if (type === 'heartbeat' && last !== undefined && last.includes('"type":"heartbeat"')) {
119 S.lines[S.lines.length - 1] = text
120 } else {
121 S.lines.push(text)
122 }
123 if (S.lines.length > MAX_LINES) {
124 const head = S.lines[0] ?? ''
125 S.lines = [head, ...S.lines.slice(-(MAX_LINES - 1))]
126 }
127 if (URGENT.has(type)) void flush($)
128 else scheduleFlush($)
129 } catch (err) {
130 debug($, `record ${type}`, err)
131 }
132}
133
134function scheduleFlush($: Dollar): void {
135 if (S.flushTimer !== undefined) return
136 S.flushTimer = $.clock.after(FLUSH_MS, () => {
137 S.flushTimer = undefined
138 void flush($)
139 })
140}
141
142/** Writes the file; while a write runs, the running loop takes the new lines too. */
143function flush($: Dollar): Promise<void> {
144 if (S.file === undefined) return Promise.resolve()
145 S.isDirty = true
146 S.writing ??= writeLoop($, S.file)
147 return S.writing
148}
149
150async function writeLoop($: Dollar, file: string): Promise<void> {
151 try {
152 while (S.isDirty && S.file === file) {
153 S.isDirty = false
154 await $.fs.write(file, `${S.lines.join('\n')}\n`)
155 }
156 } catch (err) {
157 debug($, 'write', err)
158 } finally {
159 S.writing = undefined
160 }
161}
162
163/**
164 * Keeps the turn's last answer whole, beside the session's file, for the
165 * overseer's copy button: masked like every line, written without waiting.
166 */
167function keepAnswer($: Dollar, answer: string): void {
168 if (S.file === undefined || answer.trim() === '') return
169 const file = S.file.replace(/([^/]+)\.jsonl$/, 'answers/$1.md')
170 const whole =
171 answer.length > MAX_ANSWER
172 ? `${answer.slice(0, MAX_ANSWER)}\n\n[… gekürzt: ${answer.length - MAX_ANSWER} Zeichen fehlen]`
173 : answer
174 $.fs.write(file, stripControl(redact(whole))).catch((err: unknown) => debug($, 'answer', err))
175}
176
177// ---------- session facts ----------
178
179async function gitBranch($: Dollar, cwd: string): Promise<string | null> {
180 try {
181 const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 3000 })
182 if (head.exitCode !== 0) return null
183 const name = head.stdout.trim()
184 if (name !== 'HEAD') return name
185 const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd, timeoutMs: 3000 })
186 return sha.exitCode === 0 ? `(${sha.stdout.trim()})` : 'HEAD'
187 } catch {
188 return null
189 }
190}
191
192/** The last session_start of a file, unless a session_end follows it. */
193function liveStart(text: string): Record<string, unknown> | undefined {
194 const start = lastStart(text)
195 if (start === undefined) return undefined
196 const after = text.slice(text.lastIndexOf('"type":"session_start"'))
197 return after.includes('"type":"session_end"') ? undefined : start
198}
199
200/** Colours held right now by running sessions of other projects. */
201async function takenColors($: Dollar, dir: string, key: string, id: string): Promise<Set<number>> {
202 const taken = new Set<number>()
203 try {
204 if (!(await $.fs.exists(dir))) return taken
205 const now = Date.now()
206 for (const entry of await $.fs.list(dir)) {
207 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
208 if (entry.name === `${id}.jsonl` || now - entry.mtimeMs > ACTIVE_MS) continue
209 const start = liveStart(await $.fs.read(`${dir}/${entry.name}`))
210 if (start === undefined || start.pkey === key || typeof start.colorIndex !== 'number') continue
211 taken.add(start.colorIndex)
212 }
213 } catch (err) {
214 debug($, 'reading other sessions', err)
215 }
216 return taken
217}
218
219/**
220 * The colour this project got at its first session, unless a running session
221 * of another project holds it now; then the next free one, for this session
222 * only. One store key per project, so sessions starting together never
223 * overwrite each other, and the key is a hash, so no path lands in the store.
224 */
225async function chooseColor($: Dollar, dir: string, key: string, cwd: string, id: string): Promise<number> {
226 const storeKey = `color:${key}`
227 let kept: unknown = null
228 try {
229 kept = await $.store.get(storeKey)
230 } catch (err) {
231 debug($, 'store read', err)
232 }
233 const taken = await takenColors($, dir, key, id)
234 const isKept = typeof kept === 'number' && kept >= 0 && kept < PALETTE.length
235 if (isKept && !taken.has(kept as number)) return kept as number
236 const index = pickIndex(cwd, taken)
237 if (!isKept) {
238 try {
239 await $.store.set(storeKey, index)
240 } catch (err) {
241 debug($, 'store write', err)
242 }
243 }
244 return index
245}
246
247// ---------- iTerm2 ----------
248
249// The plugin API has no way to write to the terminal, so a child process
250// writes the escape sequences to the controlling terminal, /dev/tty.
251async function toTty($: Dollar, sequence: string, timeoutMs = 2000): Promise<void> {
252 if (!S.isIterm) return
253 try {
254 const ran = await $.process.run(['/bin/sh', '-c', 'printf "%s" "$1" > /dev/tty', 'overseer', sequence], {
255 timeoutMs,
256 })
257 if (ran.exitCode !== 0) debug($, 'tty write', ran.stderr.trim())
258 } catch (err) {
259 debug($, 'tty write', err)
260 }
261}
262
263function paintTab($: Dollar, colorIndex: number, project: string, background: string | undefined): void {
264 const color = PALETTE[colorIndex]
265 if (color === undefined) return
266 const [r, g, b] = color.rgb
267 void toTty(
268 $,
269 `\x1b]6;1;bg;red;brightness;${r}\x07` +
270 `\x1b]6;1;bg;green;brightness;${g}\x07` +
271 `\x1b]6;1;bg;blue;brightness;${b}\x07` +
272 `\x1b]1;● ${project}\x07` +
273 `\x1b]1337;SetUserVar=overseer_project=${base64(project)}\x07` +
274 `\x1b]1337;SetUserVar=overseer_color=${base64(color.name)}\x07` +
275 (background === undefined
276 ? ''
277 : `\x1b]11;${background}\x07\x1b]1337;SetBadgeFormat=${base64(`● ${project}`)}\x07`),
278 )
279}
280
281// Bounded below session.end's own 1.5 s, which covers this wait.
282function resetTab($: Dollar): Promise<void> {
283 return toTty(
284 $,
285 '\x1b]6;1;bg;*;default\x07\x1b]1337;SetUserVar=overseer_project=\x07\x1b]1337;SetUserVar=overseer_color=\x07' +
286 (S.isTinted ? '\x1b]111\x07\x1b]1337;SetBadgeFormat=\x07' : ''),
287 1000,
288 )
289}
290
291// ---------- lifecycle ----------
292
293async function start($: Dollar, known: boolean | undefined): Promise<void> {
294 const id = await $.session.id()
295 const handover = S.handover
296 // the ending id is still current for a moment after a /clear: try again later
297 if (handover !== undefined && handover.id === id) throw new Error('waiting for the new session id')
298 const isInteractive = known ?? (await $.session.surface()) !== null
299 const home = (await $.env.get('HOME')) ?? ''
300 const dir = (await $.env.get('OVERSEER_DIR')) || `${home}/.claude/overseer/sessions`
301 const cwd = await $.session.cwd()
302 const key = fnv1a(cwd).toString(16)
303 const file = `${dir}/${id}.jsonl`
304
305 let restored: string[] = []
306 try {
307 if (await $.fs.exists(file)) {
308 restored = (await $.fs.read(file)).split('\n').filter(one => one.trim() !== '')
309 }
310 } catch (err) {
311 debug($, 'restore', err)
312 }
313
314 const branch = await gitBranch($, cwd)
315 const colorIndex =
316 S.colorIndex !== undefined && S.cwd === cwd ? S.colorIndex : await chooseColor($, dir, key, cwd, id)
317 const color = PALETTE[colorIndex] ?? PALETTE[0]!
318 const project = scrub(basename(cwd))
319 const short = id.slice(0, 4)
320 const termProgram = await $.env.get('TERM_PROGRAM')
321 const lcTerminal = await $.env.get('LC_TERMINAL')
322 const tmux = await $.env.get('TMUX')
323 const itermSession = await $.env.get('ITERM_SESSION_ID')
324 const version = await $.session.version()
325
326 S.isIterm =
327 isInteractive && tmux === undefined && (termProgram === 'iTerm.app' || lcTerminal === 'iTerm2')
328 Object.assign(S, { id, file, dir, cwd, colorIndex, branch, isInteractive, handover: undefined })
329 S.lines = [
330 ...restored,
331 line('session_start', {
332 sid: id,
333 short,
334 cwd: scrub(cwd),
335 pkey: key,
336 project,
337 branch: branch === null ? null : scrub(branch),
338 colorIndex,
339 color: color.name,
340 hex: color.hex,
341 source:
342 handover?.reason ??
343 (restored.length === 0 ? 'startup' : restored.at(-1)!.includes('"type":"session_end"') ? 'resume' : 'reload'),
344 previous: handover?.id.slice(0, 4),
345 interactive: isInteractive,
346 terminal: termProgram ?? lcTerminal ?? null,
347 iterm: itermSession?.split(':')[0] ?? null,
348 version: version.version,
349 }),
350 ...S.queued,
351 ]
352 S.queued = []
353 await flush($)
354
355 try {
356 await update($, meta, () => ({
357 project,
358 branch: branch === null ? null : scrub(branch),
359 colorName: color.name,
360 hex: color.hex,
361 short,
362 }))
363 } catch (err) {
364 debug($, 'state', err)
365 }
366 // OVERSEER_TINT=off keeps the window as the profile has it
367 S.isTinted = S.isIterm && (await $.env.get('OVERSEER_TINT')) !== 'off'
368 paintTab($, colorIndex, project, S.isTinted ? tint(color.rgb, await $.env.get('COLORFGBG')) : undefined)
369
370 S.heartbeat?.cancel()
371 S.heartbeat = $.clock.every(HEARTBEAT_MS, () => void beat($))
372}
373
374/** Starts the file once; a failed start is tried again by the next event. */
375function ensureStarted($: Dollar, isInteractive: boolean | undefined): Promise<void> {
376 S.ready ??= start($, isInteractive).catch(err => {
377 S.ready = undefined
378 debug($, 'start', err)
379 })
380 return S.ready
381}
382
383async function beat($: Dollar): Promise<void> {
384 try {
385 if (S.cwd !== undefined) {
386 const branch = await gitBranch($, S.cwd)
387 if (branch !== S.branch) {
388 S.branch = branch
389 const shown = branch === null ? null : scrub(branch)
390 record($, 'meta', { branch: shown })
391 await update($, meta, old => (old === null ? old : { ...old, branch: shown }))
392 }
393 }
394 // the branch rides along, so a file cut to its newest lines still has it
395 record($, 'heartbeat', { branch: S.branch === undefined || S.branch === null ? S.branch : scrub(S.branch) })
396 } catch (err) {
397 debug($, 'heartbeat', err)
398 }
399}
400
401// ---------- hooks ----------
402
403export const register: Register = on => {
404 // Nothing here is awaited: git, the store and the first write run beside
405 // the session, and events recorded meanwhile wait in the queue.
406 on('session.start', ($, e, next) => {
407 void ensureStarted($, e.isInteractive)
408 return next(e)
409 })
410
411 on('prompt.submit', ($, e, next) => {
412 try {
413 if (e.isMeta !== true) record($, 'user_prompt', { text: clip(e.text, 300), command: e.command?.name })
414 } catch (err) {
415 debug($, 'prompt.submit', err)
416 }
417 return next(e)
418 })
419
420 on('tool.call', async ($, e, next) => {
421 const id = e.tool_use_id
422 const agent = e.agentId?.slice(0, 8)
423 try {
424 const input = e as unknown as Record<string, unknown>
425 record($, 'tool_call', {
426 id,
427 tool: e.tool,
428 desc: describeTool(String(e.tool), input),
429 cmd: e.tool === 'Bash' ? clip(String(input.command ?? ''), 300) : undefined,
430 agent,
431 })
432 } catch (err) {
433 debug($, 'tool.call', err)
434 }
435
436 const ran = await next(e)
437
438 try {
439 if (ran.deny !== undefined) {
440 record($, 'tool_error', { id, tool: e.tool, denied: true, error: clip(ran.deny, 300), agent })
441 } else if (ran.isError === true) {
442 const text = ran.text ?? ''
443 const denied = DENIED.test(text) ? true : undefined
444 record($, 'tool_error', { id, tool: e.tool, denied, error: clipEnds(text, 400), agent })
445 } else {
446 const command = e.tool === 'Bash' ? String((e as unknown as Record<string, unknown>).command ?? '') : ''
447 const out = TESTISH.test(command) ? clipEnds(ran.text ?? '', 300) : undefined
448 record($, 'tool_ok', { id, tool: e.tool, out, agent })
449 }
450 } catch (err) {
451 debug($, 'tool.call result', err)
452 }
453 return ran
454 })
455
456 on('turn.start', ($, e, next) => {
457 if (e.agentId === undefined) record($, 'turn_start')
458 return next(e)
459 })
460
461 on('turn.complete', ($, e, next) => {
462 try {
463 if (e.agentId === undefined) {
464 record($, 'turn_end', { reason: e.reason, ms: e.durationMs })
465 if (e.reason === 'answer') {
466 keepAnswer($, e.answer)
467 } else if (e.reason === 'error') {
468 record($, 'error', { kind: 'api_error', error: clip(e.answer, 300) })
469 } else if (e.reason === 'refusal') {
470 record($, 'error', {
471 kind: 'refusal',
472 error: clip(e.refusal.explanation ?? e.refusal.category ?? '', 300),
473 })
474 }
475 }
476 } catch (err) {
477 debug($, 'turn.complete', err)
478 }
479 return next(e)
480 })
481
482 on('classic.Notification', ($, e, next) => {
483 record($, 'attention', { kind: e.notification_type, text: clip(e.message, 200) })
484 return next(e)
485 })
486
487 on('session.end', async ($, e, next) => {
488 try {
489 record($, 'session_end', { reason: e.reason })
490 S.flushTimer?.cancel()
491 S.flushTimer = undefined
492 await flush($)
493 if (e.reason === 'clear' || e.reason === 'resume') {
494 // the process goes on under another id: the next event opens its file
495 S.handover = { id: e.sessionId, reason: e.reason }
496 S.file = undefined
497 S.lines = []
498 S.ready = undefined
499 } else {
500 S.heartbeat?.cancel()
501 await resetTab($)
502 }
503 } catch (err) {
504 debug($, 'session.end', err)
505 }
506 return next(e)
507 })
508
509 // A coloured line above the prompt (project, branch, colour, short id) so
510 // the colour in the overseer can be matched to this terminal.
511 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
512 const shown = await read($, meta)
513 if (e.props.hasSurvey || shown === null) return next(e)
514 if ((await $.env.get('OVERSEER_BAND')) === '0') return next(e)
515 const { Box, Text } = $.ui.resolve(e)
516
517 return (
518 <Box>
519 <Text color={shown.hex} bold>
520 ● {shown.project}
521 </Text>
522 <Text dimColor>
523 {' '}
524 · {shown.branch ?? 'kein git'} · {shown.colorName} · {shown.short}
525 </Text>
526 </Box>
527 )
528 })
529}
530hooks/lib/describe.ts 25 lines1import { clip } from './redact'
2
3const pick = (input: Record<string, unknown>, key: string): string | undefined => {
4 const value = input[key]
5 return typeof value === 'string' && value.trim() !== '' ? value : undefined
6}
7
8/** One short, redacted line saying what a tool call does. */
9export function describeTool(tool: string, input: Record<string, unknown>): string {
10 const text =
11 (tool === 'Bash' && (pick(input, 'description') ?? pick(input, 'command'))) ||
12 pick(input, 'file_path') ||
13 pick(input, 'notebook_path') ||
14 pick(input, 'pattern') ||
15 pick(input, 'url') ||
16 pick(input, 'query') ||
17 pick(input, 'description') ||
18 pick(input, 'skill') ||
19 pick(input, 'prompt') ||
20 Object.values(input).find((v): v is string => typeof v === 'string' && v.trim() !== '') ||
21 ''
22
23 return clip(text, 120)
24}
25hooks/lib/palette.ts 60 lines1// Eight colours that stay apart on a dark terminal and in an iTerm2 tab.
2// Red is left out on purpose: the overseer uses it for errors.
3
4export type PaletteColor = { name: string; hex: string; rgb: [number, number, number] }
5
6export const PALETTE: readonly PaletteColor[] = [
7 { name: 'BLAU', hex: '#3B82F6', rgb: [59, 130, 246] },
8 { name: 'ORANGE', hex: '#F97316', rgb: [249, 115, 22] },
9 { name: 'GRÜN', hex: '#22C55E', rgb: [34, 197, 94] },
10 { name: 'LILA', hex: '#A855F7', rgb: [168, 85, 247] },
11 { name: 'GELB', hex: '#EAB308', rgb: [234, 179, 8] },
12 { name: 'TÜRKIS', hex: '#14B8A6', rgb: [20, 184, 166] },
13 { name: 'PINK', hex: '#EC4899', rgb: [236, 72, 153] },
14 { name: 'BRAUN', hex: '#B45309', rgb: [180, 83, 9] },
15]
16
17/** FNV-1a, 32 bit: stable across runs, machines and engine versions. */
18export function fnv1a(text: string): number {
19 let hash = 0x811c9dc5
20 for (let i = 0; i < text.length; i++) {
21 hash ^= text.charCodeAt(i)
22 hash = Math.imul(hash, 0x01000193) >>> 0
23 }
24 return hash >>> 0
25}
26
27export function hashIndex(projectPath: string): number {
28 return fnv1a(projectPath) % PALETTE.length
29}
30
31/**
32 * The hash's colour, unless a project that is active right now already holds
33 * it: then the next free one. With every colour taken the hash wins.
34 */
35export function pickIndex(projectPath: string, takenByOthers: ReadonlySet<number>): number {
36 const start = hashIndex(projectPath)
37 for (let step = 0; step < PALETTE.length; step++) {
38 const index = (start + step) % PALETTE.length
39 if (!takenByOthers.has(index)) return index
40 }
41 return start
42}
43
44/**
45 * The window background, tinted with the project colour: a little of it mixed
46 * into white on a light profile, into near-black on a dark one. iTerm2 says
47 * which in COLORFGBG ("fg;bg", bg 7 or 15 is light).
48 */
49export function tint(rgb: readonly [number, number, number], colorFgBg: string | undefined): string {
50 const last = colorFgBg?.split(';').at(-1)?.trim()
51 const bg = last ? Number(last) : Number.NaN
52 const isDark = Number.isInteger(bg) && bg !== 7 && bg !== 15
53 const [base, share] = isDark ? [30, 0.18] : [255, 0.12]
54 const hex = (channel: number) =>
55 Math.round(base + (channel - base) * share)
56 .toString(16)
57 .padStart(2, '0')
58 return `rgb:${hex(rgb[0])}/${hex(rgb[1])}/${hex(rgb[2])}`
59}
60hooks/lib/redact.ts 116 lines1// Masks secrets before anything is written to disk. Order matters: whole
2// blocks and specific token formats first, then credentials in URLs and on
3// command lines, then key=value assignments, then e-mails.
4//
5// Every quantifier that can restart at each position is bounded, and the
6// input is capped before it gets here (clip), so redaction stays linear and
7// cannot stall a hook on a long unbroken string.
8
9const MASK = '[REDACTED]'
10const CAP = 8_000
11
12// A secret-sounding name, ending the key: GITHUB_TOKEN, db.password, "api-key".
13const NAME =
14 '[A-Za-z0-9_.-]{0,40}?(?:password|passwd|passwort|kennwort|pwd|pass|pw|secret|geheimnis|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|authorization|credentials?|cookie|session[_-]?secret|client[_-]?secret|signing[_-]?key|webhook[_-]?url)'
15// A value: quoted up to its closing quote, or bare up to whitespace.
16const VALUE = `(?:"[^"\\n]{1,512}"|'[^'\\n]{1,512}'|[^\\s"'\\n]{1,512})`
17
18const PATTERNS: readonly [RegExp, string][] = [
19 // PEM and PGP private keys, whole block (cut blocks too)
20 [/-----BEGIN [A-Z ]{0,40}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]{0,40}PRIVATE KEY(?: BLOCK)?-----|$)/g, MASK],
21 // Anthropic / OpenAI style keys
22 [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g, MASK],
23 // GitHub, GitLab, Hugging Face, Slack, npm, SendGrid tokens
24 [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, MASK],
25 [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, MASK],
26 [/\bglpat-[A-Za-z0-9_-]{20,}/g, MASK],
27 [/\bhf_[A-Za-z0-9]{30,}/g, MASK],
28 [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, MASK],
29 [/\bxapp-[A-Za-z0-9-]{10,}/g, MASK],
30 [/\bnpm_[A-Za-z0-9]{30,}/g, MASK],
31 [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, MASK],
32 // AWS access key ids, Google API keys, Stripe keys
33 [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, MASK],
34 [/\bAIza[0-9A-Za-z_-]{30,}/g, MASK],
35 [/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g, MASK],
36 // JSON Web Tokens
37 [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, MASK],
38 // Webhook and bot URLs whose path is the secret
39 [/(hooks\.slack\.com\/services\/)[^\s"'<>]+/gi, `$1${MASK}`],
40 [/(discord(?:app)?\.com\/api\/webhooks\/)[^\s"'<>]+/gi, `$1${MASK}`],
41 [/(api\.telegram\.org\/bot)[^/\s"'<>]+/gi, `$1${MASK}`],
42 // Signed URLs and keys in query strings
43 [/([?&](?:sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|key|api_key|apikey|token|access_token|auth|code|secret|password)=)[^&\s"'<>#]+/gi, `$1${MASK}`],
44 // Authorization headers
45 [/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi, `$1 ${MASK}`],
46 // URLs with inline credentials: scheme://user:pass@host
47 [/([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]{1,128}:)[^\s@/]{1,256}@/gi, `$1${MASK}@`],
48 // curl -u user:pass, --user user:pass
49 [/(\s(?:-u|--user)\s+["']?[^\s:"']{1,128}:)[^\s"']{1,256}/g, `$1${MASK}`],
50 // mysql -pSECRET / -p SECRET, sshpass -p SECRET
51 [/(\b(?:mysql|mysqladmin|mysqldump|mariadb|sshpass)\b[^\n|;&]{0,200}?\s-p\s?)[^\s-][^\s]{0,255}/g, `$1${MASK}`],
52 // echo SECRET | sudo -S
53 [/(\becho\s+)["']?[^\s|]{1,256}["']?(\s*\|\s*sudo\s+-S)/g, `$1${MASK}$2`],
54 // SQL: PASSWORD 'x', IDENTIFIED BY 'x'
55 [/(\b(?:PASSWORD|IDENTIFIED\s+BY)\s+)'[^'\n]{0,256}'/gi, `$1'${MASK}'`],
56 // CLI flags: --password x, --client-secret=x, --auth-token "x"
57 [new RegExp(`(--[a-z0-9-]{0,30}(?:password|passwd|secret|token|api-key|apikey|auth|credentials?)(?:=|\\s+))${VALUE}`, 'gi'), `$1${MASK}`],
58 // key=value and "key": "value" with a secret-sounding name
59 [new RegExp(`(["']?\\b${NAME}["']?\\s{0,4}=\\s{0,4})${VALUE}`, 'gi'), `$1${MASK}`],
60 [new RegExp(`(["']?\\b${NAME}["']?\\s{0,4}:\\s{0,4})(?:"[^"\\n]{1,512}"|'[^'\\n]{1,512}')`, 'gi'), `$1${MASK}`],
61 // key: value in prose or YAML: the value may hold spaces, mask to the line end
62 [new RegExp(`(\\b${NAME}\\s{0,4}:[^\\S\\n]{0,4})(?!\\[REDACTED)[^\\s"'][^\\n]{0,200}`, 'gi'), `$1${MASK}`],
63 // e-mail addresses
64 [/[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,8}\.[A-Za-z]{2,24}\b/g, '[EMAIL]'],
65]
66
67// Control bytes would reach a terminal as live escape sequences (the band,
68// the tab title, the overseer): ANSI sequences first, then the rest of C0/C1.
69export function stripControl(text: string): string {
70 return text
71 .replace(/\x1b\[[0-9;?]{0,32}[ -/]{0,4}[@-~]/g, '')
72 .replace(/\x1b\][^\x07\x1b]{0,512}(?:\x07|\x1b\\)?/g, '')
73 .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
74}
75
76export function redact(text: string): string {
77 let out = text
78 for (const [pattern, replacement] of PATTERNS) {
79 out = out.replace(pattern, replacement)
80 }
81 return out
82}
83
84/** Head and tail of a long text, so redaction never runs over megabytes. */
85function cap(text: string): string {
86 if (text.length <= 2 * CAP) return text
87 // cut at whitespace, so no secret is split into halves that match nothing
88 const headEnd = Math.max(text.lastIndexOf(' ', CAP), text.lastIndexOf('\n', CAP), CAP / 2)
89 const from = text.length - CAP
90 const spaces = [text.indexOf(' ', from), text.indexOf('\n', from)].filter(i => i >= 0)
91 const tailStart = spaces.length > 0 ? Math.min(...spaces) : from
92 return `${text.slice(0, headEnd)}\n…\n${text.slice(tailStart)}`
93}
94
95/** Redacts first, then cuts, so a cut never leaves half a secret behind. */
96export function clip(text: string, max: number): string {
97 const clean = stripControl(redact(cap(text)))
98 .replace(/\s*\n\s*/g, ' ⏎ ')
99 .replace(/[^\S\n]+/g, ' ')
100 .trim()
101 return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean
102}
103
104/** Like clip, but keeps the head and the tail: a test run's verdict is at its end. */
105export function clipEnds(text: string, max: number): string {
106 const clean = clip(text, Number.MAX_SAFE_INTEGER)
107 if (clean.length <= max) return clean
108 const tail = Math.floor(max * 0.4)
109 return `${clean.slice(0, max - tail - 3)} … ${clean.slice(-tail)}`
110}
111
112/** Redacted and free of control bytes, but otherwise unchanged (paths, branch names). */
113export function scrub(text: string): string {
114 return stripControl(redact(cap(text)))
115}
116types/index.d.ts 14 lines1export type OverseerMeta = {
2 project: string
3 branch: string | null
4 colorName: string
5 hex: string
6 short: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 overseer: { meta: Shaped<OverseerMeta | null> }
12 }
13}
14