SLOPSHOPPER

web

/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…

newguardcommandtoolprocess
v0.1.0MITupdated 2026-10-20ZH-L1N/agent-window-mods/web
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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: No http(s) link in Claude's last reply. Usage: /web [<url>] | close | trusted | forget <origin> (no argument ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

web

/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

/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.

  • Address check: 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).

web_read (tool)

mcp__web__web_read, deferred (behind ToolSearch, so its schema does not ride in every request). Input { url?: string }.

  • Without 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).
  • With 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.
  • Reading: 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.
  • Returned (JSON text, the tool result only; nothing is written anywhere):
  { "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.

  • Trust: a site not in $.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.
  • Failures (no browser, a non-http tab, a page that did not appear, a script error) come back to Claude as an error result naming what to do.

The capture's shape follows Zehui's ui-selector CaptureV1 contract: page identity is origin + pathname, and everything not captured is named.

Options

OptionValuesDefault
splitdown, rightdown

Install

From the local marketplace folder (~/.claude/mods):

claude plugin install web@zehui-mods --scope user

Notes

  • No network from the mod: it runs 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.
  • One open at a time: a second /web or web_read while a pane is being split says so.
  • English only.
Source 3 files
hooks/register.tsx 345 lines
1// 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}
345
hooks/kit.ts 386 lines
1// 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}
386
types/index.d.ts 37 lines
1// 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