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

A Claude Code mod from Encore Mods. The URLs you need stay one click away.
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.(down)./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.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/session-linkshooks/register.js 119 lines1// 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