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

<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.
![]()
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.
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.gh (one GraphQL call for each host) and acli.tracking Jira 6 · GitHub 4 (1 ✗) · Links 2 /track./track opens a pane with one section for each source. Press r to refresh, x to hide an auto row, Esc to close./clear, --resume and --continue.gh or acli, the items stay in the list and the section says why. A circuit breaker pauses a failing tool for 5 minutes.gh 2.102 or newer, logged in. It gives the live state of GitHub items.acli 1.3.19 or newer, logged in. It gives the live state of Jira items.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
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
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
Work as usual. Mention issues, PRs, Jira keys and links in your prompts. The band fills by itself.
| Command | Action |
|---|---|
/track | Opens the pane and refreshes the items that are due. |
/track refresh | Refreshes every item and waits. Use it in claude -p. |
/track clear | Removes all items of this session. |
The mod README lists every captured pattern, the auto-tracking rules, persistence and the known limits.
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.
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.
| Path | Content |
|---|---|
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.md | Rules for agents and contributors: lint, branches, commits, PRs, issues. |
Makefile | Build, test, lint and install targets. |
make lint runs all hooks in .pre-commit-config.yaml on all files.
| Hook | Checks |
|---|---|
pre-commit-hooks | Whitespace, end of file, line endings, YAML, JSON, merge markers, symlinks, large files, private keys, no commit to main. |
markdownlint-cli2 | Markdown. Config: .markdownlint-cli2.jsonc. |
biome-check | Format, lint and import order of plugins/ and e2e/. Config: biome.json. |
shellcheck | Shell scripts. |
actionlint | GitHub Actions workflows. |
typecheck | tsc on the mod and on e2e/ (make typecheck). |
conventional-pre-commit, commit-subject-case | Commit messages. See AGENTS.md. |
| Workflow | Runs |
|---|---|
test | make test and make e2e-unit on every PR and push to main. |
lint | The pre-commit hooks, the PR title check and the branch name check. |
release | On a tag <plugin>--v<version>: validates, tests, builds the zip and publishes the GitHub release. |
claude, claude-code-review | Claude answers @claude mentions and reviews pull requests. |
Release steps: plugins/artifact-tracking/README.md.
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.
MIT © Martin Macak
hooks/register.ts 169 lines1import { 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}
169hooks/links.ts 535 lines1// 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}
535types/index.d.ts 30 lines1// 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