SLOPSHOPPER
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · probe
│ ┃ Tracking ✕ › fix the failing auth test and add an audit log call │ ┃ Item Title Type Status │ ┃ - J1 Parent story with a rather lo ⏺ Read(src/auth.ts) │ ┃ J2 Subtask subtask in-progress ⎿ Read 6 lines │ ┃ J3 Bug bug todo ⏺ Update(src/auth.ts) │ ┃ #1 https://github.com/o/r/pull/1 ⎿ Added 2 lines, removed 1 line │ ┃ W1 https://example.com/x web ⏺ Bash(bun test) │ ┃ C6 ADDED-BY-COMMAND x new ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /probe-run │ ⎿ probe: probe-run: 26 entries written (26 ok) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tracking
Item Title Type Status - J1 Parent story with a rather long title that mu J2 Subtask subtask in-progress J3 Bug bug todo #1 https://github.com/o/r/pull/1 PR open W1 https://example.com/x web C6 ADDED-BY-COMMAND x new
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.tsx 299 lines
1import { 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
299
hooks/tools.ts 29 lines
1// 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}
29
types/index.d.ts 6 lines
1export 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