/web opens a link in terminal-browser in a herdr pane below (the last link of Claude's reply by default); the web_read tool lets Claude read the open page as a…

/web opens a link in terminal-browser (the browser that runs in a terminal pane) in a herdr pane below this one, and the web_read tool lets Claude read the page that browser shows. The mod fetches nothing itself: the browser does the fetching, and the mod only runs herdr and terminal-browser.
/web [<url>] | close | trusted | forget <origin>
/web <url>: opens the address. In herdr (HERDR_ENV=1) only; elsewhere the answer is "/web needs a herdr pane".herdr pane split --current --direction down --no-focus --cwd <session cwd>herdr pane run <new pane id> "terminal-browser open '<url>'"The new pane's id is kept in $.state (web.pane). While the browser in that pane still runs, a later /web <url> opens a new tab there instead (terminal-browser action --browser <key> -- open <url>, terminal-browser's own open-tab, no shell) and splits nothing.
http:// or https:// only; no whitespace, quote, backslash, control character, user name or password. The address goes out as the URL parser writes it (ASCII), inside single quotes./web alone: the last http(s):// link in Claude's last reply with text (bare, in a Markdown link or in <...>; trailing punctuation dropped)./web close: closes the pane this mod opened, with herdr pane close <id>. Not terminal-browser shutdown, which stops the whole daemon and every browser. When herdr refuses, the answer says so and the pane stays remembered; close it in herdr by hand./web trusted: the sites web_read reads without asking./web forget <origin>: removes one (any address on that site works).mcp__web__web_read, deferred (behind ToolSearch, so its schema does not ride in every request). Input { url?: string }.
url: reads the active tab of the browser in the pane /web opened, else of the first browser in this terminal tab (terminal-browser ls --json).url: asks about the site first, then opens it (a new tab of a running browser, or a new herdr pane as /web does), waits up to about 3 seconds for the tab to show it, and reads it.terminal-browser action --browser <key> --tab <id> -- eval <fixed script> --json. The script (in hooks/kit.ts) only reads: it clones main, else article, else body, drops script, style, noscript, template, nav, header, footer, aside, frames, svg, canvas, form controls, nodes hidden by attribute and nodes the page hides with CSS (display: none, visibility: hidden), collapses whitespace and cuts the text at 20,000 characters; links are the first 50 distinct http(s) anchors. { "origin": "https://docs.example.com", "pathname": "/guide/intro",
"title": "...", "text": "...", "links": [{ "text": "Next", "href": "https://docs.example.com/guide/next" }],
"omitted": ["query strings", "fragments", "cookies", "storage", "form values", "frames", "images"] }
omitted adds text beyond 20,000 characters and links beyond 50 when either was cut. The mod bounds the page's answer again whatever the page returned: origin and path re-derived (http/https only), title 300 characters, text 20,000, links 50 with every href reduced to origin + pathname, omitted rebuilt.
$.store (web.trusted) gets one question: "Let Claude read pages from <origin>?" with Allow once, Always, No. Always stores it; No, a typed answer or a dismissed question returns {"denied":"<origin>"} and reads nothing. The page's own answer is not trusted for its origin: after the read, terminal-browser ls --json gives the tab's real address, a disagreement returns nothing, and a page that moved to another site between the check and the read is asked about again.The capture's shape follows Zehui's ui-selector CaptureV1 contract: page identity is origin + pathname, and everything not captured is named.
| Option | Values | Default |
|---|---|---|
split | down, right | down |
From the local marketplace folder (~/.claude/mods):
claude plugin install web@zehui-mods --scope user
herdr (split, run, close) and terminal-browser (ls, action open, action eval), nothing else. No model is called. Nothing is drawn: no band row, no pane of its own./web or web_read while a pane is being split says so.hooks/register.tsx 345 lines1// The web mod: /web opens an address in terminal-browser in a herdr pane
2// below this one, and the web_read tool reads the page the browser shows as a
3// bounded capture (kit.ts has the page script and the bounds). The mod itself
4// fetches nothing: the browser does, and the mod only runs `herdr` and
5// `terminal-browser` through $.process.run. Trusted origins live in $.store
6// (web.trusted); the pane /web opened in $.state (web.pane).
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, PluginOptions, Register } from 'claude-code'
9
10import type { WebCapture } from '../types'
11import {
12 ALLOW_ONCE,
13 ALWAYS,
14 ASK_HEADER,
15 COMMAND,
16 EVAL_TIMEOUT_MS,
17 HERDR_TIMEOUT_MS,
18 LOADING_RETRIES,
19 LOADING_WAIT_MS,
20 LS_TIMEOUT_MS,
21 NEEDS_HERDR,
22 NO,
23 OPEN_TIMEOUT_MS,
24 PAGE_SCRIPT,
25 POLL_MS,
26 POLL_TRIES,
27 STORE_TRUSTED,
28 TOOL,
29 TOOL_DESCRIPTION,
30 TOOL_SCHEMA,
31 USAGE,
32 activeTab,
33 askQuestion,
34 boundCapture,
35 checkUrl,
36 directionOf,
37 evalErrorOf,
38 lastLinkIn,
39 openLine,
40 originOf,
41 pageOf,
42 paneIdOf,
43 parseArgs,
44 parseLs,
45 pickBrowser,
46 tabShowing,
47 trustedOf,
48 unwrapEval,
49 whyOf,
50} from './kit'
51import type { TbBrowser, TbTab } from './kit'
52
53const pane = atom({ plugin: 'web', key: 'pane' } as const, '')
54
55// One open at a time: a second /web or web_read while a pane is being split
56// waits for nothing and splits nothing. Set before any await.
57let isOpening = false
58
59/** A step that failed, with the words the person or the model reads. */
60class Refusal extends Error {}
61
62const firstLine = (s: string): string => s.trim().split('\n')[0]?.slice(0, 200) ?? ''
63
64async function isInHerdr($: EngineInterface): Promise<boolean> {
65 return (await $.env.get('HERDR_ENV')) === '1'
66}
67
68/** The browsers terminal-browser lists for this terminal tab; [] when none or when ls fails. */
69async function listBrowsers($: EngineInterface): Promise<TbBrowser[]> {
70 try {
71 const r = await $.process.run(['terminal-browser', 'ls', '--json'], { timeoutMs: LS_TIMEOUT_MS })
72 return r.exitCode === 0 ? (parseLs(r.stdout) ?? []) : []
73 } catch {
74 return []
75 }
76}
77
78/** Splits a herdr pane (no focus, the session's cwd) and runs the browser on `href` in it; answers the pane id. */
79async function splitAndOpen($: EngineInterface, options: PluginOptions, href: string): Promise<string> {
80 if (!(await isInHerdr($))) throw new Refusal(NEEDS_HERDR)
81 const cwd = await $.session.cwd()
82 const split = await $.process.run(
83 ['herdr', 'pane', 'split', '--current', '--direction', directionOf(options.split), '--no-focus', '--cwd', cwd],
84 { timeoutMs: HERDR_TIMEOUT_MS },
85 )
86 const id = split.exitCode === 0 ? paneIdOf(split.stdout) : null
87 if (!id) throw new Refusal(`herdr pane split gave no pane${split.stderr ? `: ${firstLine(split.stderr)}` : ''}`)
88 await update($, pane, () => id)
89 const run = await $.process.run(['herdr', 'pane', 'run', id, openLine(href)], { timeoutMs: HERDR_TIMEOUT_MS })
90 if (run.exitCode !== 0) throw new Refusal(`herdr pane run failed in ${id}${run.stderr ? `: ${firstLine(run.stderr)}` : ''}`)
91 return id
92}
93
94/** Opens `href` as a new tab of a browser already running (terminal-browser's own open-tab; no shell). */
95async function openTab($: EngineInterface, b: TbBrowser, href: string): Promise<void> {
96 const r = await $.process.run(['terminal-browser', 'action', '--browser', b.key, '--', 'open', href], { timeoutMs: OPEN_TIMEOUT_MS })
97 if (r.exitCode !== 0) throw new Refusal(`terminal-browser could not open a tab in ${b.key}${r.stderr ? `: ${firstLine(r.stderr)}` : ''}`)
98}
99
100type Opened = { how: 'tab' | 'pane'; paneId: string | null; browserKey: string | null }
101
102/**
103 * Opens `href`: as a new tab of the browser in the pane /web opened when it
104 * still runs, else in a new herdr pane. With `isReuseAny` (web_read), any
105 * browser of this terminal tab takes the new tab.
106 */
107async function openUrl($: EngineInterface, options: PluginOptions, href: string, isReuseAny: boolean): Promise<Opened> {
108 if (isOpening) throw new Refusal(`a page is already opening; try again in a moment (/${COMMAND} ${href})`)
109 isOpening = true
110 try {
111 const remembered = await read($, pane)
112 if (remembered || isReuseAny) {
113 const browsers = await listBrowsers($)
114 const b = remembered ? browsers.find(x => x.pane === remembered) : undefined
115 const target = b ?? (isReuseAny ? pickBrowser(browsers, remembered) : null)
116 if (target) {
117 await openTab($, target, href)
118 return { how: 'tab', paneId: target.pane, browserKey: target.key }
119 }
120 }
121 return { how: 'pane', paneId: await splitAndOpen($, options, href), browserKey: null }
122 } finally {
123 isOpening = false
124 }
125}
126
127/** /web <url> and /web: open and answer what happened. */
128async function webOpen($: EngineInterface, options: PluginOptions, raw: string): Promise<string> {
129 const url = checkUrl(raw)
130 if (!url.ok) return `Not opened: ${url.why}. ${USAGE}`
131 if (!(await isInHerdr($))) return NEEDS_HERDR
132 try {
133 const o = await openUrl($, options, url.href, false)
134 return o.how === 'tab'
135 ? `Opened ${url.href} in a new tab of the browser in pane ${o.paneId} (/${COMMAND} close closes that pane).`
136 : `Opened ${url.href} in pane ${o.paneId} ${directionOf(options.split) === 'right' ? 'to the right' : 'below'} (/${COMMAND} close closes it).`
137 } catch (err) {
138 return `Not opened: ${whyOf(err)}`
139 }
140}
141
142/** The last http(s) link of the main conversation's last assistant reply with text. */
143async function lastReplyLink($: EngineInterface): Promise<string | null> {
144 const all = await $.session.messages()
145 for (let i = all.length - 1; i >= 0; i--) {
146 const m = all[i]!
147 if (m.role === 'assistant' && m.text.trim() !== '') return lastLinkIn(m.text)
148 }
149 return null
150}
151
152async function webClose($: EngineInterface): Promise<string> {
153 const id = await read($, pane)
154 if (!id) return `No pane opened by /${COMMAND} in this session.`
155 if (!(await isInHerdr($))) return NEEDS_HERDR
156 try {
157 const r = await $.process.run(['herdr', 'pane', 'close', id], { timeoutMs: HERDR_TIMEOUT_MS })
158 if (r.exitCode === 0) {
159 await update($, pane, () => '')
160 return `Closed pane ${id}.`
161 }
162 return `herdr pane close ${id} failed${r.stderr ? `: ${firstLine(r.stderr)}` : ''}; close the pane in herdr by hand (its browser stops with it).`
163 } catch (err) {
164 return `herdr pane close ${id} failed: ${whyOf(err)}; close the pane in herdr by hand.`
165 }
166}
167
168async function trustedList($: EngineInterface): Promise<string[]> {
169 try {
170 return trustedOf(await $.store.get(STORE_TRUSTED))
171 } catch {
172 return []
173 }
174}
175
176/**
177 * Whether Claude may read pages from `origin`: yes when it is trusted, else
178 * the person is asked once (Allow once, Always, No). Always stores it; No, a
179 * typed answer, a dismissed dialog or no one to ask is a no.
180 */
181async function isAllowed($: EngineInterface, origin: string): Promise<boolean> {
182 if ((await trustedList($)).includes(origin)) return true
183 let answer: string
184 try {
185 answer = await $.ui.ask(askQuestion(origin), { options: [ALLOW_ONCE, ALWAYS, NO], header: ASK_HEADER })
186 } catch {
187 return false
188 }
189 if (answer === ALWAYS) {
190 const now = await trustedList($)
191 if (!now.includes(origin)) await $.store.set(STORE_TRUSTED, [...now, origin])
192 return true
193 }
194 return answer === ALLOW_ONCE
195}
196
197const sleep = ($: EngineInterface, ms: number) => $.clock.sleep(ms)
198
199/** After opening `href`: the browser and tab that show it, asking ls a few times while the page appears. */
200async function findOpened($: EngineInterface, opened: Opened, href: string): Promise<{ b: TbBrowser; tab: TbTab }> {
201 const page = pageOf(href)
202 for (let i = 0; i < POLL_TRIES; i++) {
203 const browsers = await listBrowsers($)
204 const b =
205 (opened.browserKey ? browsers.find(x => x.key === opened.browserKey) : undefined) ??
206 (opened.paneId ? browsers.find(x => x.pane === opened.paneId) : undefined)
207 const isLast = i === POLL_TRIES - 1
208 const tab = b && page ? tabShowing(b, page) : null
209 // A tab with no CDP target yet is still starting: ask again, unless this is the last try.
210 if (b && tab && (tab.hasTarget || isLast)) return { b, tab }
211 if (isLast && b) {
212 const t = activeTab(b)
213 if (t) return { b, tab: t }
214 }
215 await sleep($, POLL_MS)
216 }
217 throw new Refusal(`the browser has not shown ${href} yet; call ${TOOL} again without url in a moment`)
218}
219
220/** Runs the page script in one tab and answers the bounded capture; a loading page is read again a little later. */
221async function capture($: EngineInterface, b: TbBrowser, tab: TbTab): Promise<WebCapture> {
222 for (let attempt = 0; ; attempt++) {
223 const r = await $.process.run(
224 ['terminal-browser', 'action', '--browser', b.key, '--tab', tab.id, '--', 'eval', PAGE_SCRIPT, '--json'],
225 { timeoutMs: EVAL_TIMEOUT_MS },
226 )
227 const pageError = evalErrorOf(r.stdout)
228 if (r.exitCode !== 0 || pageError)
229 throw new Refusal(`terminal-browser could not read tab ${tab.id}: ${pageError ?? (firstLine(r.stderr) || `exit ${r.exitCode}`)}`)
230 const raw = unwrapEval(r.stdout)
231 const bounded = raw ? boundCapture(raw) : null
232 if (!bounded) throw new Refusal(`tab ${tab.id} did not answer with a readable http(s) page`)
233 if (!bounded.isLoading || attempt >= LOADING_RETRIES) return bounded.capture
234 await sleep($, LOADING_WAIT_MS)
235 }
236}
237
238/**
239 * The origin the browser itself shows in a tab, from a fresh ls after the read
240 * (the page script runs in the page, so its own origin is only a hint); null
241 * when the tab is gone or not an http(s) page.
242 */
243async function originShown($: EngineInterface, b: TbBrowser, tab: TbTab): Promise<string | null> {
244 const t = (await listBrowsers($)).find(x => x.key === b.key)?.tabs.find(x => x.id === tab.id)
245 return t ? originOf(t.url) : null
246}
247
248/** What the tool answers: the capture or the denial as JSON text; a failure as a refusal. */
249type ToolAnswer = { result: string } | { deny: string }
250
251async function webRead($: EngineInterface, options: PluginOptions, input: Record<string, unknown>): Promise<ToolAnswer> {
252 const asked = typeof input.url === 'string' && input.url.trim() !== '' ? input.url : null
253 try {
254 let where: { b: TbBrowser; tab: TbTab }
255 let origin: string
256 if (asked !== null) {
257 const url = checkUrl(asked)
258 if (!url.ok) return { deny: `${TOOL}: not opened: ${url.why}` }
259 origin = url.origin
260 if (!(await isAllowed($, origin))) return { result: JSON.stringify({ denied: origin }) }
261 const opened = await openUrl($, options, url.href, true)
262 where = await findOpened($, opened, url.href)
263 } else {
264 const b = pickBrowser(await listBrowsers($), await read($, pane))
265 const tab = b ? activeTab(b) : null
266 if (!b || !tab) return { deny: `${TOOL}: no page is open in the terminal browser; pass url, or /${COMMAND} <url> opens one` }
267 const o = originOf(tab.url)
268 if (!o) return { deny: `${TOOL}: the open tab is not an http(s) page (${firstLine(tab.url) || 'no address'})` }
269 origin = o
270 if (!(await isAllowed($, origin))) return { result: JSON.stringify({ denied: origin }) }
271 where = { b, tab }
272 }
273 const got = await capture($, where.b, where.tab)
274 // The browser's own address for the tab decides, not the page's word: a disagreement returns nothing.
275 const shown = await originShown($, where.b, where.tab)
276 if (!shown) return { deny: `${TOOL}: tab ${where.tab.id} no longer shows an http(s) page; nothing returned` }
277 if (shown !== got.origin) return { deny: `${TOOL}: the page said it is ${got.origin} but the browser shows ${shown}; nothing returned` }
278 // The page moved to another site between the check and the read: that site is asked for too.
279 if (shown !== origin && !(await isAllowed($, shown))) return { result: JSON.stringify({ denied: shown }) }
280 return { result: JSON.stringify(got) }
281 } catch (err) {
282 return { deny: `${TOOL}: ${whyOf(err)}` }
283 }
284}
285
286export const register: Register = (on, options) => {
287 on('session.start', async ($, e, next) => {
288 const started = await next(e)
289 await $.command.register({
290 name: 'web',
291 description: 'Open a link in terminal-browser in a herdr pane (the last link of the last reply by default), close it, or manage the sites web_read may read',
292 argumentHint: '[url] | close | trusted | forget <origin>',
293 })
294 try {
295 await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: true })
296 } catch {
297 // The command still works without the tool.
298 }
299 return started
300 })
301
302 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
303 on('command.run', { command: 'web' }, async ($, e) => {
304 const asked = parseArgs(e.args)
305 switch (asked.kind) {
306 case 'last': {
307 let link: string | null
308 try {
309 link = await lastReplyLink($)
310 } catch (err) {
311 return { text: `/${COMMAND}: ${whyOf(err)}` }
312 }
313 if (!link) return { text: `No http(s) link in Claude's last reply. ${USAGE}` }
314 return { text: await webOpen($, options, link) }
315 }
316 case 'open':
317 return { text: await webOpen($, options, asked.url) }
318 case 'close':
319 return { text: await webClose($) }
320 case 'trusted': {
321 const list = await trustedList($)
322 return {
323 text:
324 list.length === 0
325 ? `No trusted sites: ${TOOL} asks before reading each new one (Always adds it here).`
326 : `${TOOL} reads these without asking:\n${list.map(o => ` ${o}`).join('\n')}\n/${COMMAND} forget <origin> removes one.`,
327 }
328 }
329 case 'forget': {
330 const origin = originOf(asked.origin) ?? asked.origin
331 const list = await trustedList($)
332 if (!list.includes(origin)) return { text: `${origin} was not trusted (/${COMMAND} trusted lists them).` }
333 await $.store.set(STORE_TRUSTED, list.filter(o => o !== origin))
334 return { text: `Forgot ${origin}: ${TOOL} asks again before reading it.` }
335 }
336 default:
337 return { text: USAGE }
338 }
339 })
340
341 on('tool.call', { tool: 'mcp__web__web_read' }, async ($, e) => webRead($, options, e as unknown as Record<string, unknown>)).catch(
342 ($, e, next) => (next.called ? undefined : next(e)),
343 )
344}
345hooks/kit.ts 386 lines1// Shared by register.tsx and the tests: the words, the URL check, the link
2// finder, the parsers of herdr's and terminal-browser's output, the page
3// script web_read runs in the browser, and the bounding of what it returns.
4// Nothing here runs a command or touches the network.
5import type { WebCapture, WebLink } from '../types'
6
7export const COMMAND = 'web'
8export const TOOL = 'web_read'
9/** The full name the model calls: mcp__<plugin>__<name>. */
10export const TOOL_FULL = 'mcp__web__web_read'
11export const STORE_TRUSTED = 'web.trusted'
12export const USAGE = `Usage: /${COMMAND} [<url>] | close | trusted | forget <origin> (no argument: the last link of Claude's last reply)`
13export const NEEDS_HERDR = `/${COMMAND} needs a herdr pane (HERDR_ENV=1) to open the browser`
14
15export const TOOL_DESCRIPTION =
16 'Reads the page open in the terminal browser (or opens url first) and returns its title, origin+path, main text (cut to 20,000 characters) and first 50 links.'
17export const TOOL_SCHEMA = {
18 type: 'object',
19 properties: {
20 url: {
21 type: 'string',
22 description: 'An http(s) address to open in the terminal browser before reading. Leave it out to read the page already open.',
23 },
24 },
25 additionalProperties: false,
26}
27
28/** The trust question's three answers, as $.ui.ask offers them. */
29export const ALLOW_ONCE = 'Allow once'
30export const ALWAYS = 'Always'
31export const NO = 'No'
32export const ASK_HEADER = 'web_read'
33export const askQuestion = (origin: string): string => `Let Claude read pages from ${origin}?`
34
35export const MAX_TEXT = 20_000
36export const MAX_LINKS = 50
37export const MAX_TITLE = 300
38export const MAX_LINK_TEXT = 200
39/** What a capture never carries (ui-selector's §6.4 list, for a whole page). */
40export const OMITTED_ALWAYS = ['query strings', 'fragments', 'cookies', 'storage', 'form values', 'frames', 'images'] as const
41export const OMITTED_TEXT = 'text beyond 20,000 characters'
42export const OMITTED_LINKS = 'links beyond 50'
43
44/** Each herdr command's limit. */
45export const HERDR_TIMEOUT_MS = 5_000
46/** `terminal-browser ls --json` and `action -- open`. */
47export const LS_TIMEOUT_MS = 5_000
48export const OPEN_TIMEOUT_MS = 10_000
49/** The page script's run. */
50export const EVAL_TIMEOUT_MS = 15_000
51/** After opening a page: how often and how many times ls is asked for its tab. */
52export const POLL_MS = 250
53export const POLL_TRIES = 12
54/** A page still loading is read again this many times, this far apart. */
55export const LOADING_RETRIES = 2
56export const LOADING_WAIT_MS = 400
57
58const whyOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
59export { whyOf }
60
61/** The split direction option: `right`, or `down` for anything else (the default). */
62export const directionOf = (option: unknown): 'down' | 'right' => (option === 'right' ? 'right' : 'down')
63
64export type UrlCheck = { ok: true; href: string; origin: string } | { ok: false; why: string }
65
66/**
67 * An address /web may open: http or https only, no whitespace, and nothing
68 * that could leave the single quotes it travels in (`'`, `\`, control
69 * characters). Answers the parsed address (ASCII, as the URL parser writes it).
70 */
71export function checkUrl(raw: string): UrlCheck {
72 const s = raw.trim()
73 if (s === '') return { ok: false, why: 'no address given' }
74 if (/\s/.test(s)) return { ok: false, why: 'the address has whitespace in it' }
75 // eslint-disable-next-line no-control-regex
76 if (/[\u0000-\u001f\u007f'\\]/.test(s)) return { ok: false, why: "the address has a quote, a backslash or a control character in it" }
77 if (!/^https?:\/\//i.test(s)) return { ok: false, why: 'only http:// and https:// addresses open' }
78 let u: URL
79 try {
80 u = new URL(s)
81 } catch {
82 return { ok: false, why: 'not a valid address' }
83 }
84 if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, why: 'only http:// and https:// addresses open' }
85 if (u.username || u.password) return { ok: false, why: 'an address with a user name or password does not open here' }
86 const href = u.href
87 // eslint-disable-next-line no-control-regex
88 if (!/^[\x21-\x7e]+$/.test(href) || /['\\]/.test(href)) return { ok: false, why: 'the address has a character that cannot be quoted safely' }
89 return { ok: true, href, origin: u.origin }
90}
91
92/** The pane's command: the address in single quotes (checkUrl has ruled out a quote inside it). */
93export const openLine = (href: string): string => `terminal-browser open '${href}'`
94
95/** An http(s) origin from an address or an origin, else null. */
96export function originOf(text: string): string | null {
97 try {
98 const u = new URL(text.trim())
99 return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
100 } catch {
101 return null
102 }
103}
104
105/** `origin + pathname` of an http(s) address, else null. */
106export function pageOf(text: string): string | null {
107 try {
108 const u = new URL(text)
109 return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin + u.pathname : null
110 } catch {
111 return null
112 }
113}
114
115/** A link's tail with trailing sentence punctuation and unbalanced closing parentheses dropped (`_` stays: it can end a path). */
116function trimLinkTail(link: string): string {
117 let s = link
118 for (;;) {
119 const t = s.replace(/[.,;:!?*~]+$/, '')
120 if (!(t.endsWith(')') && t.split(')').length > t.split('(').length)) return t
121 s = t.slice(0, -1)
122 }
123}
124
125/**
126 * The last http(s) link in a text: bare or inside Markdown `[..](..)` or
127 * `<..>`, with balanced parentheses kept (`.../Mercury_(planet)`) and
128 * trailing sentence punctuation dropped. Null when there is none.
129 */
130export function lastLinkIn(text: string): string | null {
131 const found = text.match(/https?:\/\/[^\s<>"'`[\]{}]+/gi)
132 if (!found) return null
133 for (let i = found.length - 1; i >= 0; i--) {
134 const link = trimLinkTail(found[i]!)
135 if (checkUrl(link).ok) return link
136 }
137 return null
138}
139
140/** The new pane's id from `herdr pane split`'s JSON (`.result.pane.pane_id`), else null. */
141export function paneIdOf(stdout: string): string | null {
142 try {
143 const j = JSON.parse(stdout) as { result?: { pane?: { pane_id?: unknown } } }
144 const id = j.result?.pane?.pane_id
145 return typeof id === 'string' && id.length > 0 ? id : typeof id === 'number' ? String(id) : null
146 } catch {
147 return null
148 }
149}
150
151/** One tab of a terminal browser, as `terminal-browser ls --json` lists it. */
152export type TbTab = { id: string; url: string; title: string; isActive: boolean; hasTarget: boolean }
153/** One terminal browser: its key (what --browser takes), its herdr pane and its tabs. */
154export type TbBrowser = { key: string; pane: string | null; isInCurrentTab: boolean; tabs: TbTab[] }
155
156/**
157 * The browsers of `terminal-browser ls --json`
158 * (`{ self, browsers: [{ key, pane: { tab, pane }, inCurrentTab, tabs: [{ id, active, title, url, targetId }] }] }`);
159 * a tab whose `targetId` is still null cannot be read yet (`hasTarget` false);
160 * an entry without a key is skipped. Null when the output is not that JSON.
161 */
162export function parseLs(stdout: string): TbBrowser[] | null {
163 let j: unknown
164 try {
165 j = JSON.parse(stdout)
166 } catch {
167 return null
168 }
169 const list = (j as { browsers?: unknown } | null)?.browsers
170 if (!Array.isArray(list)) return null
171 const out: TbBrowser[] = []
172 for (const b of list) {
173 const r = (b ?? {}) as Record<string, unknown>
174 const key = typeof r.key === 'string' || typeof r.key === 'number' ? String(r.key) : ''
175 if (!key) continue
176 const p = ((r.pane ?? {}) as Record<string, unknown>).pane
177 const tabs: TbTab[] = []
178 for (const t of Array.isArray(r.tabs) ? r.tabs : []) {
179 const q = (t ?? {}) as Record<string, unknown>
180 if (typeof q.id !== 'string' && typeof q.id !== 'number') continue
181 tabs.push({
182 id: String(q.id),
183 url: typeof q.url === 'string' ? q.url : '',
184 title: typeof q.title === 'string' ? q.title : '',
185 isActive: q.active === true,
186 hasTarget: typeof q.targetId === 'string' && q.targetId !== '',
187 })
188 }
189 out.push({
190 key,
191 pane: typeof p === 'string' && p ? p : typeof p === 'number' ? String(p) : null,
192 isInCurrentTab: r.inCurrentTab !== false,
193 tabs,
194 })
195 }
196 return out
197}
198
199/** The browser to read: the one in the pane /web opened, else the first in this terminal tab, else the first. */
200export function pickBrowser(browsers: readonly TbBrowser[], pane: string): TbBrowser | null {
201 return (pane ? browsers.find(b => b.pane === pane) : undefined) ?? browsers.find(b => b.isInCurrentTab) ?? browsers[0] ?? null
202}
203
204/** A browser's active tab, else its first. */
205export const activeTab = (b: TbBrowser): TbTab | null => b.tabs.find(t => t.isActive) ?? b.tabs[0] ?? null
206
207/** The tab showing `page` (origin + pathname), the active one first; null when none does. */
208export function tabShowing(b: TbBrowser, page: string): TbTab | null {
209 const hits = b.tabs.filter(t => pageOf(t.url) === page)
210 return hits.find(t => t.isActive) ?? hits[hits.length - 1] ?? null
211}
212
213/**
214 * The script web_read runs in the page (`terminal-browser action ... -- eval`).
215 * It reads, never writes: it clones main (else article, else body), drops
216 * script, style, noscript, template, nav, footer, header, aside, iframe, svg,
217 * canvas, form controls, nodes hidden by attribute and nodes the live page
218 * hides with CSS (display none, visibility hidden; read with getComputedStyle
219 * on the live twin of each cloned element) from the clone, puts a line break
220 * after block elements, collapses whitespace and cuts the text to 20,000
221 * characters; links are the first 50 distinct http(s) anchors, each reduced to
222 * origin + pathname. It returns one JSON string. The mod bounds it again.
223 */
224export const PAGE_SCRIPT = [
225 '(() => {',
226 'const MAX = 20000, LINKS = 50;',
227 'const drop = "script,style,noscript,template,nav,footer,header,aside,iframe,frame,svg,canvas,object,embed,input,textarea,select,button,[hidden],[aria-hidden=true]";',
228 'const blocks = "p,div,li,ul,ol,dl,dd,dt,tr,table,br,hr,h1,h2,h3,h4,h5,h6,pre,blockquote,section,article,main,figure,figcaption,details,summary";',
229 'const root = document.querySelector("main") || document.querySelector("article") || document.body;',
230 'let text = "";',
231 'if (root) {',
232 'const copy = root.cloneNode(true);',
233 'const live = root.querySelectorAll("*"), twins = copy.querySelectorAll("*"), gone = [];',
234 'for (let i = 0; i < live.length && i < twins.length; i++) { const cs = getComputedStyle(live[i]); if (cs.display === "none" || cs.visibility === "hidden") gone.push(twins[i]); }',
235 'gone.forEach(n => n.remove());',
236 'copy.querySelectorAll(drop).forEach(n => n.remove());',
237 'copy.querySelectorAll(blocks).forEach(n => n.after("\\n"));',
238 'copy.querySelectorAll("td,th").forEach(n => n.after(" "));',
239 'text = (copy.textContent || "").replace(/[^\\S\\n]+/g, " ").replace(/ ?\\n ?/g, "\\n").replace(/\\n{3,}/g, "\\n\\n").trim();',
240 '}',
241 'const isCut = text.length > MAX;',
242 'if (isCut) text = text.slice(0, MAX);',
243 'const seen = new Set(); const links = []; let isMore = false;',
244 'for (const a of document.querySelectorAll("a[href]")) {',
245 'let u; try { u = new URL(a.getAttribute("href") || "", document.baseURI); } catch (err) { continue; }',
246 'if (u.protocol !== "http:" && u.protocol !== "https:") continue;',
247 'const href = u.origin + u.pathname;',
248 'if (seen.has(href)) continue;',
249 'if (links.length >= LINKS) { isMore = true; break; }',
250 'seen.add(href);',
251 'links.push({ text: (a.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 200), href });',
252 '}',
253 'const omitted = ["query strings", "fragments", "cookies", "storage", "form values", "frames", "images"];',
254 'if (isCut) omitted.push("text beyond 20,000 characters");',
255 'if (isMore) omitted.push("links beyond 50");',
256 'return JSON.stringify({ origin: location.origin, pathname: location.pathname, title: String(document.title || "").slice(0, 300), text, links, omitted, ready: document.readyState });',
257 '})()',
258].join(' ')
259
260/** The page script's reply, unwrapped: agent-browser's `--json` envelope, a quoted string, or the object itself. */
261export function unwrapEval(stdout: string): Record<string, unknown> | null {
262 let v: unknown = stdout.trim()
263 for (let depth = 0; depth < 4; depth++) {
264 if (typeof v === 'string') {
265 try {
266 v = JSON.parse(v)
267 } catch {
268 return null
269 }
270 continue
271 }
272 if (v && typeof v === 'object') {
273 const o = v as Record<string, unknown>
274 if ('origin' in o && 'text' in o) return o
275 const data = o.data as Record<string, unknown> | undefined
276 if (data && typeof data === 'object' && 'result' in data) {
277 v = data.result
278 continue
279 }
280 return null
281 }
282 return null
283 }
284 return null
285}
286
287/** The page's error from agent-browser's `--json` envelope (`{ success: false, error }`), else null. */
288export function evalErrorOf(stdout: string): string | null {
289 try {
290 const o = JSON.parse(stdout.trim()) as { success?: unknown; error?: unknown }
291 return o && o.success === false ? (typeof o.error === 'string' ? o.error : 'the page script failed') : null
292 } catch {
293 return null
294 }
295}
296
297// eslint-disable-next-line no-control-regex
298const clean = (s: string, isMultiline: boolean): string => s.replace(isMultiline ? /[\u0000-\u0008\u000b-\u001f\u007f]/g : /[\u0000-\u001f\u007f]/g, '')
299
300/**
301 * The capture as web_read returns it, bounded here whatever the page answered:
302 * origin + pathname re-derived (http/https only), title 300, text 20,000,
303 * links 50 distinct with hrefs reduced to origin + pathname, and `omitted`
304 * rebuilt from the fixed list plus what was cut (by the page or here). Null
305 * when the reply is not a page of http(s) origin. `isLoading` is true while
306 * the document was still loading.
307 */
308export function boundCapture(raw: Record<string, unknown>): { capture: WebCapture; isLoading: boolean } | null {
309 const origin = typeof raw.origin === 'string' ? raw.origin : ''
310 const pathname = typeof raw.pathname === 'string' ? raw.pathname : '/'
311 let u: URL
312 try {
313 u = new URL(pathname.startsWith('/') ? pathname : `/${pathname}`, origin)
314 } catch {
315 return null
316 }
317 if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.origin !== origin) return null
318 const pageOmitted = Array.isArray(raw.omitted) ? raw.omitted.filter((x): x is string => typeof x === 'string') : []
319 let text = clean(typeof raw.text === 'string' ? raw.text : '', true)
320 let isTextCut = pageOmitted.includes(OMITTED_TEXT)
321 if (text.length > MAX_TEXT) {
322 text = text.slice(0, MAX_TEXT)
323 isTextCut = true
324 }
325 const links: WebLink[] = []
326 const seen = new Set<string>()
327 let isLinksCut = pageOmitted.includes(OMITTED_LINKS)
328 for (const l of Array.isArray(raw.links) ? raw.links : []) {
329 const r = (l ?? {}) as Record<string, unknown>
330 const href = typeof r.href === 'string' ? pageOf(r.href) : null
331 if (!href || seen.has(href)) continue
332 if (links.length >= MAX_LINKS) {
333 isLinksCut = true
334 break
335 }
336 seen.add(href)
337 links.push({ text: clean(typeof r.text === 'string' ? r.text : '', false).slice(0, MAX_LINK_TEXT), href })
338 }
339 const omitted: string[] = [...OMITTED_ALWAYS]
340 if (isTextCut) omitted.push(OMITTED_TEXT)
341 if (isLinksCut) omitted.push(OMITTED_LINKS)
342 return {
343 capture: {
344 origin: u.origin,
345 pathname: u.pathname,
346 title: clean(typeof raw.title === 'string' ? raw.title : '', false).slice(0, MAX_TITLE),
347 text,
348 links,
349 omitted,
350 },
351 isLoading: raw.ready === 'loading',
352 }
353}
354
355/** The trusted origins as kept in the store: distinct http(s) origins only. */
356export function trustedOf(value: unknown): string[] {
357 if (!Array.isArray(value)) return []
358 const out: string[] = []
359 for (const v of value) {
360 const o = typeof v === 'string' ? originOf(v) : null
361 if (o && !out.includes(o)) out.push(o)
362 }
363 return out
364}
365
366/** What /web was asked. */
367export type WebArgs =
368 | { kind: 'last' }
369 | { kind: 'open'; url: string }
370 | { kind: 'close' }
371 | { kind: 'trusted' }
372 | { kind: 'forget'; origin: string }
373 | { kind: 'usage' }
374
375export function parseArgs(args: string): WebArgs {
376 const words = args.trim().split(/\s+/).filter(Boolean)
377 if (words.length === 0) return { kind: 'last' }
378 const verb = words[0]!.toLowerCase()
379 if (words.length === 1 && (verb === 'close' || verb === 'off')) return { kind: 'close' }
380 if (words.length === 1 && verb === 'trusted') return { kind: 'trusted' }
381 if (verb === 'forget') return words.length === 2 ? { kind: 'forget', origin: words[1]! } : { kind: 'usage' }
382 if (verb === 'help') return { kind: 'usage' }
383 // Anything else is an address; checkUrl says why it does not open (whitespace included).
384 return { kind: 'open', url: args.trim() }
385}
386types/index.d.ts 37 lines1// The web mod's state contract and the shape web_read returns.
2
3/** One link of a capture: its words and its address cut to origin + pathname. */
4export type WebLink = {
5 /** The anchor's text, whitespace collapsed, at most 200 characters. */
6 text: string
7 /** `origin + pathname` only: no query string, no fragment. */
8 href: string
9}
10
11/**
12 * What web_read returns for a page, after ui-selector's CaptureV1 data
13 * contract: page identity is origin + pathname, never the query or hash, and
14 * everything left out is named in `omitted`.
15 */
16export type WebCapture = {
17 origin: string
18 pathname: string
19 /** The document's title, at most 300 characters. */
20 title: string
21 /** The main readable text (main, else article, else body) with scripts, styles, nav, header, footer and aside removed, at most 20,000 characters. */
22 text: string
23 /** The first 50 distinct http(s) links of the page. */
24 links: WebLink[]
25 /** What the capture never carries, plus what was cut. */
26 omitted: string[]
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 web: {
32 /** The herdr pane /web opened in this session; '' when none. */
33 pane: string
34 }
35 }
36}
37