SLOPSHOPPER

reply-links

Turns PRs, issues, Google Drive files, ElevenLabs agents and other things Claude names in a reply into clickable links. Deterministic: no model calls.

new
v0.1.0MITupdated 2026-10-24orgonag/reply-links
A shopper browsing a rack in a slop shop
README

reply-links

A Claude Code mod that turns the things Claude names in a reply into clickable links: pull requests, issues, Google Drive files, ElevenLabs agents, and anything else a tool result gave a name and a URL for.

It is deterministic. It runs no model calls, so it adds no tokens or cost per reply, and it makes no network requests.

Install

At the prompt of a terminal session:

/plugin install reply-links --marketplace orgonag/reply-links

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install reply-links --marketplace orgonag/reply-links

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with orgonag/reply-links, then Add plugin.

Needs a Claude Code build with mods (2.1.287 or later).

What it links

Claude writesBecomes a link to
PR 306, PR #306, pull request 306The pull request in the session's GitHub repository
#298 (two digits or more)The issue or pull request with that number (GitHub redirects an issue link to a PR)
owner/repo#12That repository's issue or pull request
A name a tool returned with a URL, such as a Drive file's name and webViewLinkThat URL, on its first mention in the reply
A Google Drive item returned with an id and mimeType but no linkhttps://drive.google.com/open?id=<id>
agent_… and conv_… ElevenLabs ids, and an agent's name from a tool resultThe agent or conversation in the ElevenLabs dashboard
A Supabase project returned with its ref and regionThe project's dashboard

The repository comes from the session folder's origin remote. Outside a GitHub repository, the mod uses the last repository it saw in a GitHub URL in tool output (for example from gh pr view or gh pr create), and when a tool printed the exact URL for a number, that URL wins.

It never changes code blocks, inline code, links Claude already wrote, bare URLs, or HTML. #1 and other single-digit numbers stay text, and so does a number with no repository to point at.

How it works

The mod hooks one event, session.append, which Claude Code raises for every row it adds to a conversation. Of those rows it changes only one kind: the text of Claude's own replies in the main conversation, where it adds markdown links. It reads tool results without changing them, and it leaves every other row alone, including your prompts, tool calls, subagent replies and rows other mods or code append. It makes no network requests and runs no commands.

In detail:

  • Tool results (door: tool-result): it reads each result for GitHub pull request and issue URLs, and parses JSON for objects that carry a name (name, title, displayName, filename, ...) beside a web URL (webViewLink, html_url, url, link, ...) or an id it knows how to turn into one.
  • Replies (door: response): it rewrites the text blocks of each main-conversation reply with markdown links before the reply is saved.

Because the link becomes part of the reply's markdown, any renderer draws it, including the plain Claude Code renderer and mods that redraw replies such as prismantis. A reply streams in as plain text and gains its links once it finishes.

When the session starts (and after a reload) it rebuilds what it learned from the tool results already in the conversation.

