scratch probe

<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.tsx 299 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Entry, Row } from '../types'
4import { Availability, parseRemote } from './tools'
5
6const log = atom({ plugin: 'probe', key: 'log' } as const, [] as Entry[])
7const ticks = atom({ plugin: 'probe', key: 'ticks' } as const, 0)
8const rowsAtom = atom({ plugin: 'probe', key: 'rows' } as const, [] as Row[])
9const collapsed = atom({ plugin: 'probe', key: 'collapsed' } as const, [] as string[])
10const PANE = 'probe-pane'
11const loadId = Math.random().toString(36).slice(2, 8) // module variable: new value after each (re)load
12const OUT = '<scratch>/out/'
13
14const home = '~'
15const redact = (s: string) => s.split(home).join('~')
16
17async function attempt($: EngineInterface, label: string, fn: () => Promise<unknown>): Promise<Entry> {
18 const t0 = await $.clock.now()
19 try {
20 const value = await fn()
21 return { label, ok: true, ms: (await $.clock.now()) - t0, value }
22 } catch (err) {
23 const e = err as { name?: string; message?: string }
24 return { label, ok: false, ms: (await $.clock.now()) - t0, name: e?.name, message: redact(String(e?.message ?? err)) }
25 }
26}
27const slim = (r: { exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean }) => ({
28 exitCode: r.exitCode, stdout: redact(r.stdout.slice(0, 300)), stderr: redact(r.stderr.slice(0, 300)), trunc: r.isStdoutTruncated,
29})
30
31async function push($: EngineInterface, e: Entry): Promise<void> {
32 await update($, log, (l) => [...(l ?? []), e])
33}
34
35async function dump($: EngineInterface, name: string): Promise<void> {
36 await $.fs.write(OUT + name, JSON.stringify(await read($, log), null, 1))
37}
38
39export const register: Register = (on) => {
40 const rows: unknown[] = []
41 let avail: Availability | undefined
42 let starts = 0
43
44 on('session.start', async ($, e, next) => {
45 await $.command.register({ name: 'probe-run', description: 'process.run probes' })
46 await $.command.register({ name: 'probe-timers', description: 'start timer probes' })
47 await $.command.register({ name: 'probe-status', description: 'show/dump probe log', argumentHint: '[file]' })
48 await $.command.register({ name: 'probe-avail', description: 'Availability cache probe' })
49 await $.command.register({ name: 'probe-repo', description: 'session.repo probe' })
50 await $.command.register({ name: 'probe-pane', description: 'open the probe pane' })
51 await $.command.register({ name: 'probe-pane-later', description: 'open pane from a timer', argumentHint: '[tag]' })
52 await $.command.register({ name: 'probe-timers2', description: 'long timer probes' })
53 await $.command.register({ name: 'probe-globals', description: 'typeof globals' })
54 await $.command.register({ name: 'probe-add', description: 'add a row from a command hook' })
55 avail = new Availability((argv, init) => $.process.run(argv, init))
56 let beat = 0
57 const sid = await $.session.id()
58 $.clock.every(2000, async () => {
59 beat += 1
60 await $.fs.write(OUT + 'heartbeat.json', JSON.stringify({ loadId, beat, sid, at: await $.clock.now() }))
61 })
62 starts += 1
63 await $.fs.write(OUT + 'starts.json', JSON.stringify({ loadId, starts, sid, at: await $.clock.now() }))
64 return next(e)
65 })
66
67 on('command.run', { command: 'probe-run' }, async ($) => {
68 const R: Entry[] = []
69 const run = async (label: string, argv: string[], init?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number }) =>
70 R.push(await attempt($, label, async () => slim(await $.process.run(argv, init))))
71 await run('A1 missing tool', ['definitely-not-a-tool-xyz', '--version'])
72 await run('A2 gh --version', ['gh', '--version'])
73 await run('A3 acli --version', ['acli', '--version'])
74 await run('A4 which gh', ['which', 'gh'])
75 await run('A4b which missing', ['which', 'definitely-not-a-tool-xyz'])
76 await run('A6 sh -c command -v gh', ['sh', '-c', 'command -v gh; echo "rc=$?"'])
77 await run('A6b sh -c command -v missing', ['sh', '-c', 'command -v definitely-not-a-tool-xyz; echo "rc=$?"'])
78 await run('A7 timeout 1s on sleep 5', ['sleep', '5'], { timeoutMs: 1000 })
79 await run('A8 pwd default cwd', ['pwd'])
80 await run('A8b pwd cwd=/tmp', ['pwd'], { cwd: '/tmp' })
81 await run('A8c pwd cwd relative "docs"', ['pwd'], { cwd: 'docs' })
82 await run('A8d pwd cwd nonexistent', ['pwd'], { cwd: '/nonexistent-dir-xyz' })
83 await run('A9 empty argv', [])
84 await run('A10 relative ./gh', ['./gh', '--version'])
85 await run('A10b absolute gh', ['/opt/homebrew/bin/gh', '--version'])
86 await run('A11 gh with env PATH=/usr/bin', ['gh', '--version'], { env: { PATH: '/usr/bin' } })
87 await run('A11b which gh with env PATH=/usr/bin', ['/usr/bin/env', 'sh', '-c', 'command -v gh; echo rc=$?'], { env: { PATH: '/usr/bin' } })
88 await run('A14 non-executable file', ['/etc/hosts'])
89 await run('A15 exit 3', ['sh', '-c', 'echo out; echo err 1>&2; exit 3'])
90 await run('A15b killed by signal', ['sh', '-c', 'kill -9 $$'])
91 await run('A5b env var names count', ['sh', '-c', 'env | wc -l'])
92 await run('A5c env has CLAUDE vars (names only)', ['sh', '-c', 'env | cut -d= -f1 | grep -i "^CLAUDE\\|^ANTHROPIC" | sort | tr "\\n" " "'])
93 R.push(await attempt($, 'A5d child PATH equals $.env.get(PATH)', async () => {
94 const hostPath = await $.env.get('PATH')
95 const child = (await $.process.run(['sh', '-c', 'printf %s "$PATH"'])).stdout
96 return { equal: hostPath === child, entries: child.split(':').length }
97 }))
98 const t0 = await $.clock.now()
99 const par = await attempt($, 'A16 3x sleep 1 in parallel', async () => {
100 await Promise.all([1, 2, 3].map(() => $.process.run(['sleep', '1'])))
101 return 'done'
102 })
103 R.push(par)
104 R.push(await attempt($, 'A17 session.repo', async () => {
105 const r = await $.session.repo()
106 return r && { root: redact(r.root), remote: r.remote && r.remote.replace(/([:/])[^/:]+\/[^/]+$/, '$1<owner>/<repo>'), internal: r.internal, name: r.name, parsed: r.remote && (parseRemote(r.remote) ? { ...parseRemote(r.remote), owner: '<owner>', repo: '<repo>' } : null) }
107 }))
108 R.push(await attempt($, 'A18 session.root', async () => redact(await $.session.root())))
109 await $.fs.write(OUT + 'probe-run.json', JSON.stringify(R, null, 1))
110 return { text: `probe-run: ${R.length} entries written (${R.filter((x) => x.ok).length} ok)` }
111 })
112
113 on('command.run', { command: 'probe-avail' }, async ($) => {
114 const R: Entry[] = []
115 const t0 = await $.clock.now()
116 const a = await avail!.has('gh'); const b = await avail!.has('gh'); const c = await avail!.has('definitely-not-a-tool-xyz')
117 R.push({ label: 'avail', ok: true, value: { gh: a, gh2: b, missing: c } })
118 return { text: JSON.stringify(R) }
119 })
120
121 on('command.run', { command: 'probe-timers' }, async ($) => {
122 const start = await $.clock.now()
123 // T1: after 300ms, process.run then state update
124 $.clock.after(300, async () => {
125 const t = await $.clock.now()
126 const e = await attempt($, 'T1 after(300): process.run gh --version then update state', async () => slim(await $.process.run(['gh', '--version'])))
127 await push($, { ...e, at: t - start })
128 })
129 // T2: every 1000 ms, 3 ticks then cancel
130 let n = 0
131 const timer = $.clock.every(1000, async () => {
132 n += 1
133 const t = await $.clock.now()
134 await update($, ticks, (v) => (v ?? 0) + 1)
135 await push($, { label: 'T2 every(1000) tick ' + n, ok: true, at: t - start })
136 if (n >= 3) timer.cancel()
137 })
138 // T3: long callback: 12 s process.run inside the timer (hook budget is 10 s)
139 $.clock.after(500, async () => {
140 const e = await attempt($, 'T3 after(500): process.run sleep 12 (> 10 s hook budget)', async () => slim(await $.process.run(['sleep', '12'], { timeoutMs: 20000 })))
141 await push($, { ...e, at: (await $.clock.now()) - start })
142 })
143 // T4: callback that sleeps with $.clock.sleep 11 s (sleep counts against a hook budget)
144 $.clock.after(600, async () => {
145 const e = await attempt($, 'T4 after(600): clock.sleep 11000', async () => { await $.clock.sleep(11000); return 'slept' })
146 await push($, { ...e, at: (await $.clock.now()) - start })
147 })
148 // T5: callback that throws; later timers must still run
149 $.clock.after(700, () => { throw new Error('T5 boom') })
150 $.clock.after(1500, async () => push($, { label: 'T5b after throwing timer, still runs', ok: true, at: (await $.clock.now()) - start }))
151 // T6: store.set from timer
152 $.clock.after(800, async () => {
153 const e = await attempt($, 'T6 store.set from timer', async () => { await $.store.set('probe-t6', { ok: 1 }); return await $.store.get('probe-t6') })
154 await push($, { ...e, at: (await $.clock.now()) - start })
155 })
156 return { text: 'probe-timers: started' }
157 })
158
159 on('command.run', { command: 'probe-timers2' }, async ($) => {
160 const start = await $.clock.now()
161 // T7: a 45 s callback inside a timer
162 $.clock.after(100, async () => {
163 const e = await attempt($, 'T7 after(100): process.run sleep 45', async () => slim(await $.process.run(['sleep', '45'], { timeoutMs: 60000 })))
164 await push($, { ...e, at: (await $.clock.now()) - start })
165 })
166 // T8: every(1000) whose callback takes 2500 ms: overlap or wait?
167 let n = 0
168 const t = $.clock.every(1000, async () => {
169 n += 1
170 const k = n
171 await push($, { label: 'T8 every(1000) callback ' + k + ' start', ok: true, at: (await $.clock.now()) - start })
172 await $.clock.sleep(2500)
173 await push($, { label: 'T8 every(1000) callback ' + k + ' end', ok: true, at: (await $.clock.now()) - start })
174 if (k >= 4) t.cancel()
175 })
176 return { text: 'probe-timers2: started' }
177 })
178
179 on('command.run', { command: 'probe-status' }, async ($, e) => {
180 const l = await read($, log)
181 const n = await read($, ticks)
182 const file = e.args.trim() || 'status.json'
183 await $.fs.write(OUT + file, JSON.stringify({ at: await $.clock.now(), ticks: n, log: l }, null, 1))
184 return { text: `probe-status: ticks=${n} entries=${l.length} -> ${file}` }
185 })
186
187 on('command.run', { command: 'probe-repo' }, async ($) => {
188 const r = await $.session.repo()
189 return { text: JSON.stringify({ repo: r === null ? null : { remoteShape: r.remote && r.remote.replace(/([:/])[^/:]+\/[^/]+$/, '$1<owner>/<repo>'), internal: r.internal, name: r.name } }) }
190 })
191
192 on('command.run', { command: 'probe-pane' }, async ($) => {
193 await update($, rowsAtom, () => [
194 { id: 'J1', depth: 0, title: 'Parent story with a rather long title that must be truncated', type: 'story', status: 'in-progress', hasKids: true },
195 { id: 'J2', depth: 1, title: 'Subtask', type: 'subtask', status: 'in-progress' },
196 { id: 'J3', depth: 1, title: 'Bug', type: 'bug', status: 'todo' },
197 { id: '#1', depth: 0, title: 'A pull request', type: 'PR', status: 'open', href: 'https://github.com/o/r/pull/1' },
198 { id: 'W1', depth: 0, title: 'Example page', type: 'web', status: '', href: 'https://example.com/x' },
199 ])
200 const opened = await $.ui.open({ id: PANE, title: 'Tracking' })
201 // timer writes state later: does the pane redraw by itself?
202 $.clock.after(3000, async () => { await update($, rowsAtom, (l) => [...l, { id: 'T9', depth: 0, title: 'ADDED-BY-TIMER', type: 'x', status: 'new' }]) })
203 return { text: 'probe-pane: ' + JSON.stringify(opened) }
204 })
205
206 on('command.run', { command: 'probe-pane-later' }, async ($, e) => {
207 // an unrequested open: a timer, not the person
208 $.clock.after(1000, async () => {
209 const opened = await $.ui.open({ id: PANE, title: 'Tracking' })
210 await $.fs.write(OUT + 'pane-open-' + (e.args.trim() || 'x') + '.json', JSON.stringify(opened))
211 })
212 return { text: 'probe-pane-later: scheduled' }
213 })
214
215 on('command.run', { command: 'probe-add' }, async ($) => {
216 await update($, rowsAtom, (l) => [...l, { id: 'C' + (l.length + 1), depth: 0, title: 'ADDED-BY-COMMAND', type: 'x', status: 'new' }])
217 return { text: 'probe-add: ok' }
218 })
219
220 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221 const { Box, Text, Button, Link } = $.ui.resolve(e)
222 void $.fs.write(OUT + 'render-props-' + (e.viewport?.columns ?? 'x') + '.json', JSON.stringify({ placement: e.props.placement, bodyColumns: e.props.bodyColumns, scroll: e.props.scroll, viewport: e.viewport, isFocused: e.props.isFocused, title: e.props.title }))
223 const all = await read($, rowsAtom)
224 const closed = await read($, collapsed)
225 const shown: Row[] = []
226 let skipDepth = -1
227 for (const r of all) {
228 if (skipDepth >= 0 && r.depth > skipDepth) continue
229 skipDepth = closed.includes(r.id) ? r.depth : -1
230 shown.push(r)
231 }
232 return (
233 <Box flexDirection="column">
234 <Box columnGap={1}>
235 <Box width={14}><Text bold>Item</Text></Box>
236 <Box flexGrow={1} flexShrink={1}><Text bold wrap="truncate-end">Title</Text></Box>
237 <Box width={8}><Text bold>Type</Text></Box>
238 <Box width={12}><Text bold>Status</Text></Box>
239 </Box>
240 {shown.map((r) => (
241 <Box key={'row-' + r.id} columnGap={1}>
242 <Box width={14} flexShrink={0} paddingLeft={r.depth * 2}>
243 <Box width={2} flexShrink={0}>
244 {r.hasKids ? (
245 <Button key={'t-' + r.id} plain label={closed.includes(r.id) ? '+' : '-'} onPress={() => update($, collapsed, (c) => (c.includes(r.id) ? c.filter((x) => x !== r.id) : [...c, r.id]))} />
246 ) : (
247 <Text> </Text>
248 )}
249 </Box>
250 <Text wrap="truncate-end">{r.id}</Text>
251 </Box>
252 <Box flexGrow={1} flexShrink={1}>
253 {r.href ? <Text wrap="truncate-end"><Link href={r.href}>{r.title}</Link></Text> : <Text wrap="truncate-end">{r.title}</Text>}
254 </Box>
255 <Box width={8} flexShrink={0}><Text wrap="truncate-end">{r.type}</Text></Box>
256 <Box width={12} flexShrink={0}><Text wrap="truncate-end" dimColor>{r.status}</Text></Box>
257 </Box>
258 ))}
259 </Box>
260 )
261 })
262
263 on('command.run', { command: 'probe-globals' }, async () => {
264 const g = globalThis as Record<string, unknown>
265 const names = ['Date', 'URL', 'URLSearchParams', 'Intl', 'TextEncoder', 'structuredClone', 'AbortController', 'setTimeout', 'setInterval', 'queueMicrotask', 'fetch', 'process', 'require', 'Buffer', 'crypto', 'console', 'RegExp', 'Map', 'Set', 'Promise', 'WeakRef', 'BigInt']
266 const out: Record<string, string> = {}
267 for (const n of names) out[n] = typeof g[n]
268 let urlOk = ''
269 try { const u = new URL('https://acme.atlassian.net/browse/ABC-12?x=1#y'); urlOk = u.host + '|' + u.pathname } catch (err) { urlOk = 'ERR ' + String(err) }
270 let dateOk = ''
271 try { dateOk = typeof Date.now() + '|' + new Date(0).toISOString() } catch (err) { dateOk = 'ERR ' + String(err) }
272 let lookbehind = ''
273 try { lookbehind = String(new RegExp('(?<![\\w/])#(\\d+)\\b').exec('see #12 and a#3')?.[1]) } catch (err) { lookbehind = 'ERR ' + String(err) }
274 return { text: JSON.stringify({ out, urlOk, dateOk, lookbehind }) }
275 })
276
277 on('prompt.submit', async ($, e, next) => {
278 await $.fs.write(OUT + 'events/prompt.submit-' + (await $.clock.now()) + '.json', JSON.stringify({ text: e.text, origin: e.origin, wait: e.wait, turnId: e.turnId }))
279 return next(e)
280 })
281 on('command.run', async ($, e, next) => {
282 await $.fs.write(OUT + 'events/command.run-' + (await $.clock.now()) + '.json', JSON.stringify({ command: e.command, args: e.args, origin: e.origin }))
283 return next(e)
284 })
285
286 on('session.append', async ($, e, next) => {
287 const stored = await next(e)
288 const text = e.door === 'attachment' ? '(omitted)' : redact(JSON.stringify(e.message.content).slice(0, 400))
289 const row = { door: e.door, origin: e.origin, agentId: e.agentId, type: e.message.type, name: e.message.name, role: e.message.role, isMeta: e.message.isMeta, content: text }
290 rows.push(row)
291 await $.fs.write(OUT + 'rows/' + String(rows.length).padStart(3, '0') + '-' + e.door + '.json', JSON.stringify(row, null, 1))
292 return stored
293 })
294}
295
296// touched 1791630080.8973062
297// touched for hot reload
298// touched again
299hooks/tools.ts 29 lines1// No $ in this file. The runner is injected.
2export type RunInit = { cwd?: string; env?: Record<string, string>; stdin?: string; timeoutMs?: number }
3export type RunResult = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean; isStderrTruncated: boolean }
4export type Run = (argv: readonly string[], init?: RunInit) => Promise<RunResult>
5
6/** Cheap detection: run `<name> --version` once, cache the promise. */
7export class Availability {
8 private cache = new Map<string, Promise<boolean>>()
9 constructor(private readonly run: Run) {}
10 has(name: string): Promise<boolean> {
11 let p = this.cache.get(name)
12 if (!p) {
13 p = this.run([name, '--version'], { timeoutMs: 5000 }).then((r) => r.exitCode === 0, () => false)
14 this.cache.set(name, p)
15 }
16 return p
17 }
18}
19
20export type Parsed = { host: string; owner: string; repo: string } | null
21/** owner/repo from a git remote URL: scp-like, ssh://, https://, git://. */
22export function parseRemote(remote: string | null | undefined): Parsed {
23 if (!remote) return null
24 const m =
25 /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remote) ??
26 /^(?:ssh|https?|git):\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remote)
27 return m ? { host: m[1]!, owner: m[2]!, repo: m[3]! } : null
28}
29types/index.d.ts 6 lines1export type Entry = { label: string; ok?: boolean; ms?: number; at?: number; name?: string; message?: string; value?: unknown }
2export type Row = { id: string; depth: number; title: string; type: string; status: string; href?: string; hasKids?: boolean }
3declare module 'claude-code' {
4 interface PluginState { probe: { log: Entry[]; ticks: number; rows: Row[]; collapsed: string[] } }
5}
6