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

꺼 둠 — 공식 terminal-browser 플러그인(
/browser)으로 대체했다. 기록으로 남겨 둔다.
Claude Code 안 pane 에 브라우저를 띄우는 mod. herdr 창을 나누지 않고, Claude 창을 zoom 한 채로 대화 옆에서 웹을 본다.
/web github.com # pane 을 열고 연다 (주소만 쓰면 https://, 낱말이면 DuckDuckGo 검색)
/web # 빈 브라우저
/web close # 닫는다 — pane 을 닫아도 꺼진다
/web debug # 상태 (그림이 안 나올 때)
clip.scale: 2 — 배율을 덮어쓰면 클릭 좌표가 가끔 반으로 들어간다.‹ › ↻ 와 주소칸(불러오는 중엔 ↻ 가 × 멈춤), 아래 줄은 제목·호스트·로딩. 휠은 페이지 스크롤, 그림을 클릭하면 페이지 클릭 + 키 입력이 페이지로 간다(Esc 로 돌아옴).agent-browser --cdp <포트> 로 그 탭을 읽고 누른다. 비밀번호 입력은 사람이 한다.~/.cache/claude-web/profile 에 남는다. 다른 세션이 이미 쓰고 있으면 그 세션만 임시 프로필(로그인 없음)로 뜨고, 끄면 지운다./Applications/Google Chrome.app), nodeCLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 (dotfiles zshrc 가 켠다). Claude Code 는 터미널 이름이 kitty·ghostty 일 때만 그림을 띄우는데 herdr 는 libghostty 라고 답해서, 이게 없으면 그림 자리에 페이지 제목만 흐리게 나온다 (/web debug 의 blit: 줄이 그 이유를 말한다)./plugin → web → configure)| 키 | 기본 | |
|---|---|---|
cell_aspect | 2.2 | 칸 높이÷너비. 페이지가 길쭉하면 줄이고 납작하면 키운다 |
px_per_column | 9 | 한 칸당 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)이 받는다.
hooks/register.tsx 225 lines1import 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}
225hooks/input.tsx 19 lines1import 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