SLOPSHOPPER

Session links

Local dev servers and deploy URLs from the session, one click away above the prompt, dimmed when the server is down

newbandguardprocessnetworktimer
★ 1v0.1.0MITupdated 2026-10-21benjaminb10/encore-mods/plugins/session-links
A shopper browsing a rack in a slop shop
README

Session links

A Claude Code mod from Encore Mods. The URLs you need stay one click away.

  • Collects local dev servers (http://localhost:5173) and deploy URLs (Vercel, Netlify, Cloudflare Pages, Fly, Render, Railway, Heroku, or any URL on a line that reads like a deploy log) seen during the session.
  • Shows the last 4 above the prompt. Click to open in your browser.
  • Checks local servers every 20 seconds and dims the ones that stopped, marked (down).

Install

/plugin marketplace add benjaminb10/encore-mods
/plugin install session-links@encore-mods

Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.

Try it without installing

git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/session-links
Source 1 files
hooks/register.js 119 lines
1// session-links: the local dev servers and deploy URLs that show up during a session stay one click
2// away above the prompt. A local server that stops answering is dimmed and marked "(down)".
3
4const MAX = 4
5const URL_RE = /https?:\/\/[^\s"'<>)\]`*|\\]+/g
6const LOCAL = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?/
7const DEPLOY_LINE = /^\s*(?:[>✓✔✅🔗\-*•]\s*)*(?:production|preview|deployed(?: to)?|aliased|live at|deployment ready)\b[^:]*:?\s*https?:\/\//i
8const DEPLOY_HOST = /\.(vercel\.app|netlify\.app|pages\.dev|fly\.dev|onrender\.com|up\.railway\.app|herokuapp\.com)\b/
9
10let links = [] // { url, alive }
11
12// Local URLs anywhere; deploy URLs from tool output (deploy hosts, or lines that read like a deploy
13// log) and from Claude's replies only on lines that talk about a deploy.
14export function extractLinks(text, fromTool) {
15  const found = []
16  for (const line of String(text).split(/\\n|\n/)) {
17    for (let u of line.match(URL_RE) || []) {
18      u = u.replace(/[.,;:!?'"]+$/, '').replace('://0.0.0.0', '://localhost').replace('://127.0.0.1', '://localhost')
19      const local = LOCAL.test(u)
20      const deploy = DEPLOY_LINE.test(line) || (fromTool && DEPLOY_HOST.test(u))
21      if (!local && !deploy) continue
22      if (!local && /\/(api|_next|static|assets)\//.test(u)) continue
23      if (!found.includes(u)) found.push(u)
24    }
25  }
26  return found
27}
28
29// Only output of a command that starts a local server, or deploys, gives links. A URL that merely
30// shows up in a file, a log or a history Claude read does not count.
31const SERVER_CMD = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|preview|serve)\b|\bvite\b|\bnext\s+(dev|start)\b|\bexpo\s+start\b|\bastro\s+dev\b|\b(wrangler|vercel|netlify)\s+dev\b|\bhttp\.server\b|\bserve\s|\buvicorn\b|\bflask\s+run\b|\bhugo\s+server\b|\brails\s+s(erver)?\b|\bphp\s+-S\b/
32const DEPLOY_CMD = /\bvercel\s+(deploy|--prod|-p)\b|\bvercel\s*($|[;&|])|\bnetlify\s+deploy\b|\bwrangler\s+(pages\s+)?deploy\b|\bfly\s+deploy\b|\bfirebase\s+deploy\b|\brailway\s+up\b/m
33
34export function linksFromCommand(command, output, background) {
35  const cmd = String(command || '')
36  const isServer = background || SERVER_CMD.test(cmd), isDeploy = DEPLOY_CMD.test(cmd)
37  if (!isServer && !isDeploy) return []
38  return extractLinks(output, true).filter((u) => (LOCAL.test(u) ? isServer : isDeploy))
39}
40
41function addLinks(urls) {
42  for (const url of urls) {
43    links = links.filter((l) => l.url !== url)
44    links.unshift({ url, alive: true })
45  }
46  links = links.slice(0, MAX)
47}
48
49function short(url) {
50  const s = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
51  return s.length > 40 ? s.slice(0, 39) + '…' : s
52}
53
54async function openUrl($, url) {
55  for (const cmd of [['open', url], ['xdg-open', url], ['cmd', '/c', 'start', '', url]]) {
56    try {
57      const r = await $.process.run(cmd)
58      if (r.exitCode === 0) return
59    } catch {}
60  }
61}
62
63export function register(on) {
64  on('session.start', async ($, e, next) => {
65    $.clock.every(20000, async () => {
66      let changed = false
67      for (const l of links) {
68        if (!LOCAL.test(l.url)) continue
69        let alive = false
70        try {
71          const r = await $.http.fetch(l.url, { method: 'HEAD' })
72          alive = r.status > 0
73        } catch {}
74        if (alive) { l.fails = 0; if (!l.alive) { l.alive = true; changed = true } }
75        else if ((l.fails = (l.fails || 0) + 1) >= 2) { links = links.filter((x) => x !== l); changed = true }
76      }
77      if (changed) $.ui.invalidate('ui.render')
78    })
79    return next(e)
80  })
81
82  on('tool.call', async ($, e, next) => {
83    const result = await next(e)
84    if (e.tool === 'Bash') {
85      let text = ''
86      try { text = JSON.stringify(result).slice(0, 200000) } catch {}
87      const urls = linksFromCommand(e.command, text, e.run_in_background === true)
88      if (urls.length) { addLinks(urls); $.ui.invalidate('ui.render') }
89    }
90    return result
91  }).catch(() => undefined)
92
93  on('turn.complete', async ($, e, next) => {
94    if (!e.agentId && !e.isAborted) {
95      const urls = extractLinks(e.answer || '', false)
96      if (urls.length) { addLinks(urls); $.ui.invalidate('ui.render') }
97    }
98    return next(e)
99  })
100
101  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102    if (e.props.isWorking || !links.length) return next(e)
103    const { Box, Button } = $.ui.resolve(e)
104    const items = links.map((l, i) =>
105      Button({
106        key: 'session-link-' + i,
107        label: '↗ ' + short(l.url) + (l.alive ? '' : ' (down)'),
108        plain: true,
109        dimColor: !l.alive,
110        onPress: async () => {
111          await openUrl($, l.url)
112        },
113      }),
114    )
115    const theirs = await next(e)
116    return Box({ flexDirection: 'column', children: [Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: items }), theirs] })
117  })
118}
119