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…

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 271 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'
14import { bandsFile, parseOrder, stackBands } from './bands'
15
16const BROWSER_TOOL = /^mcp__.*playwright.*__browser_/
17const IDLE_MS = 90_000 // a driver this quiet lets go
18const MAX_WAIT_MS = 5 * 60_000
19
20// Held by the host, so a reload keeps who holds the browser.
21const holder = atom({ plugin: 'browser-lanes', key: 'holder' } as const, null as Lane | null)
22const waiting = atom({ plugin: 'browser-lanes', key: 'waiting' } as const, [] as string[])
23const attached = atom({ plugin: 'browser-lanes', key: 'attachment' } as const, null as Attachment | null)
24
25let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
26
27export const register: Register = on => {
28 const names = new Map<string, string>() // agent id → a short label
29 const shots = new Map<string, number>() // label → screenshots taken
30
31 on('session.start', async ($, e, next) => {
32 const r = await next(e)
33 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
34 void inspect($).catch(() => {})
35 return r
36 })
37
38 on('command.run', { command: 'browser' }, async ($, e) => {
39 if (e.args.trim() === 'clean') return { text: await clean($) }
40 const a = await inspect($)
41 return { text: browserReport(a) }
42 })
43
44 // Closing the session closes its browser too.
45 on('session.end', async ($, e, next) => {
46 const a = await read($, attached)
47 if (a?.state === 'attached' && a.chromePid) await closeChrome($, a.chromePid)
48 return next(e)
49 })
50
51 on('agent.spawn', async ($, e, next) => {
52 const r = await next(e)
53 if (r.agentId) names.set(r.agentId, slug(e.name || e.description || e.subagentType))
54 return r
55 })
56
57 on('tool.call', async ($, e, next) => {
58 if (!BROWSER_TOOL.test(e.tool)) return next(e)
59 const owner = e.agentId ?? 'main'
60 const label = e.agentId ? (names.get(e.agentId) ?? `agent-${e.agentId.slice(0, 6)}`) : 'main'
61
62 await warnOtherSession($)
63
64 // Wait for the lane: time inside $ calls doesn't count against the hook's limit.
65 const started = Date.now()
66 let isQueued = false
67 while (!next.signal.aborted) {
68 const lane = await read($, holder)
69 if (!lane || lane.owner === owner || Date.now() - lane.last > IDLE_MS) break
70 if (!isQueued) {
71 isQueued = true
72 await update($, waiting, w => [...w, label])
73 }
74 if (Date.now() - started > MAX_WAIT_MS) {
75 await update($, waiting, w => drop(w, label))
76 return { deny: `browser-lanes: the browser has been busy with "${lane.label}" for over 5 minutes. Try again later, or ask the user.` }
77 }
78 await $.process.run(['sleep', '0.5'])
79 }
80 if (isQueued) await update($, waiting, w => drop(w, label))
81
82 const at = Date.now()
83 await update($, holder, lane => (lane && lane.owner === owner ? { ...lane, last: at } : { owner, label, since: at, last: at }))
84 await claimAcrossSessions($, label)
85
86 let call = e
87 if (e.tool.endsWith('browser_take_screenshot')) {
88 const n = (shots.get(label) ?? 0) + 1
89 shots.set(label, n)
90 const args = e as unknown as { filename?: string; type?: string }
91 call = { ...e, filename: shotName(label, n, args.filename, args.type) } as typeof e
92 }
93
94 let r = await next(call)
95 if (r.isError && /already in use/i.test(r.text ?? '')) {
96 // Self-heal: offer to close the browser that blocks ours, then try once more.
97 const a = await inspect($)
98 if (a.state === 'blocked' && a.chromePid && (await closeBlocker($, a))) {
99 await $.process.run(['sleep', '1.5'])
100 r = await next(call)
101 void inspect($).catch(() => {})
102 } else {
103 $.ui.toast(`browser-lanes: ${describeAttachment(a)}. Run /browser clean.`, { timeoutMs: 8000 })
104 }
105 } else void inspect($).catch(() => {})
106 if (e.tool.endsWith('browser_close')) await release($, owner)
107 else await update($, holder, lane => (lane && lane.owner === owner ? { ...lane, last: Date.now() } : lane))
108 return r
109 })
110
111 // A subagent that finished lets go of the browser.
112 on('turn.complete', async ($, e, next) => {
113 const r = await next(e)
114 if (e.agentId) await release($, e.agentId)
115 else void inspect($).catch(() => {})
116 return r
117 })
118
119 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120 const rest = await next(e) // what other mods and Claude Code draw here stays
121 const a = await read($, attached)
122 if (e.props.hasSurvey || !a || a.state === 'none') return rest
123 const lane = await read($, holder)
124 const isDriving = lane !== null && Date.now() - lane.last <= IDLE_MS
125 const queue = await read($, waiting)
126 const { Text } = $.ui.resolve(e)
127 const color = a.state === 'attached' ? 'green' : a.state === 'blocked' ? 'red' : undefined
128 const mine = (
129 <Text wrap="truncate-end">
130 <Text color="magenta" bold>{' ◎ browser: '}</Text>
131 <Text color={color} dimColor={a.state === 'ready'}>{describeAttachment(a)}</Text>
132 {isDriving && <Text dimColor>{` · driving: ${lane.label}`}</Text>}
133 {queue.length > 0 && <Text color="yellow">{` · ${queue.length} waiting (${queue.join(', ')})`}</Text>}
134 {a.state === 'blocked' && <Text dimColor>{' · /browser'}</Text>}
135 </Text>
136 )
137 return stackBands(rest, 'browser-lanes', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
138 })
139}
140
141// The band order file, as the claude-code-mods checklist saves it; [] on any error.
142async function readBandOrder($: EngineInterface): Promise<string[]> {
143 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
144 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
145}
146
147// Forgets the cached band order, for tests.
148export function resetBandOrder() {
149 bandOrder = undefined
150}
151
152// Reads `ps` to see whose Chrome is whose, and keeps the answer for the band.
153async function inspect($: EngineInterface): Promise<Attachment> {
154 const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim()) // our Claude process
155 const ps = await $.process.run(['ps', '-axo', 'pid=,ppid=,command='], { timeoutMs: 10_000 })
156 const list = parsePs(ps.stdout)
157 const cwds = new Map<number, string>()
158 for (const pid of otherHolders(list, me)) {
159 const lsof = await $.process.run(['lsof', '-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeoutMs: 10_000 }).catch(() => null)
160 const cwd = lsof?.stdout.split('\n').find(l => l.startsWith('n'))?.slice(1)
161 if (cwd) cwds.set(pid, cwd)
162 }
163 const a = attachment(list, me, await $.session.cwd(), cwds)
164 await update($, attached, () => a)
165 return a
166}
167
168// Closes one Playwright Chrome, after checking the pid still is one: between `ps` and
169// `kill` the process may have ended and its pid gone to something else.
170async function closeChrome($: EngineInterface, pid: number) {
171 const now = await $.process.run(['ps', '-p', String(pid), '-o', 'command='], { timeoutMs: 2_000 }).catch(() => null)
172 if (!now || now.exitCode !== 0 || !/--user-data-dir=/.test(now.stdout) || /Helper/.test(now.stdout)) return false
173 const r = await $.process.run(['kill', String(pid)], { timeoutMs: 2_000 }).catch(() => null)
174 return r?.exitCode === 0
175}
176
177// Asks before closing the Chrome another Claude holds; never touches that Claude itself.
178async function closeBlocker($: EngineInterface, a: Attachment) {
179 const where = a.holder?.cwd ? ` in ${a.holder.cwd.split('/').slice(-2).join('/')}` : ''
180 try {
181 const answer = await $.ui.ask(
182 `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.`,
183 { options: ['Close it and retry', 'Leave it'], header: 'Browser' },
184 )
185 if (answer !== 'Close it and retry') return false
186 } catch {
187 return false
188 }
189 return a.chromePid ? closeChrome($, a.chromePid) : false
190}
191
192// /browser clean: lists every Playwright browser and closes the ones the person picks.
193async function clean($: EngineInterface) {
194 const me = Number((await $.process.run(['sh', '-c', 'echo $PPID'])).stdout.trim())
195 const ps = await $.process.run(['ps', '-axo', 'pid=,ppid=,command='], { timeoutMs: 10_000 })
196 const all = browsers(parsePs(ps.stdout), me)
197 const others = all.filter(b => !b.isMine)
198 const orphans = others.filter(b => b.claudePid === undefined)
199 if (others.length === 0) return `${all.length === 1 ? 'Only this session has a browser.' : 'No Playwright browsers open.'} Nothing to clean.`
200
201 const list = others.map(b => `- Chrome ${b.chromePid} · ${b.claude}`).join('\n')
202 const options = [`Close all ${others.length} other browsers`, ...(orphans.length > 0 && orphans.length < others.length ? [`Close only the ${orphans.length} orphans`] : []), 'Keep them']
203 let answer = 'Keep them'
204 try {
205 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' })
206 } catch {
207 // no one to ask: keep them
208 }
209 const chosen = answer.startsWith('Close all') ? others : answer.startsWith('Close only') ? orphans : []
210 if (chosen.length === 0) return `Kept all browsers:\n${list}`
211 const closed: number[] = []
212 for (const b of chosen) {
213 if (await closeChrome($, b.chromePid)) closed.push(b.chromePid)
214 }
215 void inspect($).catch(() => {})
216 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')}`
217}
218
219export function browserReport(a: Attachment) {
220 const lines = [`Browser: ${describeAttachment(a)}`, `- This Claude process: pid ${a.claudePid}; Playwright servers running on this machine: ${a.servers}`]
221 if (a.state === 'blocked') {
222 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})` : ''}.`)
223 lines.push('- Fix for good: run Playwright with --isolated so every session gets its own profile.')
224 lines.push('- Right now: /browser clean closes it (the other Claude session stays open).')
225 }
226 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.')
227 return lines.join('\n')
228}
229
230async function release($: EngineInterface, owner: string) {
231 await update($, holder, lane => (lane && lane.owner === owner ? null : lane))
232}
233
234// The store is shared by every session of this plugin: note who drives, warn on a clash.
235async function claimAcrossSessions($: EngineInterface, label: string) {
236 await $.store.set('driver', { session: await $.session.id(), label, at: Date.now() })
237}
238
239async function warnOtherSession($: EngineInterface) {
240 const d = (await $.store.get('driver')) as { session?: string; label?: string; at?: number } | undefined
241 if (!d?.session || !d.at || Date.now() - d.at > IDLE_MS) return
242 if (d.session === (await $.session.id())) return
243 $.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`)
244}
245
246// `login-test-03.png`, or the asked-for name with the driver in front.
247export function shotName(label: string, n: number, asked?: string, type?: string) {
248 if (asked) {
249 const base = asked.split('/').pop() ?? asked
250 if (base.startsWith(`${label}-`)) return asked
251 return asked.slice(0, asked.length - base.length) + `${label}-${base}`
252 }
253 return `${label}-${String(n).padStart(2, '0')}.${type ?? 'png'}`
254}
255
256export function slug(text: string) {
257 return (
258 text
259 .toLowerCase()
260 .replace(/[^a-z0-9]+/g, '-')
261 .replace(/^-+|-+$/g, '')
262 .slice(0, 24)
263 .replace(/-+$/, '') || 'agent'
264 )
265}
266
267function drop(list: string[], one: string) {
268 const i = list.indexOf(one)
269 return i === -1 ? list : [...list.slice(0, i), ...list.slice(i + 1)]
270}
271hooks/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}
100hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/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