SLOPSHOPPER

browser-lanes

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…

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · browser-lanes
› 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 › /browser ⎿ browser-lanes: Browser: no Playwright server in this session ⎿ browser-lanes: - This Claude process: pid 0; Playwright servers running on this machine: 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

Source 4 files
hooks/register.tsx 271 lines
1// 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}
271
hooks/attach.ts 100 lines
1// 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}
100
hooks/bands.ts 73 lines
1// 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}
73
types/index.d.ts 20 lines
1export 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