SLOPSHOPPER

web

/web <주소> — 헤드리스 Chrome 화면을 Claude Code pane 에 띄운다. 클릭·스크롤·입력, Claude 도 같은 탭을 조작.

newpanecommandpromptprocessnetwork
v0.3.0MITupdated 2026-10-07JongDeug/dotfiles/ai/claude/mods/web
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · web
│ ┃ web ✕ › 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 │ ┃ │ ┃ › /web │ ┃ ⎿ web: 브라우저를 못 띄웠다: Error: browser exited │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · web
‹ › ↻ 주소 또는 검색어 ⏎ 이동 꺼져 있음 — 주소를 넣으면 다시 뜬다
README

web

꺼 둠 — 공식 terminal-browser 플러그인(/browser)으로 대체했다. 기록으로 남겨 둔다.

Claude Code 안 pane 에 브라우저를 띄우는 mod. herdr 창을 나누지 않고, Claude 창을 zoom 한 채로 대화 옆에서 웹을 본다.

/web github.com     # pane 을 열고 연다 (주소만 쓰면 https://, 낱말이면 DuckDuckGo 검색)
/web                # 빈 브라우저
/web close          # 닫는다 — pane 을 닫아도 꺼진다
/web debug          # 상태 (그림이 안 나올 때)
  • 화면 — 헤드리스 Chrome 화면을 2배(레티나) PNG 로 찍어 pane 의 그림(kitty 그래픽)을 바꿔 끼운다. 바뀔 때만 찍고, 같은 화면은 다시 안 보낸다. 창 배율(deviceScaleFactor)은 1 로 두고 스크린샷만 clip.scale: 2 — 배율을 덮어쓰면 클릭 좌표가 가끔 반으로 들어간다.
  • 조작 — 위 줄 ‹ › ↻ 와 주소칸(불러오는 중엔 ↻ 가 × 멈춤), 아래 줄은 제목·호스트·로딩. 휠은 페이지 스크롤, 그림을 클릭하면 페이지 클릭 + 키 입력이 페이지로 간다(Esc 로 돌아옴).
  • 검색 — 주소칸에 낱말을 넣으면 DuckDuckGo. 구글은 헤드리스 Chrome 을 로봇 확인 페이지로 막는다.
  • Claude 도 같은 탭을 — 떠 있는 동안 시스템 프롬프트에 CDP 포트가 들어가서, Claude 가 agent-browser --cdp <포트> 로 그 탭을 읽고 누른다. 비밀번호 입력은 사람이 한다.
  • 로그인 유지 — 프로필이 ~/.cache/claude-web/profile 에 남는다. 다른 세션이 이미 쓰고 있으면 그 세션만 임시 프로필(로그인 없음)로 뜨고, 끄면 지운다.

필요한 것

  • Google Chrome (/Applications/Google Chrome.app), node
  • 그림이 되는 터미널 — kitty·Ghostty. herdr 안이면 CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 (dotfiles zshrc 가 켠다). Claude Code 는 터미널 이름이 kitty·ghostty 일 때만 그림을 띄우는데 herdr 는 libghostty 라고 답해서, 이게 없으면 그림 자리에 페이지 제목만 흐리게 나온다 (/web debug 의 blit: 줄이 그 이유를 말한다).
  • Claude 조작용 agent-browser — PATH 에 없으면 terminal-browser 에 든 것을 찾아 쓴다.

설정 (/plugin → web → configure)

키기본
cell_aspect2.2칸 높이÷너비. 페이지가 길쭉하면 줄이고 납작하면 키운다
px_per_column9한 칸당 CSS 픽셀. 키우면 페이지가 넓게(작게) 보인다

안 될 때

  • /web debug — 브라우저 상태, 그림이 거부된 이유(blit:), 클릭·키가 몇 번 들어왔는지(input:).
  • ~/.cache/claude-web/ctl.log — 보조 프로세스가 받은 명령(navigate·click·key·wheel…)이 다 남는다. 클릭했는데 여기 /click 이 없으면 터미널→Claude Code 구간, 있는데 페이지가 반응 없으면 브라우저 구간 문제다.

구조

