SLOPSHOPPER

overseer

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

newbandguardpromptprocesstimer
v0.2.0no licenseupdated 2026-10-25DYAI2025/claude-overseer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overseer
› 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 ● app · feat/auth-refresh · BRAUN · prev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● app · feat/auth-refresh · BRAUN · prev
README

overseer

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:

  1. Reporter (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.
  2. Overseer (cli/overseer.mjs): a Node script with no dependencies that you run in its own terminal. It follows those files and draws the overview.

Install

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.

Run the overseer

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.

OptionEffect
--onceprint one snapshot and exit
--allalso show sessions that ended over 10 min ago or went quiet over 1 h ago
--no-colorplain text
--dir <path>read another sessions folder (default ~/.claude/overseer/sessions, or $OVERSEER_DIR)
--no-mouseleave 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:

  • a hotkey: Settings → Profiles → Overseer → Keys → "A hotkey opens a dedicated window" — the overseer then drops down over everything on a key;
  • at start: open an Overseer window, Window → Save Window Arrangement, and Settings → General → Startup → "Open default window arrangement"; add iTerm2 to the macOS login items to have it after every login.

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.

What you see

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

StatusMeaning
läufta turn is running and moving
bereitthe turn is done, the session waits for your next prompt
warteta permission dialog or a question (AskUserQuestion, plan approval) waits for you
hängta turn has made no progress (no tool call, no answer) for over 10 min
Fehlerat least one problem of severity error
inaktivno heartbeat for over 5 min (heartbeats come every 30 s)
beendetthe session ended (shown for 10 min)

Problems detected

ProblemRule
Tests schlagen Nx fehl2 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 hintereinanderthe newest 3+ tool results are errors with the same message (numbers folded), no success in between
Wiederholte Tool-Fehler5+ tool errors within 10 min, not counting failed test runs
Seit N min kein Fortschritta turn is running with no progress for over 10 min
Wartet auf deine Freigabe / Antwortas above, while a permission dialog or a question is open
API-Fehler / Verweigerunga 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 Lebenszeichenheartbeats 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.

The reporter

Events, one JSON object per line, each with v, ts (ms) and type:

typefields
session_startsid, 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_prompttext (cut to 300 characters), command for a slash command
tool_callid, tool, desc (short description), cmd (Bash only), agent (subagent)
tool_ok / tool_errorid, 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_endreason, ms
errorkind (api_error, refusal), error
attentionkind (permission_prompt, idle_prompt, …), text
heartbeatevery 30 s with the current branch; consecutive ones are folded into one line
metabranch when it changes
session_endreason

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.

Development

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.

Source 5 files
hooks/register.tsx 530 lines
1import { 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}
530
hooks/lib/describe.ts 25 lines
1import { 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}
25
hooks/lib/palette.ts 60 lines
1// 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}
60
hooks/lib/redact.ts 116 lines
1// 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}
116
types/index.d.ts 14 lines
1export 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