Limits

  • A name links only after a tool in the session has returned it with a URL. Names shorter than 4 characters, or made only of digits, are skipped.
  • Only the first mention of a learned name is linked in each reply.
  • Subagent replies are left alone.
  • The links are stored in the transcript, so the model sees them in later turns too (a few extra tokens per link).

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.ts 44 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { emptyRegistry, harvest, linkify, repoFromRemote, resultText, type Registry } from './links'
3
4const seen: { reg: Registry } = { reg: emptyRegistry() }
5
6async function readRepo($: EngineInterface, reg: Registry) {
7  const repo = await $.session.repo()
8  reg.repo = repo?.remote ? repoFromRemote(repo.remote) : null
9}
10
11async function rebuild($: EngineInterface): Promise<Registry> {
12  const fresh = emptyRegistry()
13  const messages = await $.session.messages({ as: 'api' })
14  const tools = new Map<string, string>()
15  for (const message of messages) {
16    for (const block of message.content) {
17      if (block.type === 'tool_use') tools.set(String(block.id), String(block.name))
18      if (block.type === 'tool_result') harvest(resultText(block.content), tools.get(String(block.tool_use_id)) ?? '', fresh)
19    }
20  }
21  return fresh
22}
23
24export const register: Register = on => {
25  on('session.start', async ($, e, next) => {
26    const started = await next(e)
27    seen.reg = await rebuild($).catch(() => emptyRegistry())
28    return started
29  })
30
31  on('session.append', { door: 'tool-result' }, ($, e, next) => {
32    const tool = e.origin.kind === 'tool' ? e.origin.tool : ''
33    for (const block of e.message.content) if (block.type === 'tool_result') harvest(resultText(block.content), tool, seen.reg)
34    return next(e)
35  }).catch(($, e, next) => next(e))
36
37  on('session.append', { door: 'response' }, async ($, e, next) => {
38    if (e.agentId !== undefined || e.message.type !== 'assistant') return next(e)
39    await readRepo($, seen.reg).catch(() => undefined)
40    const content = e.message.content.map(block => (block.type === 'text' && typeof block.text === 'string' ? { ...block, text: linkify(block.text, seen.reg) } : block))
41    return next({ ...e, message: { ...e.message, content } })
42  }).catch(($, e, next) => next(e))
43}
44
hooks/links.ts 149 lines
1export type Registry = {
2  repo: string | null
3  seenRepo: string | null
4  names: Map<string, string>
5  numbers: Map<string, string>
6}
7
8export const emptyRegistry = (): Registry => ({ repo: null, seenRepo: null, names: new Map(), numbers: new Map() })
9
10const MAX_NAMES = 2000
11const MAX_NODES = 5000
12const NAME_KEYS = ['name', 'title', 'displayName', 'display_name', 'filename', 'fileName', 'file_name', 'label']
13const URL_KEYS = ['webViewLink', 'htmlUrl', 'html_url', 'url', 'link', 'webUrl', 'web_url', 'alternateLink', 'permalink', 'viewUrl', 'view_url', 'editUrl', 'edit_url', 'shareUrl', 'share_url', 'href']
14const GITHUB_REF = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/(?:pull|issues)\/(\d+)/g
15const GITHUB_REMOTE = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
16
17export const repoFromRemote = (remote: string): string | null => {
18  const m = GITHUB_REMOTE.exec(remote.trim())
19  return m ? `${m[1]}/${m[2]}` : null
20}
21
22const isWebUrl = (v: unknown): v is string => typeof v === 'string' && /^https:\/\/[^\s<>()]+$/.test(v)
23
24const goodName = (v: unknown): v is string =>
25  typeof v === 'string' && v.length >= 4 && v.length <= 120 && !/^[\d\s.,:-]+$/.test(v) && !/[\n\r`[\]]/.test(v)
26
27const templateUrl = (o: Record<string, unknown>, tool: string): string | null => {
28  const id = typeof o.id === 'string' ? o.id : null
29  if (typeof o.agent_id === 'string' && /^agent_\w{10,}$/.test(o.agent_id)) return `https://elevenlabs.io/app/agents/agents/${o.agent_id}`
30  if (id && typeof o.mimeType === 'string') return `https://drive.google.com/open?id=${encodeURIComponent(id)}`
31  if (id && /^[a-z]{20}$/.test(id) && typeof o.region === 'string' && ('organization_id' in o || /supabase/i.test(tool))) return `https://supabase.com/dashboard/project/${id}`
32  return null
33}
34
35const remember = (reg: Registry, name: string, url: string) => {
36  if (reg.names.size >= MAX_NAMES && !reg.names.has(name)) return
37  reg.names.set(name.trim(), url)
38}
39
40const walk = (node: unknown, tool: string, reg: Registry, budget: { left: number }, depth = 0) => {
41  if (depth > 8 || budget.left-- <= 0 || node === null || typeof node !== 'object') return
42  if (Array.isArray(node)) {
43    for (const item of node) walk(item, tool, reg, budget, depth + 1)
44    return
45  }
46  const o = node as Record<string, unknown>
47  const name = NAME_KEYS.map(k => o[k]).find(goodName)
48  const url = URL_KEYS.map(k => o[k]).find(isWebUrl) ?? templateUrl(o, tool)
49  if (name && url) remember(reg, name, url)
50  for (const v of Object.values(o)) if (v && typeof v === 'object') walk(v, tool, reg, budget, depth + 1)
51}
52
53const parseJson = (text: string): unknown => {
54  const start = text.search(/[[{]/)
55  if (start < 0) return undefined
56  try {
57    return JSON.parse(text.slice(start))
58  } catch {
59    return undefined
60  }
61}
62
63export const harvest = (text: string, tool: string, reg: Registry) => {
64  for (const m of text.matchAll(GITHUB_REF)) {
65    reg.seenRepo = m[1]!
66    reg.numbers.set(`${m[1]}#${m[2]}`, m[0])
67  }
68  const json = parseJson(text)
69  if (json !== undefined) walk(json, tool, reg, { left: MAX_NODES })
70}
71
72export const resultText = (content: unknown): string =>
73  typeof content === 'string'
74    ? content
75    : Array.isArray(content)
76      ? content.map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : '')).join('\n')
77      : ''
78
79const PROTECTED = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`|!?\[[^\]\n]*\]\([^)\n]*\)|<[^>\n]+>|https?:\/\/[^\s<>()]+/g
80
81type Hit = { start: number; end: number; label: string; url: string }
82
83const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84const escapeLabel = (s: string) => s.replace(/[[\]]/g, '\\$&')
85
86const githubUrl = (reg: Registry, repo: string | null, n: string, kind: 'pull' | 'issues'): string | null => {
87  const r = repo ?? reg.repo ?? reg.seenRepo
88  if (!r) return null
89  return reg.numbers.get(`${r}#${n}`) ?? `https://github.com/${r}/${kind}/${n}`
90}
91
92const RULES: ((text: string, reg: Registry) => Hit[])[] = [
93  (text, reg) =>
94    [...text.matchAll(/(?<![\w/.-])([\w.-]+\/[\w.-]+)#(\d+)\b/g)].map(m => ({ start: m.index, end: m.index + m[0].length, label: m[0], url: githubUrl(reg, m[1]!, m[2]!, 'issues')! })),
95  (text, reg) =>
96    [...text.matchAll(/\b(?:PRs?|[Pp]ull request)\s*#?(\d{1,6})\b/g)].flatMap(m => {
97      const url = githubUrl(reg, null, m[1]!, 'pull')
98      return url ? [{ start: m.index, end: m.index + m[0].length, label: m[0], url }] : []
99    }),
100  (text, reg) =>
101    [...text.matchAll(/(?<=^|[\s(])#(\d{2,6})\b(?![.,]\d)/gm)].flatMap(m => {
102      const url = githubUrl(reg, null, m[1]!, 'issues')
103      return url ? [{ start: m.index, end: m.index + m[0].length, label: m[0], url }] : []
104    }),
105  text =>
106    [...text.matchAll(/\b(agent_\w{10,}|conv_\w{10,})\b/g)].map(m => ({
107      start: m.index,
108      end: m.index + m[0].length,
109      label: m[0],
110      url: m[1]!.startsWith('agent_') ? `https://elevenlabs.io/app/agents/agents/${m[1]}` : `https://elevenlabs.io/app/agents/history/${m[1]}`,
111    })),
112]
113
114const nameHits = (text: string, reg: Registry, linked: Set<string>): Hit[] => {
115  const hits: Hit[] = []
116  const names = [...reg.names.keys()].sort((a, b) => b.length - a.length)
117  for (const name of names) {
118    if (linked.has(name)) continue
119    const m = new RegExp(`(?<![\\w/])${escapeRe(name)}(?!\\w)`).exec(text)
120    if (!m) continue
121    hits.push({ start: m.index, end: m.index + name.length, label: name, url: reg.names.get(name)! })
122    linked.add(name)
123  }
124  return hits
125}
126
127const linkPlain = (text: string, reg: Registry, linked: Set<string>): string => {
128  const all = [...RULES.flatMap(rule => rule(text, reg)), ...nameHits(text, reg, linked)].sort((a, b) => a.start - b.start || b.end - a.end)
129  let out = ''
130  let at = 0
131  for (const hit of all) {
132    if (hit.start < at) continue
133    out += text.slice(at, hit.start) + `[${escapeLabel(hit.label)}](${hit.url})`
134    at = hit.end
135  }
136  return out + text.slice(at)
137}
138
139export const linkify = (text: string, reg: Registry): string => {
140  const linked = new Set<string>()
141  let out = ''
142  let at = 0
143  for (const m of text.matchAll(PROTECTED)) {
144    out += linkPlain(text.slice(at, m.index), reg, linked) + m[0]
145    at = m.index + m[0].length
146  }
147  return out + linkPlain(text.slice(at), reg, linked)
148}
149