Shows which machine and repo a session runs in (Mac or the claude-code container, repo, branch, fork) in the status line, and nudges Claude when it reaches for…

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.
The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.
| Mod | What you get | Commands | |
|---|---|---|---|
| where-am-i | Status line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container. | /where | |
| roll-call | A pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message. | `/rollcall [open\ | linked] [text], tool report` |
| fuel-gauge | ⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot. | /fuel [text] | |
| gpu-crab | While a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud. | /gpu | |
| vitals-strip | Unraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green. | `/vitals [show\ | quiet]` |
| reef | An aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name. | /reef [text] | |
| keymaster | Masks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan. | /keymaster, /keymaster allow <tag> | |
| trailer-guard | Refuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable). | /trailers | |
| gatekeeper | A pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you. | /gates [text], tool gates | |
| codex-corner | A boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch. | /codex-card | |
| house-rules | Notes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own. | /rules, /rules add …, /rules off <id> |
Everything is read-only.
| Mod | Reads |
|---|---|
| roll-call, fuel-gauge, reef | The desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage) |
| vitals-strip | The HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read |
| reef | unraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview) |
| gpu-crab, reef | Ollama GET /api/ps at the ollamaUrl option (default http://localhost:11434) |
| gatekeeper | gh api graphql, one search query every 5 minutes and shortly after a push |
| where-am-i, trailer-guard, codex-corner | git in the session's checkout |
Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:
"pluginConfigs": {
"gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
"vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
"where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}
For one terminal session:
claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge
For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}
Or install from this folder as a marketplace, so /reload-plugins picks up edits:
claude plugin marketplace add ~/dev/crab-workshop
Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.
sh scripts/check.sh # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod
shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.
AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead./keymaster allow its tag.MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.
hooks/register.ts 104 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { describe, hostKind, hostLabel, movesCheckout, nudge, ownerRepo, parseRemotes, parseRules, shortPath } from './place'
4import type { Place, RepoInfo } from './place'
5
6const git = async ($: EngineInterface, cwd: string, args: string[]): Promise<string> => {
7 try {
8 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
9 return r.exitCode === 0 ? r.stdout.trim() : ''
10 } catch {
11 return ''
12 }
13}
14
15async function readRepo($: EngineInterface, cwd: string): Promise<RepoInfo | undefined> {
16 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
17 if (!top) return undefined
18 const [branch, gitDir, common, remotesText] = await Promise.all([
19 git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
20 git($, cwd, ['rev-parse', '--absolute-git-dir']),
21 git($, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
22 git($, cwd, ['remote', '-v']),
23 ])
24 const remotes = parseRemotes(remotesText)
25 const origin = remotes.origin ? ownerRepo(remotes.origin) : null
26 const upstream = remotes.upstream ? ownerRepo(remotes.upstream) : null
27 return {
28 name: origin?.name ?? upstream?.name ?? top.split('/').pop() ?? top,
29 branch: branch === 'HEAD' ? 'detached' : branch || '?',
30 ...(origin ? { origin: origin.owner } : {}),
31 ...(upstream ? { upstream: upstream.owner } : {}),
32 isWorktree: !!gitDir && !!common && gitDir !== common,
33 }
34}
35
36// Kept per load: a reload re-reads the place at start.
37let place: Place | null = null
38let host = ''
39let kind: Place['kind'] = 'linux'
40let hostRules: [string, string][] = []
41const said = new Set<string>()
42
43async function refresh($: EngineInterface): Promise<Place> {
44 const cwd = await $.session.cwd()
45 const repo = await readRepo($, cwd)
46 place = { kind, host, label: hostLabel(kind, host, hostRules), cwd, ...(repo ? { repo } : {}) }
47 $.ui.status(describe(place))
48 return place
49}
50
51export const register: Register = (on, options) => {
52 hostRules = parseRules(options.hosts as readonly string[] | undefined)
53 const homes = parseRules(options.homes as readonly string[] | undefined)
54 const targets = ((options.containerTargets as readonly string[] | undefined) ?? []).map(String)
55
56 on('session.start', async ($, e, next) => {
57 const started = await next(e)
58 try {
59 const [uname, name, isDocker] = await Promise.all([
60 $.process.run(['uname', '-s'], { timeoutMs: 3000 }).then(r => r.stdout.trim()).catch(() => ''),
61 $.process.run(['hostname'], { timeoutMs: 3000 }).then(r => r.stdout.trim()).catch(() => ''),
62 $.fs.exists('/.dockerenv').catch(() => false),
63 ])
64 host = name || 'unknown'
65 kind = hostKind(uname, isDocker === true)
66 await $.command.register({ name: 'where', description: 'Show which machine, folder, repo, branch and remotes this session runs in' })
67 await refresh($)
68 } catch {
69 // The status line is a nicety; a failure here changes nothing else.
70 }
71 return started
72 })
73
74 on('command.run', { command: 'where' }, async $ => {
75 const p = await refresh($)
76 const cwd = p.cwd
77 const remotes = parseRemotes(await git($, cwd, ['remote', '-v']))
78 const lines = [
79 `host ${p.label} (${p.host}, ${p.kind})`,
80 `folder ${shortPath(cwd)}`,
81 ...(p.repo
82 ? [
83 `repo ${p.repo.name} on ${p.repo.branch}${p.repo.isWorktree ? ' (a worktree)' : ''}`,
84 ...Object.entries(remotes).map(([n, u]) => `remote ${n.padEnd(9)}${u}`),
85 ]
86 : ['repo (not a git checkout)']),
87 ]
88 return { text: lines.join('\n') }
89 })
90
91 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92 const r = await next(e)
93 const command = String(e.command ?? '')
94 if (movesCheckout(command)) await refresh($).catch(() => undefined)
95 if (!place || r.deny !== undefined) return r
96 const n = nudge(place, command, homes, targets)
97 // Once per kind per load: a nudge, not a nag.
98 if (!n || said.has(n.kind)) return r
99 said.add(n.kind)
100 $.ui.toast(n.note.replace(/^where-am-i: /, '').split('. ')[0] + '.')
101 return { ...r, context: [...(r.context ?? []), n.note] }
102 }).catch(($, e, next) => next(e))
103}
104hooks/place.ts 128 lines1// Pure logic for where-am-i: what kind of host this is, how to describe the
2// repo, and when a command deserves a nudge.
3
4export type HostKind = 'mac' | 'container' | 'linux'
5
6export type Place = {
7 kind: HostKind
8 host: string
9 label: string
10 cwd: string
11 repo?: RepoInfo
12}
13
14export type RepoInfo = {
15 /** Repo name, from origin's URL or the folder. */
16 name: string
17 branch: string
18 /** owner of origin (davidcoulson) and of upstream (tinkerologie), when present. */
19 origin?: string
20 upstream?: string
21 isWorktree: boolean
22}
23
24/** `pattern=value` lines into pairs; `*` and `?` globs. */
25export function parseRules(raw: readonly string[] | string | undefined): [string, string][] {
26 const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,\n]/) : []
27 return list
28 .map(s => String(s).trim())
29 .filter(s => s.includes('='))
30 .map(s => [s.slice(0, s.indexOf('=')).trim(), s.slice(s.indexOf('=') + 1).trim()] as [string, string])
31 .filter(([p, v]) => p && v)
32}
33
34export const glob = (pattern: string, s: string): boolean =>
35 new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i').test(s)
36
37export function hostKind(uname: string, isDocker: boolean): HostKind {
38 if (/darwin/i.test(uname)) return 'mac'
39 return isDocker ? 'container' : 'linux'
40}
41
42export function hostLabel(kind: HostKind, host: string, rules: [string, string][]): string {
43 const hit = rules.find(([p]) => glob(p, host))
44 if (hit) return hit[1]
45 const short = host.replace(/\.local$/, '')
46 return kind === 'mac' ? '🍎 Mac' : kind === 'container' ? `🐳 ${short}` : `🐧 ${short}`
47}
48
49/** owner/name from a git remote URL (ssh or https); null when it isn't one. */
50export function ownerRepo(url: string): { owner: string; name: string } | null {
51 const m = url.trim().match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)
52 return m && m[1] && m[2] ? { owner: m[1], name: m[2] } : null
53}
54
55/** `git remote -v` output into name → url (fetch lines). */
56export function parseRemotes(text: string): Record<string, string> {
57 const out: Record<string, string> = {}
58 for (const line of text.split('\n')) {
59 const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)/)
60 if (m && m[1] && m[2] && !out[m[1]]) out[m[1]] = m[2]
61 }
62 return out
63}
64
65export function describe(p: Place): string {
66 const parts = [p.label]
67 if (p.repo) {
68 const r = p.repo
69 let repo = `${r.name} · ${r.branch}`
70 if (r.isWorktree) repo += ' (worktree)'
71 parts.push(repo)
72 if (r.upstream && r.origin && r.upstream !== r.origin) parts.push(`fork of ${r.upstream}`)
73 } else {
74 parts.push(shortPath(p.cwd))
75 }
76 return parts.join(' · ')
77}
78
79export const shortPath = (path: string): string =>
80 path.replace(/^\/Users\/[^/]+/, '~').replace(/^\/home\/[^/]+/, '~').replace(/^~\/Library\/Application Support\/Claude\/scratch-workspaces\/.*\/(scratch-[^/]+)/, 'scratch/$1')
81
82const DEV = /(^|[\s;&|(])(make\s+(test|check|lint)|go\s+(test|vet|build)|pytest|\.venv\/bin\/pytest|script\/(test|setup|hassfest)|ruff\s|mypy\s|npm\s+(test|run)|pnpm\s|uv\s+run)/
83
84export const isDevCommand = (command: string): boolean => DEV.test(command)
85
86/** The container or Unraid word a remote command on the Mac names, or null. */
87export function sshTarget(command: string, targets: readonly string[]): string | null {
88 const m = command.match(/(^|[\s;&|(])(ssh|scp|rsync|sftp)\s+([^\n;&|]*)/)
89 if (!m || !m[3]) return null
90 const rest = m[3]
91 return targets.find(t => t && new RegExp(`(^|[\\s@:/])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[\\s:/])`, 'i').test(rest)) ?? null
92}
93
94export function homeFor(repo: string | undefined, rules: [string, string][]): HostKind | undefined {
95 if (!repo) return undefined
96 const v = rules.find(([p]) => glob(p, repo))?.[1]?.toLowerCase()
97 return v === 'mac' || v === 'container' ? v : undefined
98}
99
100/** The note Claude reads after the command, or null when the command is fine here. */
101export function nudge(p: Place, command: string, homes: [string, string][], targets: readonly string[]): { kind: string; note: string } | null {
102 if (p.kind === 'mac') {
103 const t = sshTarget(command, targets)
104 if (t)
105 return {
106 kind: `ssh:${t}`,
107 note:
108 `where-am-i: this session runs on the Mac (${p.host}), and that command reaches ${t}. ` +
109 `Work on the Unraid host or in the claude-code dev container belongs in a session on the "UnRAID" SSH connection (folder ~/dev), ` +
110 `where the keys and host keys are set up. If it failed with "Host key verification failed" or a permission denial, ` +
111 `stop and ask David to move the work there (or propose a side session there) instead of working around it.`,
112 }
113 }
114 const home = homeFor(p.repo?.name, homes)
115 if (home && home !== p.kind && p.kind !== 'linux' && isDevCommand(command))
116 return {
117 kind: `dev:${p.repo?.name}`,
118 note:
119 `where-am-i: ${p.repo?.name} is developed ${home === 'container' ? 'in the claude-code container (UnRAID SSH connection, ~/dev)' : 'on the Mac'}, ` +
120 `but this session runs ${p.kind === 'mac' ? 'on the Mac' : 'in the container'} (${shortPath(p.cwd)}). ` +
121 `This checkout may be stale or lack the environment (venv, CLAUDE.local.md, keys). Say so to David before relying on results from here.`,
122 }
123 return null
124}
125
126/** Whether a command may have moved the checkout (branch or folder). */
127export const movesCheckout = (command: string): boolean => /\bgit\s+(checkout|switch|worktree|clone|rebase|merge|pull)\b|(^|[\s;&|])cd\s/.test(command)
128