SLOPSHOPPER

link-capture

Records every link Claude shows (Markdown in replies, raw OSC 8 in tool output). Adds /links.

newcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · link-capture
› 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 › /links ⎿ link-capture: No web links captured yet. `/links all` lists the rest. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<h1 align="center">claude-code-mod-tracking</h1>

  __  __  ___  ___    _____ ___    _   ___ _  _____ _  _  ___
 |  \/  |/ _ \|   \  |_   _| _ \  /_\ / __| |/ /_ _| \| |/ __|
 | |\/| | (_) | |) |   | | |   / / _ \ (__| ' < | || .` | (_ |
 |_|  |_|\___/|___/    |_| |_|_\/_/ \_\___|_|\_\___|_|\_|\___|

   #42 ──▶  PROJ-7 ──▶  https://…   ──▶  [ tracking  GitHub 4 (1 ✗) ]

<strong>Claude Code mods that keep track of the work around your session.</strong><br> Issues, PRs, Jira items, links, worktrees and agents — in one live pane.

<a href="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/test.yml"><img alt="test" src="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/test.yml/badge.svg?branch=main"></a> <a href="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/lint.yml"><img alt="lint" src="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/lint.yml/badge.svg?branch=main"></a> <a href="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/release.yml"><img alt="release workflow" src="https://github.com/martin-macak/claude-code-mod-tracking/actions/workflows/release.yml/badge.svg"></a> <a href="https://github.com/martin-macak/claude-code-mod-tracking/releases/latest"><img alt="latest release" src="https://img.shields.io/github/v/release/martin-macak/claude-code-mod-tracking?display_name=release&sort=semver"></a> <a href="#requirements"><img alt="Claude Code 2.1.295 or newer" src="https://img.shields.io/badge/Claude%20Code-2.1.295%2B-D97757"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a> <a href="https://github.com/martin-macak/claude-code-mod-tracking/commits/main"><img alt="last commit" src="https://img.shields.io/github/last-commit/martin-macak/claude-code-mod-tracking"></a> <a href="https://github.com/martin-macak/claude-code-mod-tracking/issues"><img alt="open issues" src="https://img.shields.io/github/issues/martin-macak/claude-code-mod-tracking"></a> <a href="https://www.conventionalcommits.org/en/v1.0.0/"><img alt="Conventional Commits" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-fe5196"></a> <a href="#report-a-bug-or-ask-for-a-feature"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen"></a>

<a href="#features">Features</a> · <a href="#installation">Installation</a> · <a href="#usage">Usage</a> · <a href="#report-a-bug-or-ask-for-a-feature">Report a bug</a> · <a href="#development">Development</a> · <a href="plugins/artifact-tracking/README.md">Full docs</a>


This repo holds Claude Code mods (function-hooks plugins) and the research behind them.

The first mod is artifact-tracking. A session mentions GitHub issues and PRs, Jira work items, Claude artifacts and web links. The mod finds them, fetches their live state and shows them in a /track pane and in a band above the prompt. You stop losing the PR from an hour ago in the scrollback.

[!NOTE] Mods are in early access. The API changes between Claude Code releases. This repo needs Claude Code 2.1.295 or newer.

Demo

artifact-tracking in a Claude Code session: a prompt mentions GitHub items and a link, the band counts them, and /track shows their live state

You mention a PR, an issue and a docs link in a normal prompt. The mod finds them, and the band above the prompt counts them at once. A second prompt adds a PR with failing checks, and the band shows (1 ✗). /track opens a pane with the live title, state and checks of each item from gh, and a section for web links. Press r to refresh every item and Esc to close the pane.

The GIF comes from a Gherkin scenario, e2e/features/tracking.feature, that make demo records. The same scenario runs as a test under make e2e.

Features

  • Zero-effort capture. Finds GitHub issue and PR URLs, owner/repo#N, bare #N, Jira URLs and web links in your prompts and in Claude's replies, also from subagents. Tool output gives explicit GitHub and Jira URLs only.
  • Live state. Shows title, open, closed or merged, failing checks, Jira status and the Epic tree. Data comes from gh (one GraphQL call for each host) and acli.
  • Always in view. A one-line band above the prompt counts the items: tracking Jira 6 · GitHub 4 (1 ✗) · Links 2 /track.
  • Interactive pane. /track opens a pane with one section for each source. Press r to refresh, x to hide an auto row, Esc to close.
  • Auto tracking. Tracks the worktrees, agents and pull requests of the session with no mention, also stacked PRs and the refs in a PR body.
  • Survives restarts. Keeps the items across /clear, --resume and --continue.
  • Fails soft. Without gh or acli, the items stay in the list and the section says why. A circuit breaker pauses a failing tool for 5 minutes.
  • Private by design. The mod stores only the extracted fields, never raw CLI output.

Installation

Requirements

  • Claude Code 2.1.295 or newer, with mods.
  • Optional: gh 2.102 or newer, logged in. It gives the live state of GitHub items.
  • Optional: acli 1.3.19 or newer, logged in. It gives the live state of Jira items.

From the marketplace (recommended)

In a Claude Code session:

/plugin install artifact-tracking --marketplace martin-macak/claude-code-mod-tracking

Answer y to add the marketplace mod-tracking, then pick a scope. From a shell:

claude plugin install artifact-tracking --marketplace martin-macak/claude-code-mod-tracking

Get a new release:

claude plugin update artifact-tracking@mod-tracking

From a release zip

Each GitHub release attaches artifact-tracking-<version>.zip, a SHA256SUMS file and a build provenance attestation. Load a zip for one session:

claude --plugin-url https://github.com/martin-macak/claude-code-mod-tracking/releases/download/artifact-tracking--v<version>/artifact-tracking-<version>.zip

From source

git clone https://github.com/martin-macak/claude-code-mod-tracking.git
cd claude-code-mod-tracking
claude --plugin-dir plugins/artifact-tracking   # one session
make install                                    # every session, as a symlink

Usage

Work as usual. Mention issues, PRs, Jira keys and links in your prompts. The band fills by itself.

CommandAction
/trackOpens the pane and refreshes the items that are due.
/track refreshRefreshes every item and waits. Use it in claude -p.
/track clearRemoves all items of this session.

The mod README lists every captured pattern, the auto-tracking rules, persistence and the known limits.

Report a bug or ask for a feature

This project is young, and your feedback shapes it. A short report helps a lot. Did something break? Is a pattern missing? Do you want a new column or a new mod? Tell us.

Search the open issues first, and add a 👍 to an issue that you also need. Pull requests are welcome too. See Contributing.

Development

Requirements: claude 2.1.295 or newer, mise with node@24, and pre-commit. gh and acli are optional.

make init      # check the tools
make hooks     # install the git hooks (once per clone)
make test      # validate, type-check and test the mod
make install   # load the mod in every session
make e2e       # run the Gherkin scenarios against a real claude (local only)
make demo      # record docs/assets/demo.gif from the @demo scenario

make e2e and make demo also need tmux, asciinema, agg and gifsicle (brew install tmux asciinema agg gifsicle). They use the Claude login and trust store of the calling shell (CLAUDE_CONFIG_DIR, else ~/.claude), and they run real model turns. To refresh the GIF, follow the demo-scenario skill.

Run make help for every target.

Layout

PathContent
plugins/The mods. One folder per mod.
e2e/Gherkin scenarios that drive a real claude in tmux, the demo recorder and their tests.
docs/knowledge-base/Research notes, one folder per problem. Start with claude-code-mods/.
docs/superpowers/specs/Design specs.
AGENTS.mdRules for agents and contributors: lint, branches, commits, PRs, issues.
MakefileBuild, test, lint and install targets.

Lint

make lint runs all hooks in .pre-commit-config.yaml on all files.

HookChecks
pre-commit-hooksWhitespace, end of file, line endings, YAML, JSON, merge markers, symlinks, large files, private keys, no commit to main.
markdownlint-cli2Markdown. Config: .markdownlint-cli2.jsonc.
biome-checkFormat, lint and import order of plugins/ and e2e/. Config: biome.json.
shellcheckShell scripts.
actionlintGitHub Actions workflows.
typechecktsc on the mod and on e2e/ (make typecheck).
conventional-pre-commit, commit-subject-caseCommit messages. See AGENTS.md.

CI/CD

WorkflowRuns
testmake test and make e2e-unit on every PR and push to main.
lintThe pre-commit hooks, the PR title check and the branch name check.
releaseOn a tag <plugin>--v<version>: validates, tests, builds the zip and publishes the GitHub release.
claude, claude-code-reviewClaude answers @claude mentions and reviews pull requests.

Release steps: plugins/artifact-tracking/README.md.

Contributing

Read AGENTS.md first. In short: branch <type>/<summary>, Conventional Commits with a sentence-case subject, one PR per change with Closes #N, squash merge. Run make verify before you push.

License

MIT © Martin Macak

Source 3 files
hooks/register.ts 169 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Captured } from '../types'
4import { mergeLinks, scanMarkdown, scanOsc8, textOfContent } from './links'
5import type { Found, Source } from './links'
6
7// Working set: $.state (survives hot reload, versioned, one writer at a time through `update`).
8// Mirror: $.store, one key per session, so /clear and a new process can restore it.
9const links = atom({ plugin: 'link-capture', key: 'links' } as const, [] as Captured[])
10const dropped = atom({ plugin: 'link-capture', key: 'dropped' } as const, 0)
11
12const CAP = 500 // links kept in $.state
13const KEEP_SESSIONS = 20 // sessions kept in $.store (the whole store may hold 4 MiB)
14const FLUSH_MS = 1500
15
16let flushing = false // module variables: lost on reload, which only costs one early flush or one skipped check
17let knownId: string | undefined // the session id last seen; a change means /clear, /resume or /branch
18
19// ------------------------------------------------------------------ record
20
21/** Merge `found` into the session list. Never throws into the caller. */
22async function record($: EngineInterface, found: Found[], src: Source): Promise<void> {
23  if (found.length === 0) return
24  let lost = 0
25  await update($, links, (list) => {
26    const r = mergeLinks(list ?? [], found, src, CAP)
27    lost = r.dropped
28    return r.list
29  })
30  if (lost > 0) await update($, dropped, (n) => (n ?? 0) + lost)
31  schedule($)
32}
33
34/** Copy the list to $.store later. Many rows in one burst make one write. */
35function schedule($: EngineInterface): void {
36  if (flushing) return
37  flushing = true
38  $.clock.after(FLUSH_MS, async () => {
39    flushing = false
40    try {
41      await flush($)
42    } catch {
43      // A failed copy is retried by the next capture.
44    }
45  })
46}
47
48async function flush($: EngineInterface): Promise<void> {
49  const list = await read($, links)
50  if (list.length === 0) return // after /clear: never replace a stored copy with an empty one
51  const id = await $.session.id()
52  await $.store.set('s:' + id, { links: list })
53  const index = ((await $.store.get('index')) as string[] | undefined) ?? []
54  const next = [id, ...index.filter((x) => x !== id)]
55  for (const old of next.slice(KEEP_SESSIONS)) await $.store.delete('s:' + old)
56  await $.store.set('index', next.slice(0, KEEP_SESSIONS))
57}
58
59// ------------------------------------------------------------------ restore
60
61/**
62 * Fill the list again from what the session knows. `--resume` loads rows without appending them
63 * (reference.md:137), and /clear, /resume and /branch reset $.state (gotcha 11). The transcript is
64 * the source when it holds rows. The stored copy covers /clear, which empties the transcript.
65 */
66async function restore($: EngineInterface, source: string): Promise<void> {
67  if ((await read($, links)).length > 0) return // a hot reload keeps $.state; nothing to restore
68  const src: Source = { door: 'transcript', uuid: '' }
69  const messages = await $.session.messages()
70  if (Array.isArray(messages)) {
71    const all: Found[] = []
72    for (const m of messages) {
73      if (m.role === 'assistant' && m.text !== '') all.push(...scanMarkdown(m.text))
74      for (const use of m.toolUses) if (use.text) all.push(...scanOsc8(use.text))
75    }
76    await record($, all, src) // one state write for the whole transcript
77  }
78  if ((await read($, links)).length > 0) return
79  const id = await $.session.id()
80  const index = ((await $.store.get('index')) as string[] | undefined) ?? []
81  // /resume names a session that has a stored copy; /clear gives an id the store has not seen, so take the last other one
82  const from = index.includes(id) ? id : source === 'startup' ? undefined : index.find((x) => x !== id)
83  const stored = from === undefined ? undefined : ((await $.store.get('s:' + from)) as { links?: Captured[] } | undefined)
84  if (stored?.links && stored.links.length > 0) await update($, links, () => stored.links!.slice(-CAP))
85}
86
87/**
88 * /clear, /resume and /branch give the session a new id and reset $.state, and `session.start`
89 * does not fire again. The documented `classic.SessionStart` hook did not run for a user-tier mod
90 * in the live check (the built-in sec-default bypassed it), so compare ids on every use instead.
91 */
92async function ensure($: EngineInterface): Promise<void> {
93  const id = await $.session.id()
94  if (knownId === id) return
95  const hadId = knownId !== undefined
96  knownId = id
97  if (hadId) await restore($, 'clear')
98}
99
100// ------------------------------------------------------------------ /links
101
102const SHOWN = 30
103
104function render(list: readonly Captured[], args: string, lost: number): string {
105  const all = /\ball\b/.test(args)
106  const limit = Number(/\b(\d+)\b/.exec(args)?.[1] ?? SHOWN)
107  const rows = list.filter((c) => all || (c.kind === 'link' && c.web))
108  if (rows.length === 0) return all ? 'No links captured yet.' : 'No web links captured yet. `/links all` lists the rest.'
109  const shown = rows.slice(-limit)
110  const lines = shown.map((c, k) => {
111    const tag = [c.via, c.kind === 'link' ? '' : 'plain text', c.agentId ? 'subagent' : '', c.n > 1 ? `x${c.n}` : ''].filter(Boolean).join(', ')
112    return `${rows.length - shown.length + k + 1}. ${c.label && c.label !== c.url ? c.label + ' - ' : ''}${c.url} (${tag})`
113  })
114  const head = `${rows.length} link${rows.length === 1 ? '' : 's'}${shown.length < rows.length ? `, last ${shown.length}` : ''}${lost > 0 ? `, ${lost} older dropped` : ''}:`
115  return head + '\n' + lines.join('\n')
116}
117
118// ------------------------------------------------------------------ hooks
119
120export const register: Register = (on) => {
121  // Assistant text. One row per content block, complete, in order.
122  // Observe only: `next` stores the row first, then we scan what was stored.
123  on('session.append', { door: 'response' }, async ($, e, next) => {
124    const stored = await next(e)
125    try {
126      await ensure($)
127      if (stored.message) {
128        const found = scanMarkdown(textOfContent(stored.message.content))
129        await record($, found, { door: e.door, uuid: e.uuid, agentId: e.agentId })
130      }
131    } catch {
132      // A scanner or state fault must not touch the row.
133    }
134    return stored
135  }).catch(($, e, next) => next(e))
136
137  // Tool output. Raw OSC 8 bytes survive here.
138  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
139    const stored = await next(e)
140    try {
141      await ensure($)
142      if (stored.message) {
143        const found = scanOsc8(textOfContent(stored.message.content))
144        await record($, found, { door: e.door, uuid: e.uuid, agentId: e.agentId })
145      }
146    } catch {
147      // See above.
148    }
149    return stored
150  }).catch(($, e, next) => next(e))
151
152  // Once per process: register the command and load what a resume brought in.
153  on('session.start', async ($, e, next) => {
154    await $.command.register({ name: 'links', description: 'List the links Claude showed this session', argumentHint: '[all] [count]' })
155    try {
156      knownId = await $.session.id()
157      await restore($, 'startup')
158    } catch {
159      // The list stays empty. New rows still land.
160    }
161    return next(e)
162  })
163
164  on('command.run', { command: 'links' }, async ($, e, next) => {
165    await ensure($)
166    return { text: render(await read($, links), e.args, await read($, dropped)) }
167  }).catch(() => ({ text: 'link-capture: could not read the list.' }))
168}
169
hooks/links.ts 535 lines
1// Pure link scanner. No `$`, no I/O. Two entry points:
2//   scanMarkdown(text)  what the engine's Markdown renderer turns into a link
3//   scanOsc8(text)      raw OSC 8 hyperlinks in tool output
4// Rules were pinned against Claude Code 2.1.295 (see the KB page, section 3).
5
6import type { Captured, Via } from '../types'
7
8export type { Captured, Via }
9
10export type Found = {
11  url: string
12  label?: string
13  via: Via
14  /** `link`: the renderer or the tool makes a clickable span. `non-clickable`: plain text. */
15  kind: 'link' | 'non-clickable'
16}
17
18export type ScanOptions = {
19  /** Host that `owner/repo#12` points to. Default `github.com`. */
20  issueHost?: string
21}
22
23// ---------------------------------------------------------------- helpers
24
25/** One scan reads at most this many characters. A longer text loses its tail. */
26export const MAX_TEXT = 200_000
27
28const WEB_SCHEMES = ['http', 'https', 'file']
29
30/** The scheme of a URL, lower case, or '' when it has none. */
31export function schemeOf(url: string): string {
32  const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url)
33  return m ? m[1]!.toLowerCase() : ''
34}
35
36export function isWebUrl(url: string): boolean {
37  return WEB_SCHEMES.includes(schemeOf(url))
38}
39
40/** Dedupe key. Absolute http(s)/file URLs go through `URL`; anything else stays as written. */
41export function normalizeUrl(url: string): string {
42  const t = url.trim()
43  if (!isWebUrl(t)) return t
44  try {
45    return new URL(t).href
46  } catch {
47    return t
48  }
49}
50
51/**
52 * The target the renderer hands to the terminal. A destination that starts with a single `/`
53 * becomes a `file://` URL with `.` and `..` resolved. `//host/x` is dropped (null). Every other
54 * destination is passed on unchanged, whatever its scheme.
55 */
56export function emittedUrl(href: string): string | null {
57  if (href.startsWith('//')) return null
58  if (!href.startsWith('/')) return href
59  const cut = href.search(/[?#]/)
60  const path = cut === -1 ? href : href.slice(0, cut)
61  const tail = cut === -1 ? '' : href.slice(cut)
62  const out: string[] = []
63  for (const part of path.split('/')) {
64    if (part === '..') out.pop()
65    else if (part !== '.' && part !== '') out.push(part)
66  }
67  return 'file:///' + out.join('/') + tail
68}
69
70function isPunct(c: string | undefined): boolean {
71  return c !== undefined && /[!-/:-@[-`{-~]/.test(c)
72}
73
74// ---------------------------------------------------------------- block pass
75
76/**
77 * Cut the blocks the renderer does not read for links: fenced code, indented code, HTML blocks.
78 * Returns the remaining text, with the cut lines emptied.
79 */
80const HTML_BLOCK_TAGS = 'address|article|aside|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul'
81const HTML_BLOCK_OPEN = new RegExp('^ {0,3}(?:</?(?:' + HTML_BLOCK_TAGS + ')(?:[\\s/>]|$)|<(?:script|pre|style|textarea)(?:[\\s>]|$)|<!--|<\\?|<![A-Z]|<!\\[CDATA\\[)', 'i')
82const HTML_BLOCK_END: Array<[RegExp, RegExp]> = [
83  [/^ {0,3}<(?:script|pre|style|textarea)(?:[\s>]|$)/i, /<\/(?:script|pre|style|textarea)>/i],
84  [/^ {0,3}<!--/, /-->/],
85  [/^ {0,3}<\?/, /\?>/],
86  [/^ {0,3}<!\[CDATA\[/, /\]\]>/],
87  [/^ {0,3}<![A-Z]/, />/],
88]
89
90function stripCodeBlocks(text: string): string {
91  const lines = text.replace(/\r\n?/g, '\n').split('\n')
92  const out: string[] = []
93  let fence: RegExp | null = null
94  let html: RegExp | 'blank' | null = null
95  let prevBlank = true
96  let listIndent = -1 // content column of the last list marker; -1 outside a list
97  let inIndented = false
98  for (const line of lines) {
99    if (fence) {
100      if (fence.test(line)) fence = null
101      out.push('')
102      continue
103    }
104    if (html) {
105      if (html === 'blank') {
106        const ends = line.trim() === ''
107        out.push(ends ? line : '')
108        if (ends) html = null
109        prevBlank = ends
110      } else {
111        out.push('')
112        if (html.test(line)) html = null
113        prevBlank = false
114      }
115      continue
116    }
117    const blank = line.trim() === ''
118    if (blank) {
119      out.push(line)
120      prevBlank = true
121      continue
122    }
123    const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
124    if (open && !(open[1]![0] === '`' && open[2]!.includes('`'))) {
125      fence = new RegExp('^ {0,3}' + (open[1]![0] === '`' ? '`' : '~') + '{' + open[1]!.length + ',}[ \\t]*$')
126      out.push('')
127      prevBlank = false
128      inIndented = false
129      continue
130    }
131    const indent = /^ */.exec(line.replace(/\t/g, '    '))![0].length
132    const codeAt = listIndent < 0 ? 4 : listIndent + 4
133    if (indent >= codeAt && (prevBlank || inIndented)) {
134      out.push('')
135      inIndented = true
136      prevBlank = false
137      continue
138    }
139    inIndented = false
140    if (indent < 4 && HTML_BLOCK_OPEN.test(line)) {
141      const end = HTML_BLOCK_END.find(([start]) => start.test(line))
142      html = end ? end[1] : 'blank'
143      if (end && end[1].test(line.replace(end[0], ''))) html = null // opened and closed on one line
144      out.push('')
145      prevBlank = false
146      continue
147    }
148    const marker = /^( {0,3})([-*+]|\d{1,9}[.)])([ \t]+)/.exec(line)
149    if (marker) listIndent = marker[1]!.length + marker[2]!.length + Math.min(marker[3]!.length, 4)
150    else if (indent === 0 && !/^ {0,3}>/.test(line)) listIndent = -1
151    prevBlank = false
152    out.push(line)
153  }
154  return out.join('\n')
155}
156
157// ---------------------------------------------------------------- inline pass
158
159// Sticky regexes: matched at an index, never on a slice. This keeps a scan linear in the text length.
160const sticky = (re: RegExp) => new RegExp(re.source, 'y')
161function at(re: RegExp, s: string, i: number): RegExpExecArray | null {
162  re.lastIndex = i
163  return re.exec(s)
164}
165
166const AUTOLINK = sticky(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*)>/)
167const EMAIL_AUTOLINK = sticky(/<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/)
168// Inline HTML: a tag with quoted attributes, a closing tag, or a comment. The renderer prints it as text.
169const HTML_TAG = sticky(/(?:<!--[\s\S]*?-->|<\/[a-zA-Z][\w:-]*\s*>|<[a-zA-Z][\w-]*(?:\s+[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>)/)
170// Bare URL start, as the GFM literal rule has it: lower-case scheme or `www.`.
171const BARE = sticky(/(?:(?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9-]+\.?)+[^\s<]*/)
172// Trailing-punctuation trim. Applied until the result stops changing.
173const BACKPEDAL = /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/
174// `owner/repo#12`. The renderer makes it a link to the host's issue page.
175const ISSUE_REF = /(^|[^\w./-])([A-Za-z0-9][\w-]*\/[A-Za-z0-9][\w.-]*)#(\d+)\b/g
176const NO_ISSUE_HOSTS = ['bitbucket.org', 'codeberg.org', 'gitea.com', 'git.sr.ht', 'dev.azure.com']
177const ANGLE_DEST = sticky(/<((?:\\.|[^\n<>\\])+)>\s*\)/)
178const CONTROL = /[\x00-\x1f\x7f]/
179const MAX_DEST = 2048 // a longer destination is not read as a link (keeps the scan linear)
180const PLAIN_DEST = sticky(/[^\s\x00-\x1f]{0,2048}/)
181const TITLED_END = sticky(/\s+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))\s*\)/)
182const PLAIN_END = sticky(/\s*\)/)
183const MAX_BARE = 4096 // a longer candidate is taken whole, without the trim (keeps the trim linear)
184
185function trimBare(raw: string): string {
186  if (raw.length > MAX_BARE) return raw
187  let s = raw
188  for (;;) {
189    const next = BACKPEDAL.exec(s)?.[0] ?? ''
190    if (next === s) return s
191    s = next
192  }
193}
194
195/** Index of the first unmatched `)`; -1 when none; -2 when a `(` stays open and a `)` exists. */
196function findClosingParen(s: string): number {
197  if (!s.includes(')')) return -1
198  let level = 0
199  for (let i = 0; i < s.length; i++) {
200    const c = s[i]
201    if (c === '\\') i++
202    else if (c === '(') level++
203    else if (c === ')' && --level < 0) return i
204  }
205  return level > 0 ? -2 : -1
206}
207
208/** Code spans and bracket pairs of a text, found in two linear passes. */
209type Marks = {
210  tickLen: Map<number, number> // start of a backtick run -> its length
211  tickClose: Map<number, number> // start of a run -> start of the next run of the same length
212  pair: Map<number, number> // `[` index -> its `]` index
213}
214
215function markUp(s: string): Marks {
216  const tickLen = new Map<number, number>()
217  const tickClose = new Map<number, number>()
218  const open = new Map<number, number>() // run length -> start of the last run without a closer yet, scanning backwards
219  const runs: Array<[number, number]> = []
220  for (let i = 0; i < s.length; i++) {
221    const c = s[i]
222    if (c === '\\') i++
223    else if (c === '`') {
224      let j = i
225      while (s[j] === '`') j++
226      runs.push([i, j - i])
227      tickLen.set(i, j - i)
228      i = j - 1
229    }
230  }
231  for (let k = runs.length - 1; k >= 0; k--) {
232    const [start, len] = runs[k]!
233    const next = open.get(len)
234    if (next !== undefined) tickClose.set(start, next)
235    open.set(len, start)
236  }
237  const pair = new Map<number, number>()
238  const stack: number[] = []
239  for (let i = 0; i < s.length; i++) {
240    const c = s[i]
241    if (c === '\\') i++
242    else if (c === '`') {
243      const len = tickLen.get(i)
244      if (len === undefined) continue
245      const close = tickClose.get(i)
246      i = close === undefined ? i + len - 1 : close + len - 1
247    } else if (c === '[') stack.push(i)
248    else if (c === ']') {
249      const o = stack.pop()
250      if (o !== undefined) pair.set(o, i)
251    }
252  }
253  return { tickLen, tickClose, pair }
254}
255
256type LinkParse = { end: number; label: string; href: string }
257
258/** Parse `[label](dest "title")` at `from` (the `[`). Follows the renderer's lexer. */
259function parseInlineLink(s: string, from: number, marks: Marks): LinkParse | null {
260  const close = marks.pair.get(from)
261  if (close === undefined || s[close + 1] !== '(') return null
262  let i = close + 2
263  while (i < s.length && /\s/.test(s[i]!)) i++
264  const label = s.slice(from + 1, close)
265  let href: string
266  let end: number
267  if (s[i] === '<') {
268    const m = at(ANGLE_DEST, s, i)
269    if (!m) return null
270    href = m[1]!
271    end = i + m[0].length
272  } else {
273    const run = at(PLAIN_DEST, s, i)![0]
274    if (run.length >= MAX_DEST) return null
275    const titled = at(TITLED_END, s, i + run.length)
276    const plain = titled ? null : at(PLAIN_END, s, i + run.length)
277    if (titled) {
278      href = run
279      end = i + run.length + titled[0].length
280    } else if (plain) {
281      href = run
282      end = i + run.length + plain[0].length
283    } else if (run.includes(')')) {
284      // The destination ends at the last `)` of the run: `[a](url).` and `([a](url))` are links.
285      const k = run.lastIndexOf(')')
286      href = run.slice(0, k)
287      end = i + k + 1
288    } else return null
289    const stop = findClosingParen(href)
290    if (stop === -2) return null
291    if (stop > -1) {
292      end -= href.length - stop + 1
293      href = href.slice(0, stop)
294    }
295  }
296  href = href.replace(/\\([!-/:-@[-`{-~])/g, '$1')
297  return { end, label, href }
298}
299
300/** Reference definitions the text holds: `[id]: url`. The renderer ignores them. */
301function collectDefinitions(text: string): Map<string, string> {
302  const defs = new Map<string, string>()
303  for (const m of text.matchAll(/^ {0,3}\[([^\]\n]+)\]:[ \t]*<?([^\s>]+)>?/gm)) defs.set(m[1]!.toLowerCase().replace(/\s+/g, ' '), m[2]!)
304  return defs
305}
306
307function scanInline(src: string, opts: ScanOptions): Found[] {
308  const found: Found[] = []
309  const starts: number[] = [] // start offset of each entry in `found`
310  const add = (pos: number, f: Found) => {
311    starts.push(pos)
312    // A destination with a control character (a raw ESC or BEL in a reply) is drawn as plain text.
313    found.push(f.kind === 'link' && CONTROL.test(f.url) ? { ...f, kind: 'non-clickable' } : f)
314  }
315  const defs = collectDefinitions(src)
316  const marks = markUp(src)
317  const mask = src.split('') // characters that plain-text rules (issue refs) may still read
318  const blank = (from: number, to: number) => {
319    for (let k = from; k < to; k++) mask[k] = ' '
320  }
321  let i = 0
322  while (i < src.length) {
323    const c = src[i]!
324    if (c === '\\') {
325      i += 2
326      continue
327    }
328    if (c === '`') {
329      const len = marks.tickLen.get(i) ?? 1
330      const close = marks.tickClose.get(i)
331      if (close === undefined) {
332        i += len
333        continue
334      }
335      blank(i, close + len)
336      i = close + len
337      continue
338    }
339    if (c === '<') {
340      const auto = at(AUTOLINK, src, i)
341      if (auto) {
342        const url = auto[1]!
343        const isMail = /^mailto:/i.test(url)
344        add(i, isMail ? { url, via: 'mailto', kind: 'non-clickable' } : { url, via: 'autolink', kind: 'link' })
345        blank(i, i + auto[0].length)
346        i += auto[0].length
347        continue
348      }
349      const mail = at(EMAIL_AUTOLINK, src, i)
350      if (mail) {
351        add(i, { url: 'mailto:' + mail[1]!, via: 'mailto', kind: 'non-clickable' })
352        blank(i, i + mail[0].length)
353        i += mail[0].length
354        continue
355      }
356      const tag = at(HTML_TAG, src, i)
357      if (tag) {
358        blank(i, i + tag[0].length)
359        i += tag[0].length
360        continue
361      }
362      i++
363      continue
364    }
365    if (c === '!' && src[i + 1] === '[') {
366      const p = parseInlineLink(src, i + 1, marks)
367      if (p) {
368        add(i, { url: p.href, label: p.label || undefined, via: 'image', kind: 'non-clickable' })
369        blank(i, p.end)
370        i = p.end
371        continue
372      }
373      i++
374      continue
375    }
376    if (c === '[') {
377      const p = parseInlineLink(src, i, marks)
378      if (p) {
379        const label = p.label.trim() === '' ? undefined : p.label
380        if (/^mailto:/i.test(p.href)) add(i, { url: p.href, label, via: 'mailto', kind: 'non-clickable' })
381        else {
382          const url = emittedUrl(p.href)
383          if (url === null) add(i, { url: p.href, label, via: 'inline', kind: 'non-clickable' })
384          else add(i, { url, label: label === url ? undefined : label, via: 'inline', kind: 'link' })
385        }
386        blank(i, p.end)
387        i = p.end
388        continue
389      }
390      // `[text][id]`, `[text][]`, `[id]`: the renderer drops definitions, so these stay text.
391      const close = marks.pair.get(i)
392      if (close !== undefined && defs.size > 0 && src[close + 1] !== ':') {
393        const label = src.slice(i + 1, close)
394        const second = src[close + 1] === '[' ? marks.pair.get(close + 1) : undefined
395        const id = (second === undefined ? label : second === close + 2 ? label : src.slice(close + 2, second)).toLowerCase().replace(/\s+/g, ' ')
396        const target = defs.get(id)
397        if (target !== undefined) {
398          const end = (second ?? close) + 1
399          add(i, { url: target, label, via: 'reference', kind: 'non-clickable' })
400          blank(i, end)
401          i = end
402          continue
403        }
404      }
405      i++
406      continue
407    }
408    if (c === 'h' || c === 'f' || c === 'w') {
409      const m = at(BARE, src, i)
410      if (m) {
411        const raw = trimBare(m[0])
412        if (raw !== '') {
413          const isWww = raw.startsWith('www.')
414          add(i, { url: isWww ? 'http://' + raw : raw, label: isWww ? raw : undefined, via: 'bare', kind: 'link' })
415          blank(i, i + raw.length)
416          i += raw.length
417          continue
418        }
419      }
420    }
421    i++
422  }
423  // Plain text only: `owner/repo#12`.
424  const host = opts.issueHost ?? 'github.com'
425  if (!NO_ISSUE_HOSTS.includes(host)) {
426    const plain = mask.join('')
427    const sep = host === 'gitlab.com' ? '/-/issues/' : '/issues/'
428    for (const m of plain.matchAll(ISSUE_REF)) {
429      add(m.index! + m[1]!.length, { url: `https://${host}/${m[2]}${sep}${m[3]}`, label: `${m[2]}#${m[3]}`, via: 'issue-ref', kind: 'link' })
430    }
431  }
432  return found.map((f, k) => ({ f, k })).sort((x, y) => starts[x.k]! - starts[y.k]!).map((x) => x.f)
433}
434
435/** Links the engine's renderer makes out of Markdown text, in order of appearance. */
436export function scanMarkdown(text: string, opts: ScanOptions = {}): Found[] {
437  if (text === '') return []
438  return scanInline(stripCodeBlocks(text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text), opts)
439}
440
441// ---------------------------------------------------------------- OSC 8
442
443// OSC 8 ; params ; URI ST. ST is BEL or ESC \.
444const OSC8 = /\x1b\]8;([^;\x07\x1b]*);([^\x07\x1b]*)(?:\x07|\x1b\\)/g
445// Everything else that draws nothing: CSI, other OSC/DCS/APC/PM/SOS strings, charset selects.
446const OTHER_ESC = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b[\]P_X^][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-Za-z]/g
447
448export type Osc8Found = Found & { id?: string }
449
450function paramId(params: string): string | undefined {
451  for (const p of params.split(':')) if (p.startsWith('id=')) return p.slice(3)
452}
453
454/**
455 * Hyperlinks in raw terminal text. A non-empty URI opens a span. An empty URI closes it. A new open
456 * ends the span before it. Spans with the same `id` and URI are one link, as a terminal treats them.
457 */
458export function scanOsc8(text: string): Osc8Found[] {
459  if (!text.includes('\x1b]8;')) return []
460  type Open = { url: string; id?: string; label: string }
461  const spans: Open[] = []
462  let cur: Open | null = null
463  let pos = 0
464  const visible = (s: string) => s.replace(OSC8, '').replace(OTHER_ESC, '')
465  for (const m of text.matchAll(OSC8)) {
466    if (cur) cur.label += visible(text.slice(pos, m.index))
467    pos = m.index! + m[0].length
468    if (cur) {
469      spans.push(cur)
470      cur = null
471    }
472    const uri = m[2]!
473    if (uri !== '') cur = { url: uri, id: paramId(m[1]!), label: '' }
474  }
475  if (cur) {
476    ;(cur as Open).label += visible(text.slice(pos))
477    spans.push(cur)
478  }
479  const out: Osc8Found[] = []
480  for (const s of spans) {
481    const label = s.label.replace(/[\x00-\x1f\x7f]/g, '').trim()
482    const same = s.id === undefined ? undefined : out.find((o) => o.id === s.id && o.url === s.url)
483    if (same) {
484      if (label !== '') same.label = (same.label ?? '') + label
485      continue
486    }
487    out.push({ url: s.url, label: label === '' || label === s.url ? undefined : label, via: 'osc8', kind: 'link', id: s.id })
488  }
489  return out
490}
491
492// ---------------------------------------------------------------- content blocks
493
494/** The text of a message's content: a string, or blocks (`text`, and a tool_result's string or block content). */
495export function textOfContent(content: unknown): string {
496  if (typeof content === 'string') return content
497  if (!Array.isArray(content)) return ''
498  const parts: string[] = []
499  for (const b of content as Array<Record<string, unknown>>) {
500    if (b === null || typeof b !== 'object') continue
501    if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
502    else if (b.type === 'tool_result') parts.push(textOfContent(b.content))
503  }
504  return parts.join('\n')
505}
506
507// ---------------------------------------------------------------- merge
508
509export type Source = { door: string; uuid: string; agentId?: string }
510
511/**
512 * Add `found` to `list`. A URL already there only counts up. The list keeps the newest `cap`
513 * entries. Returns the new list and how many entries fell off the front.
514 */
515export function mergeLinks(list: readonly Captured[], found: readonly Found[], src: Source, cap: number): { list: Captured[]; dropped: number } {
516  const next = list.map((c) => ({ ...c }))
517  const index = new Map(next.map((c, k) => [c.url + '\u0000' + c.kind, k]))
518  for (const f of found) {
519    const url = normalizeUrl(f.url)
520    const key = url + '\u0000' + f.kind
521    const at = index.get(key)
522    if (at !== undefined) {
523      next[at]!.n++
524      continue
525    }
526    index.set(key, next.length)
527    const entry: Captured = { url, via: f.via, kind: f.kind, web: isWebUrl(url), door: src.door, uuid: src.uuid, n: 1 }
528    if (f.label !== undefined) entry.label = f.label
529    if (src.agentId !== undefined) entry.agentId = src.agentId
530    next.push(entry)
531  }
532  const dropped = Math.max(0, next.length - cap)
533  return { list: dropped > 0 ? next.slice(dropped) : next, dropped }
534}
535
types/index.d.ts 30 lines
1// State contract for the link-capture mod. `validate` reads the literal shape of PluginState.
2export type Via = 'inline' | 'autolink' | 'bare' | 'issue-ref' | 'reference' | 'image' | 'mailto' | 'osc8'
3
4export type Captured = {
5  /** Normalized target, as the terminal would receive it. */
6  url: string
7  /** Visible text of the link when it differs from the URL. */
8  label?: string
9  /** How the link was written. */
10  via: Via
11  /** `link`: the renderer or tool made a clickable span. `non-clickable`: it draws plain text. */
12  kind: 'link' | 'non-clickable'
13  /** True when the scheme is http, https or file. */
14  web: boolean
15  /** The session.append door of the first sighting. */
16  door: string
17  /** The row uuid of the first sighting. */
18  uuid: string
19  /** The subagent that wrote it. Absent on the main conversation. */
20  agentId?: string
21  /** How many times the same URL was seen. */
22  n: number
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'link-capture': { links: Captured[]; dropped: number }
28  }
29}
30