Shows if this session has a Playwright browser and who holds it. /browser clean closes leftover browsers. A subagent that wants the browser waits until the one…

<h1 align="center">Claude Code mods</h1>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>
Add the marketplace once, then install any mod by name:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
Or install the general-purpose set in one go:
for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
claude plugin install "$m@claude-code-mods"
done
Restart Claude Code after installing. To try one without installing:
git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar
Needs Claude Code 2.1.287+. A few mods need more (Chrome,
gh, a connector); the tables say which.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+ | /cache-clock setup |
| 📍 | where-am-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key | /openai-balance |
| 📡 | agent-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends | |
| 🌐 | browser-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost | /route |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours | /gate |
| 📏 | rulebook-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app | /music |
<a id="mission-control"></a>
Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.
w shows the agents and every tool call, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 259 lines1// Browser Lanes: is this session attached to a Playwright browser, and one driver at a time.
2// - A band says whether this session has its own browser, is ready, or is blocked because
3// another Claude process holds the shared Chrome profile (and which one). /browser says more.
4// - In this session the main loop and each subagent take turns on the browser: a second
5// driver's call waits until the first is done (closed it, finished, or idle 90 s).
6// - Screenshots are named by who took them (`login-test-03.png`).
7// - Another Claude session that used the browser in the last 90 s gets a warning.
8// - A band above the prompt says who has the browser and who waits.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { Attachment, Lane } from '../types'
13import { attachment, browsers, describeAttachment, otherHolders, parsePs } from './attach'
14
15const BROWSER_TOOL = /^mcp__.*playwright.*__browser_/
16const IDLE_MS = 90_000 // a driver this quiet lets go
17const MAX_WAIT_MS = 5 * 60_000
18
19// Held by the host, so a reload keeps who holds the browser.
20const holder = atom({ plugin: 'browser-lanes', key: 'holder' } as const, null as Lane | null)
21const waiting = atom({ plugin: 'browser-lanes', key: 'waiting' } as const, [] as string[])
22const attached = atom({ plugin: 'browser-lanes', key: 'attachment' } as const, null as Attachment | null)
23
24export const register: Register = on => {
25 const names = new Map<string, string>() // agent id → a short label
26 const shots = new Map<string, number>() // label → screenshots taken
27
28 on('session.start', async ($, e, next) => {
29 const r = await next(e)
30 await $.command.register({ name: 'browser', description: "Show if this session has a Playwright browser; /browser clean closes other sessions' browsers", argumentHint: '[clean]' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
31 void inspect($).catch(() => {})
32 return r
33 })
34
35 on('command.run', { command: 'browser' }, async ($, e) => {
36 if (e.args.trim() === 'clean') return { text: await clean($) }
37 const a = await inspect($)
38 return { text: browserReport(a) }
39 })
40
41 // Closing the session closes its browser too.
42 on('session.end', async ($, e, next) => {
43 const a = await read($, attached)
44 if (a?.state === 'attached' && a.chromePid) await closeChrome($, a.chromePid)
45 return next(e)
46 })
47
48 on('agent.spawn', async ($, e, next) => {
49 const r = await next(e)
50 if (r.agentId) names.set(r.agentId, slug(e.name || e.description || e.subagentType))
51 return r
52 })
53
54 on('tool.call', async ($, e, next) => {
55 if (!BROWSER_TOOL.test(e.tool)) return next(e)
56 const owner = e.agentId ?? 'main'
57 const label = e.agentId ? (names.get(e.agentId) ?? `agent-${e.agentId.slice(0, 6)}`) : 'main'
58
59 await warnOtherSession($)
60
61 // Wait for the lane: time inside $ calls doesn't count against the hook's limit.
62 const started = Date.now()
63 let isQueued = false
64 while (!next.signal.aborted) {
65 const lane = await read($, holder)
66 if (!lane || lane.owner === owner || Date.now() - lane.last > IDLE_MS) break
67 if (!isQueued) {
68 isQueued = true
69 await update($, waiting, w => [...w, label])
70 }
71 if (Date.now() - started > MAX_WAIT_MS) {
72 await update($, waiting, w => drop(w, label))
73 return { deny: `browser-lanes: the browser has been busy with "${lane.label}" for over 5 minutes. Try again later, or ask the user.` }
74 }
75 await $.process.run(['sleep', '0.5'])
76 }
77 if (isQueued) await update($, waiting, w => drop(w, label))
78
79 const at = Date.now()
80 await update($, holder, lane => (lane && lane.owner === owner ? { ...lane, last: at } : { owner, label, since: at, last: at }))
81 await claimAcrossSessions($, label)
82
83 let call = e
84 if (e.tool.endsWith('browser_take_screenshot')) {
85 const n = (shots.get(label) ?? 0) + 1
86 shots.set(label, n)
87 const args = e as unknown as { filename?: string; type?: string }
88 call = { ...e, filename: shotName(label, n, args.filename, args.type) } as typeof e
89 }
90
91 let r = await next(call)
92 if (r.isError && /already in use/i.test(r.text ?? '')) {
93 // Self-heal: offer to close the browser that blocks ours, then try once more.
94 const a = await inspect($)
95 if (a.state === 'blocked' && a.chromePid && (await closeBlocker($, a))) {
96 await $.process.run(['sleep', '1.5'])
97 r = await next(call)
98 void inspect($).catch(() => {})
99 } else {
100 $.ui.toast(`browser-lanes: ${describeAttachment(a)}. Run /browser clean.`, { timeoutMs: 8000 })
101 }
102 } else void inspect($).catch(() => {})
103 if (e.tool.endsWith('browser_close')) await release($, owner)
104 else await update($, holder, lane => (lane && lane.owner === owner ? { ...lane, last: Date.now() } : lane))
105 return r
106 })
107
108 // A subagent that finished lets go of the browser.
109 on('turn.complete', async ($, e, next) => {
110 const r = await next(e)
111 if (e.agentId) await release($, e.agentId)
112 else void inspect($).catch(() => {})
113 return r
114 })
115
116 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117 const rest = await next(e) // what other mods and Claude Code draw here stays
118 const a = await read($, attached)
119 if (e.props.hasSurvey || !a || a.state === 'none') return rest
120 const lane = await read($, holder)
121 const isDriving = lane !== null && Date.now() - lane.last <= IDLE_MS
122 const queue = await read($, waiting)
123 const { Box, Text } = $.ui.resolve(e)
124 const color = a.state === 'attached' ? 'green' : a.state === 'blocked' ? 'red' : undefined
125 return (
126 <Box flexDirection="column">
127 <Text wrap="truncate-end">
128 <Text color="magenta" bold>{' ◎ browser: '}</Text>
129 <Text color={color} dimColor={a.state === 'ready'}>{describeAttachment(a)}</Text>
130 {isDriving && <Text dimColor>{` · driving: ${lane.label}`}</Text>}
131 {queue.length > 0 && <Text color="yellow">{` · ${queue.length} waiting (${queue.join(', ')})`}</Text>}
132 {a.state === 'blocked' && <Text dimColor>{' · /browser'}</Text>}
133 </Text>
134 {rest}
135 </Box>
136 )
137 })
138}
139
140// Reads `ps` to see whose Chrome is whose, and keeps the answer for the band.
141async function inspect($: EngineInterface): Promise<Attachment> {
142 const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim()) // our Claude process
143 const ps = await $.process.run(['ps', '-axo', 'pid=,ppid=,command='], { timeoutMs: 10_000 })
144 const list = parsePs(ps.stdout)
145 const cwds = new Map<number, string>()
146 for (const pid of otherHolders(list, me)) {
147 const lsof = await $.process.run(['lsof', '-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeoutMs: 10_000 }).catch(() => null)
148 const cwd = lsof?.stdout.split('\n').find(l => l.startsWith('n'))?.slice(1)
149 if (cwd) cwds.set(pid, cwd)
150 }
151 const a = attachment(list, me, await $.session.cwd(), cwds)
152 await update($, attached, () => a)
153 return a
154}
155
156// Closes one Playwright Chrome, after checking the pid still is one: between `ps` and
157// `kill` the process may have ended and its pid gone to something else.
158async function closeChrome($: EngineInterface, pid: number) {
159 const now = await $.process.run(['ps', '-p', String(pid), '-o', 'command='], { timeoutMs: 2_000 }).catch(() => null)
160 if (!now || now.exitCode !== 0 || !/--user-data-dir=/.test(now.stdout) || /Helper/.test(now.stdout)) return false
161 const r = await $.process.run(['kill', String(pid)], { timeoutMs: 2_000 }).catch(() => null)
162 return r?.exitCode === 0
163}
164
165// Asks before closing the Chrome another Claude holds; never touches that Claude itself.
166async function closeBlocker($: EngineInterface, a: Attachment) {
167 const where = a.holder?.cwd ? ` in ${a.holder.cwd.split('/').slice(-2).join('/')}` : ''
168 try {
169 const answer = await $.ui.ask(
170 `browser-lanes: another Claude (pid ${a.holder?.claudePid}${where}) holds the browser this session needs. Close that browser and retry? Its Claude session stays open.`,
171 { options: ['Close it and retry', 'Leave it'], header: 'Browser' },
172 )
173 if (answer !== 'Close it and retry') return false
174 } catch {
175 return false
176 }
177 return a.chromePid ? closeChrome($, a.chromePid) : false
178}
179
180// /browser clean: lists every Playwright browser and closes the ones the person picks.
181async function clean($: EngineInterface) {
182 const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim())
183 const ps = await $.process.run(['ps', '-axo', 'pid=,ppid=,command='], { timeoutMs: 10_000 })
184 const all = browsers(parsePs(ps.stdout), me)
185 const others = all.filter(b => !b.isMine)
186 const orphans = others.filter(b => b.claudePid === undefined)
187 if (others.length === 0) return `${all.length === 1 ? 'Only this session has a browser.' : 'No Playwright browsers open.'} Nothing to clean.`
188
189 const list = others.map(b => `- Chrome ${b.chromePid} · ${b.claude}`).join('\n')
190 const options = [`Close all ${others.length} other browsers`, ...(orphans.length > 0 && orphans.length < others.length ? [`Close only the ${orphans.length} orphans`] : []), 'Keep them']
191 let answer = 'Keep them'
192 try {
193 answer = await $.ui.ask(`browser-lanes: ${others.length} Playwright browser(s) belong to other Claude sessions. Close which? (The Claude sessions stay open.)`, { options, header: 'Browser' })
194 } catch {
195 // no one to ask: keep them
196 }
197 const chosen = answer.startsWith('Close all') ? others : answer.startsWith('Close only') ? orphans : []
198 if (chosen.length === 0) return `Kept all browsers:\n${list}`
199 const closed: number[] = []
200 for (const b of chosen) {
201 if (await closeChrome($, b.chromePid)) closed.push(b.chromePid)
202 }
203 void inspect($).catch(() => {})
204 return `Closed ${closed.length} of ${chosen.length} browser(s):\n${chosen.map(b => `- Chrome ${b.chromePid} · ${b.claude}${closed.includes(b.chromePid) ? '' : ' (could not close)'}`).join('\n')}`
205}
206
207export function browserReport(a: Attachment) {
208 const lines = [`Browser: ${describeAttachment(a)}`, `- This Claude process: pid ${a.claudePid}; Playwright servers running on this machine: ${a.servers}`]
209 if (a.state === 'blocked') {
210 lines.push(`- The shared profile ${a.profile ?? ''} is held by Chrome pid ${a.chromePid} under Claude pid ${a.holder?.claudePid}${a.holder?.cwd ? ` (${a.holder.cwd})` : ''}.`)
211 lines.push('- Fix for good: run Playwright with --isolated so every session gets its own profile.')
212 lines.push('- Right now: /browser clean closes it (the other Claude session stays open).')
213 }
214 if (a.state !== 'none' && !a.isolated) lines.push('- Not isolated: sessions share one Chrome profile, so only one can have the browser at a time.')
215 return lines.join('\n')
216}
217
218async function release($: EngineInterface, owner: string) {
219 await update($, holder, lane => (lane && lane.owner === owner ? null : lane))
220}
221
222// The store is shared by every session of this plugin: note who drives, warn on a clash.
223async function claimAcrossSessions($: EngineInterface, label: string) {
224 await $.store.set('driver', { session: await $.session.id(), label, at: Date.now() })
225}
226
227async function warnOtherSession($: EngineInterface) {
228 const d = (await $.store.get('driver')) as { session?: string; label?: string; at?: number } | undefined
229 if (!d?.session || !d.at || Date.now() - d.at > IDLE_MS) return
230 if (d.session === (await $.session.id())) return
231 $.ui.toast(`browser-lanes: another Claude session (${d.session.slice(0, 8)}, ${d.label}) used the browser ${Math.round((Date.now() - d.at) / 1000)}s ago`)
232}
233
234// `login-test-03.png`, or the asked-for name with the driver in front.
235export function shotName(label: string, n: number, asked?: string, type?: string) {
236 if (asked) {
237 const base = asked.split('/').pop() ?? asked
238 if (base.startsWith(`${label}-`)) return asked
239 return asked.slice(0, asked.length - base.length) + `${label}-${base}`
240 }
241 return `${label}-${String(n).padStart(2, '0')}.${type ?? 'png'}`
242}
243
244export function slug(text: string) {
245 return (
246 text
247 .toLowerCase()
248 .replace(/[^a-z0-9]+/g, '-')
249 .replace(/^-+|-+$/g, '')
250 .slice(0, 24)
251 .replace(/-+$/, '') || 'agent'
252 )
253}
254
255function drop(list: string[], one: string) {
256 const i = list.indexOf(one)
257 return i === -1 ? list : [...list.slice(0, i), ...list.slice(i + 1)]
258}
259hooks/attach.ts 100 lines1// Which Playwright browser belongs to which Claude process, read off `ps`.
2// claude ─ npm exec @playwright/mcp ─ node playwright-mcp ─ Google Chrome --user-data-dir=…
3import type { Attachment } from '../types'
4
5export type Proc = { pid: number; ppid: number; command: string }
6
7// `ps -axo pid=,ppid=,command=` output, one process per line.
8export function parsePs(stdout: string): Proc[] {
9 return stdout
10 .split('\n')
11 .map(line => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line))
12 .filter((m): m is RegExpExecArray => m !== null)
13 .map(m => ({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] ?? '' }))
14}
15
16const isServer = (p: Proc) => /node\b.*playwright-mcp/.test(p.command) || /\bmcp-server-playwright\b/.test(p.command)
17const isChrome = (p: Proc) => /\/(Google Chrome|Chromium|chrome)( |$)/.test(p.command) && /--user-data-dir=/.test(p.command) && !/Helper/.test(p.command)
18const isClaude = (p: Proc) => /(^|\/)claude( |$)/.test(p.command)
19const profileOf = (p: Proc) => /--user-data-dir=(\S+)/.exec(p.command)?.[1]
20
21// The Claude process a process runs under, if any.
22function claudeOf(procs: Map<number, Proc>, pid: number): number | undefined {
23 for (let p = procs.get(pid), i = 0; p && i < 12; p = procs.get(p.ppid), i++) if (isClaude(p)) return p.pid
24 return undefined
25}
26
27// Claude processes, other than `me`, that run a Chrome on a shared Playwright profile:
28// the ones whose working folder we look up (a profile is per folder).
29export function otherHolders(list: Proc[], me: number): number[] {
30 const procs = new Map(list.map(p => [p.pid, p]))
31 const pids = list
32 .filter(c => isChrome(c) && /ms-playwright-mcp\/mcp-chrome/.test(profileOf(c) ?? ''))
33 .map(c => claudeOf(procs, c.pid))
34 .filter((pid): pid is number => pid !== undefined && pid !== me)
35 return [...new Set(pids)]
36}
37
38// What this Claude process (`me`, working in `myCwd`) has: no server, a server without a
39// browser, a browser, or a server whose profile (one per folder) another Claude holds.
40export function attachment(list: Proc[], me: number, myCwd = '', cwds: ReadonlyMap<number, string> = new Map()): Attachment {
41 const procs = new Map(list.map(p => [p.pid, p]))
42 const servers = list.filter(isServer)
43 const mine = servers.find(s => claudeOf(procs, s.pid) === me)
44 const chromes = list.filter(isChrome)
45 const base = { claudePid: me, servers: servers.length }
46 if (!mine) return { ...base, state: 'none', isolated: false }
47
48 const isolated = /--isolated\b/.test(mine.command)
49 const ours = chromes.find(c => c.ppid === mine.pid)
50 if (ours) return { ...base, state: 'attached', isolated, serverPid: mine.pid, chromePid: ours.pid, profile: profileOf(ours) }
51 if (isolated) return { ...base, state: 'ready', isolated, serverPid: mine.pid }
52
53 // Same folder, same shared profile: another Claude's Chrome there blocks ours.
54 const other = chromes.find(c => {
55 if (!/ms-playwright-mcp\/mcp-chrome/.test(profileOf(c) ?? '')) return false
56 const holder = claudeOf(procs, c.pid)
57 return holder !== undefined && holder !== me && myCwd !== '' && cwds.get(holder) === myCwd
58 })
59 if (!other) return { ...base, state: 'ready', isolated, serverPid: mine.pid }
60 const holderPid = claudeOf(procs, other.pid) ?? other.ppid
61 return {
62 ...base,
63 state: 'blocked',
64 isolated,
65 serverPid: mine.pid,
66 chromePid: other.pid,
67 profile: profileOf(other),
68 holder: { claudePid: holderPid, cwd: cwds.get(holderPid) ?? '', isThisSession: false },
69 }
70}
71
72// Every Playwright browser on the machine: its Chrome, its server, the Claude it serves.
73export type Browser = { chromePid: number; serverPid: number; claudePid?: number; claude: string; isMine: boolean }
74
75export function browsers(list: Proc[], me: number): Browser[] {
76 const procs = new Map(list.map(p => [p.pid, p]))
77 return list
78 .filter(c => isChrome(c) && procs.get(c.ppid) !== undefined && isServer(procs.get(c.ppid) as Proc))
79 .map(c => {
80 const claudePid = claudeOf(procs, c.pid)
81 const resume = claudePid ? /--resume\s+(\S{8})/.exec(procs.get(claudePid)?.command ?? '')?.[1] : undefined
82 return {
83 chromePid: c.pid,
84 serverPid: c.ppid,
85 claudePid,
86 claude: claudePid ? `Claude pid ${claudePid}${resume ? ` (session ${resume})` : ''}` : 'no Claude (orphan)',
87 isMine: claudePid === me,
88 }
89 })
90}
91
92// One line a person reads.
93export function describeAttachment(a: Attachment): string {
94 if (a.state === 'none') return 'no Playwright server in this session'
95 if (a.state === 'attached') return `attached · this session's Chrome${a.isolated ? ' · isolated' : ' · shared profile'}`
96 if (a.state === 'ready') return `ready · browser not open yet${a.isolated ? ' · isolated' : ''}`
97 const where = a.holder?.cwd ? ` in ${a.holder.cwd.split('/').slice(-2).join('/')}` : ''
98 return `NOT attached · another Claude (pid ${a.holder?.claudePid}${where}) holds the shared profile`
99}
100types/index.d.ts 20 lines1export type Lane = { owner: string; label: string; since: number; last: number }
2
3// Whether this Claude process has a Playwright browser, and if not, who holds the profile.
4export type Attachment = {
5 state: 'none' | 'ready' | 'attached' | 'blocked'
6 isolated: boolean
7 claudePid: number
8 serverPid?: number
9 chromePid?: number
10 profile?: string
11 holder?: { claudePid: number; cwd: string; isThisSession: boolean }
12 servers: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'browser-lanes': { holder: Lane | null; waiting: string[]; attachment: Attachment | null }
18 }
19}
20