bin/browser.mjs (Node) 가 Chrome 을 띄워 CDP 로 붙들고, 화면 PNG 와 상태를 stdout 한 줄 JSON 으로 낸다. 명령(navigate·click·wheel·key·resize·quit)은 /tmp/claude-web-<pid>.sock 로 받는다. 훅 모듈(hooks/register.tsx)은 Node 가 없어서 이 프로세스를 $.process.spawn 으로 띄우고 $.http.fetch 로 부린다. 그림 위 클릭·키는 겹친 Client 영역(hooks/input.tsx)이 받는다.

Source 2 files
hooks/register.tsx 225 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const PANE = 'web'
4
5type Helper = { socket: string; cdpPort: number; agentBrowser: string | null }
6type Line =
7  | { type: 'ready'; socket: string; cdpPort: number; agentBrowser: string | null }
8  | { type: 'frame'; file: string; generation: number }
9  | { type: 'page'; url: string; title: string }
10  | { type: 'error'; message: string }
11  | { type: 'loading'; on: boolean }
12
13// 모듈 변수 — 리로드되면 보조 프로세스도 같이 죽으니 함께 비워져도 맞다.
14let helper: Helper | null = null
15let starting: Promise<Helper> | null = null
16let frame: { file: string; generation: number } | null = null
17let page = { url: '', title: '' }
18let lastError = ''
19let loading = false
20// /web debug 용 — 클릭·키가 실제로 들어오는지.
21let inputs = { clicks: 0, keys: 0 }
22let sent = { width: 0, height: 0 }
23// /web debug 가 보여 줄 마지막 상태.
24let lastBlit = '아직 없음'
25let lastRender = '아직 안 그려짐'
26
27// 보조 프로세스(bin/browser.mjs)를 띄우고 준비될 때까지 기다린다. 이미 떠 있으면 그대로.
28function start($: EngineInterface): Promise<Helper> {
29  if (helper) return Promise.resolve(helper)
30  if (starting) return starting
31  starting = (async () => {
32    const home = (await $.env.get('HOME')) ?? ''
33    const child = $.process.spawn({ argv: ['node', `${$.plugin.root}/bin/browser.mjs`, `${home}/.cache/claude-web`] })
34    return new Promise<Helper>((resolve, reject) => {
35      void (async () => {
36        let buffer = ''
37        try {
38          for await (const piece of child) {
39            if (!('stream' in piece) || piece.stream !== 'stdout') continue
40            buffer += piece.text
41            const lines = buffer.split('\n')
42            buffer = lines.pop() ?? ''
43            for (const line of lines) if (line) await onLine($, JSON.parse(line) as Line, resolve)
44          }
45        } catch (error) {
46          reject(error)
47        } finally {
48          helper = null
49          starting = null
50          frame = null
51          sent = { width: 0, height: 0 }
52          $.ui.invalidate('ui.render')
53          reject(new Error('browser exited'))
54        }
55      })()
56    })
57  })()
58  return starting
59}
60
61async function onLine($: EngineInterface, line: Line, resolve: (h: Helper) => void) {
62  if (line.type === 'ready') {
63    helper = { socket: line.socket, cdpPort: line.cdpPort, agentBrowser: line.agentBrowser }
64    lastError = ''
65    resolve(helper)
66  } else if (line.type === 'error') {
67    lastError = line.message
68  } else if (line.type === 'loading') {
69    loading = line.on
70    $.ui.invalidate('ui.render')
71  } else if (line.type === 'frame') {
72    frame = { file: line.file, generation: line.generation }
73    // 그림만 바꿔 끼운다. 아직 안 그려졌거나 크기가 바뀌었으면 다시 그린다.
74    const blit = await $.ui.blit({ requestId: PANE, key: 'view', source: { file: line.file, format: 'png', generation: line.generation } })
75    lastBlit = blit.deny ?? 'ok'
76    if (blit.deny) $.ui.invalidate('ui.render')
77  } else {
78    page = { url: line.url, title: line.title || page.title }
79    $.ui.invalidate('ui.render')
80  }
81}
82
83// 떠 있을 때만 끈다 — 끄려고 새로 띄우지 않게.
84async function stop($: EngineInterface): Promise<void> {
85  if (helper) await $.http.fetch('http://web/quit', { method: 'POST', socketPath: helper.socket })
86}
87
88async function call($: EngineInterface, cmd: string, body: object = {}): Promise<void> {
89  const h = await start($)
90  await $.http.fetch(`http://web/${cmd}`, { method: 'POST', body: JSON.stringify(body), socketPath: h.socket })
91}
92
93export const register: Register = (on, options) => {
94  const cellAspect = typeof options.cell_aspect === 'number' ? options.cell_aspect : 2.2
95  const pxPerColumn = typeof options.px_per_column === 'number' ? options.px_per_column : 9
96
97  on('session.start', async ($, e, next) => {
98    await $.command.register({ name: 'web', description: '브라우저를 pane 에 띄운다 (/web <주소>, /web close, /web debug)', argumentHint: '<url> | close | debug' })
99    return next(e)
100  })
101
102  on('command.run', { command: 'web' }, async ($, e) => {
103    const arg = e.args.trim()
104    if (arg === 'debug') {
105      const panes = await $.ui.panes()
106      return {
107        text: [
108          `helper: ${helper ? `socket ${helper.socket}, cdp ${helper.cdpPort}, agent-browser ${helper.agentBrowser}` : starting ? '띄우는 중' : '없음'}`,
109          `page: ${page.url} (${page.title})`,
110          `frame: ${frame ? `#${frame.generation} ${frame.file}` : '없음'}, browser ${sent.width}x${sent.height}`,
111          `render: ${lastRender}`,
112          `blit: ${lastBlit}`,
113          `input: 클릭 ${inputs.clicks}번, 키 ${inputs.keys}번 받음`,
114          `panes: ${JSON.stringify(panes)}`,
115          `presentation: ${JSON.stringify(e.presentation)}`,
116          `env: TERM=${await $.env.get('TERM')} TERM_PROGRAM=${await $.env.get('TERM_PROGRAM')} KITTY_WINDOW_ID=${await $.env.get('KITTY_WINDOW_ID')}`,
117        ].join('\n'),
118      }
119    }
120    if (arg === 'close') {
121      await stop($)
122      await $.ui.close({ id: PANE })
123      return { text: '브라우저를 닫았다.' }
124    }
125    await $.ui.open({ id: PANE, title: 'web' })
126    try {
127      if (arg) await call($, 'navigate', { url: arg })
128      else await start($)
129    } catch (error) {
130      return { text: `브라우저를 못 띄웠다: ${lastError || String(error)}` }
131    }
132    return { text: arg ? `${arg} 를 연다.` : '브라우저 pane 을 열었다.' }
133  })
134
135  on('ui.close', { id: PANE }, async ($, e, next) => {
136    await stop($)
137    return next(e)
138  }).catch(($, e, next) => next(e))
139
140  // 바깥 Claude 도 같은 탭을 다룰 수 있게 알려 준다 — 떠 있을 때만.
141  on('prompt.compose', async ($, e, next) => {
142    const composed = await next(e)
143    if (!helper?.agentBrowser) return composed
144    const text = [
145      '# Browser pane',
146      `The person has a browser pane open in this terminal (headless Chrome, CDP port ${helper.cdpPort}), now at ${page.url || 'about:blank'}.`,
147      `To read or act on the page they see, run \`${helper.agentBrowser} --cdp ${helper.cdpPort} <command>\` with Bash: snapshot -i, click @e3, fill @e5 "text", open <url>, get text body. It is the same tab, so they watch what you do.`,
148      'Do not type passwords or one-time codes for them; ask them to do it in the pane.',
149    ].join('\n')
150    return { sections: [...composed.sections, { id: 'web:pane', text, scope: 'session' as const }] }
151  })
152
153  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154    if (e.surface !== 'terminal') return $.ui.resolve(e).Text({ children: '브라우저 pane 은 터미널에서만 보인다.' })
155    const { Box, Button, Client, Image, Input, Text } = $.ui.resolve(e)
156    const cols = Math.min(255, Math.max(10, e.props.bodyColumns))
157    // 위 도구줄 1줄 + 아래 상태줄 1줄.
158    const rows = Math.min(255, Math.max(4, e.props.scroll.bodyRows - 2))
159    lastRender = `${e.props.placement} ${cols}x${rows} 칸, focused ${e.props.isFocused}, frame ${frame ? '있음' : '없음'}`
160    // pane 크기에 맞춰 브라우저 창 크기를 바꾼다. 비율이 맞아야 그림이 안 찌그러진다.
161    const width = Math.round(cols * pxPerColumn)
162    const height = Math.round(rows * pxPerColumn * cellAspect)
163    if (helper && (width !== sent.width || height !== sent.height)) {
164      sent = { width, height }
165      void call($, 'resize', sent)
166    }
167    const host = page.url.replace(/^https?:\/\//, '').split('/')[0] ?? ''
168    const status = !helper
169      ? starting
170        ? '브라우저를 띄우는 중…'
171        : `꺼져 있음${lastError ? ` · ${lastError}` : ''} — 주소를 넣으면 다시 뜬다`
172      : loading
173        ? `불러오는 중… ${host}`
174        : [page.title, host].filter(Boolean).join('  ·  ') || '빈 페이지'
175    return (
176      <Box flexDirection="column">
177        <Box flexDirection="row" columnGap={1}>
178          <Button key="back" plain onPress={() => void call($, 'back')}>‹</Button>
179          <Button key="forward" plain onPress={() => void call($, 'forward')}>›</Button>
180          <Button key="reload" plain onPress={() => void call($, 'reload')}>{loading ? '×' : '↻'}</Button>
181          <Box flexGrow={1} flexShrink={1}>
182            <Input key="url" value={page.url} placeholder="주소 또는 검색어" submitLabel="이동" onSubmit={url => void call($, 'navigate', { url })} />
183          </Box>
184        </Box>
185        <Box width={cols} height={rows}>
186          {frame && helper ? (
187            // 그림 위에 클릭 영역을 띄운다. 엔진은 맨 위에 그려진 것부터 거슬러 올라가며 onPointer
188            // 를 찾으므로, 클릭 영역이 위에 있어야 클릭이 닿는다.
189            <>
190              <Image key="view" source={{ file: frame.file, format: 'png', generation: frame.generation }} columns={cols} rows={rows} alt={page.title || page.url || ' '} />
191              <Box position="absolute" top={0} left={0}>
192                <Client key="input" module="./input.tsx" width={cols} height={rows} />
193              </Box>
194            </>
195          ) : null}
196        </Box>
197        <Text dimColor wrap="truncate-end">{status}</Text>
198      </Box>
199    )
200  })
201
202  // 그림 위 클릭·키 (input.tsx 가 보낸다).
203  on('ui.message', { requestId: PANE }, async ($, e, next) => {
204    const data = e.data as { type: 'click'; x: number; y: number } | { type: 'key'; key: string; ctrl: boolean; meta: boolean; shift: boolean }
205    if (data.type === 'click') {
206      inputs.clicks += 1
207      await call($, 'click', { x: data.x, y: data.y })
208    } else {
209      inputs.keys += 1
210      await call($, 'key', data)
211    }
212    return next(e)
213  })
214
215  // pane 위 휠은 pane 을 굴리지 않고 페이지를 굴린다. 첫 줄은 주소창이라 빼고 계산.
216  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
217    if (helper && e.pointer && sent.width > 0) {
218      const rows = Math.max(1, Math.round(sent.height / (pxPerColumn * cellAspect)))
219      const cols = Math.max(1, Math.round(sent.width / pxPerColumn))
220      void call($, 'wheel', { x: (e.pointer.column + 0.5) / cols, y: (e.pointer.row - 1 + 0.5) / rows, dy: e.by * 120 })
221    }
222    return next({ ...e, offset: 0 })
223  }).catch(($, e, next) => next(e))
224}
225
hooks/input.tsx 19 lines
1import type { ClientModule, JsonValue } from 'claude-code'
2
3// 페이지 그림 위에 겹친 빈 영역. 클릭과(클릭하면 포커스가 와서) 키 입력만 받아 훅 모듈로 넘긴다.
4// 좌표는 영역에 대한 0~1 비율 — 페이지 크기는 보조 프로세스만 안다.
5const PageInput: ClientModule<JsonValue, boolean> = (_, s) => {
6  if (s.state === undefined) {
7    s.onPointer(e => {
8      if (e.type !== 'up' || s.columns === 0 || s.rows === 0) return
9      s.post({ type: 'click', x: (e.fine?.x ?? e.x + 0.5) / s.columns, y: (e.fine?.y ?? e.y + 0.5) / s.rows })
10    })
11    s.onKey(k => s.post({ type: 'key', key: k.key, ctrl: k.ctrl ?? false, meta: k.meta ?? false, shift: k.shift ?? false }))
12    s.setState(true)
13  }
14  const { Box } = s.elements
15  return <Box width={s.columns} height={s.rows} />
16}
17
18export default PageInput
19