SLOPSHOPPER

page-pane

/page <project> opens a side pane with that project's 626labs.dev portfolio card: summary, banner and link.

panecommandprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · page-pane
│ ┃ page-pane ✕ › fix the failing auth test and add an audit log call │ ┃ Run /page <project> to open a card. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /page │ ⎿ page-pane: Could not read the portfolio feed. Error: https://raw │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · page-pane
Run /page <project> to open a card.
README

sanduhr-mods

Claude Code plugins, published from Sanduhr.

Add the marketplace once, then install any plugin in it by name:

claude plugin marketplace add estevanhernandez-stack-ed/sanduhr-mods
claude plugin install <plugin>@sanduhr-mods

Plugins

<!-- sanduhr:plugins -->

  • now-playing 0.3.1: A live now-playing line above the prompt: artist, title and a moving progress bar. On Windows from the media session (YouTube Music in Chrome included); on a Mac from every player through Sanduhr's adapter, or Music and Spotify without it.
  claude plugin install now-playing --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • finish-line 0.1.1: When a turn creates a tag (git tag, gh release create), a Claude sprite runs across the band above the prompt and crosses a checkered finish line, and the tag's name lands beside it. /finish-line [tag] replays it. Reads nothing, writes nothing, no network.
  claude plugin install finish-line --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • agent-comms 0.2.1: A band above the prompt showing every message to another agent that is still waiting on a reply.
  claude plugin install agent-comms --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • decision-logger 0.1.1: Pushes the dashboard decision log hard: a standing instruction, a nudge after commits and edit-heavy turns, and a running count.
  claude plugin install decision-logger --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • page-pane 0.1.1: /page <project> opens a side pane with that project's 626labs.dev portfolio card: summary, banner and link.
  claude plugin install page-pane --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • desk-setup 0.1.0: A Claude Code skill that sets up your Sanduhr Desk as a conversation: it reads the screen and wallpaper, asks a few short questions, proposes a small look you Apply on the live preview, then offers a finishing widget such as a pixel companion or song looks. Windows, Sanduhr 3.7 or later; widgets need 3.8.
  claude plugin install desk-setup --marketplace estevanhernandez-stack-ed/sanduhr-mods

Then ask Claude Code to set up your Desk ("set up my Desk on the second screen"). Every change previews on the desktop and waits for your Apply.

<!-- /sanduhr:plugins -->

Mods to build for your own setup

Some mods are tied to one person's machines: a home server, a media box, a dashboard. Those aren't published here as code, because the code would carry someone else's hosts. What travels instead is the idea and a prompt. Paste a prompt into Claude Code (the plugin-authoring skill ships with it), fill in the parts in angle brackets, and Claude builds the mod for your setup; Sanduhr's New Mod page then keeps it, checks it and turns it on per folder.

Home lab pulse

A status-line glyph and a toast when something at home goes down or comes back, read from whatever already knows (an Uptime Kuma instance, a Tailscale tailnet, a Home Assistant), polled slowly over SSH or HTTP. /lab lists every monitor and peer on demand.

Build me a Claude Code mod called homelab-pulse with the plugin-authoring skill.
It polls my home lab every 60 seconds and shows a short status-line entry: a
check mark when everything is up, the count of what is down otherwise, and
"lab ?" when the source can't be reached. It toasts once when a monitor drops
and once when it recovers, never on every poll. A /lab command prints every
monitor and peer with its state.
Source of truth: <how to read your monitors, e.g. `ssh <host> …` printing JSON,
an HTTP endpoint, or `tailscale status --json`>. Run it with $.process.run with
a 20 s timeout, or $.http.fetch; parse only the fields you show.
Keep all state in an atom; no files, no network beyond that one source; no
credentials in the mod (use an SSH host alias or a local-network URL).
Add register.test.ts covering: all up, one down, source unreachable, recovery.

A quiet pip for a media server

Two characters in the status line while a media server answers, nothing otherwise. No alerts, slow polling, no token.

Build me a Claude Code mod called plex-pip with the plugin-authoring skill.
Every <5> minutes it fetches <your server's unauthenticated health URL, e.g.
http://<host>:32400/identity for Plex, or /System/Ping for Jellyfin> with
$.http.fetch. While the response is ok it shows "🎬🎵" in the status line via
$.ui.status; on any error or non-2xx it shows nothing. Read the URL and the
interval from plugin options (plexUrl, pollMinutes) so the mod ships without my
address in it; do nothing when the URL option is empty. No toasts, no commands,
no state beyond the last result. Add register.test.ts for ok, non-2xx, a thrown
fetch and an empty option.

If you build one, publish it to your own marketplace from Sanduhr (New Mod, a kept mod's menu, Publish to Marketplace…).

Source 3 files
hooks/register.tsx 188 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { View } from '../types'
5import { SITE_URL, VERSIONS_URL, bannerUrl, cardMarkdown, findProducts, listIds, resolveLink } from './site'
6import type { Site } from './site'
7
8const PANE = 'page-pane'
9const MAX_BANNER_BYTES = 2 * 1024 * 1024
10
11const view = atom({ plugin: 'page-pane', key: 'view' } as const, null as View | null)
12
13type Loaded =
14  | { kind: 'ok'; view: View }
15  | { kind: 'none' }
16  | { kind: 'many'; ids: string[] }
17  | { kind: 'error'; message: string }
18
19async function fetchJson($: EngineInterface, url: string): Promise<unknown> {
20  const res = await $.http.fetch(url)
21
22  if (!res.ok) {
23    throw new Error(`${url} answered ${res.status}`)
24  }
25
26  return JSON.parse(res.text)
27}
28
29// The terminal reads the picture off disk itself, so the banner goes to a file, not through `$`.
30async function downloadBanner($: EngineInterface, id: string, url: string): Promise<string | null> {
31  try {
32    const tmp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
33    const sep = tmp.includes('\\') ? '\\' : '/'
34    const file = `${tmp.replace(/[\\/]+$/, '')}${sep}page-pane${sep}${id}.png`
35    const ran = await $.process.run(
36      ['curl', '-sL', '--create-dirs', '-o', file, '-w', '%{http_code} %{size_download}', url],
37      { timeoutMs: 20000 },
38    )
39    const [code, size] = ran.stdout.trim().split(' ')
40    const bytes = Number(size)
41    const isUsable = ran.exitCode === 0 && code === '200' && bytes > 0 && bytes <= MAX_BANNER_BYTES
42
43    return isUsable ? file : null
44  } catch {
45    return null
46  }
47}
48
49async function fetchSite($: EngineInterface): Promise<Site> {
50  return (await fetchJson($, SITE_URL)) as Site
51}
52
53async function loadCard($: EngineInterface, query: string, generation: number): Promise<Loaded> {
54  let site: Site
55
56  try {
57    site = await fetchSite($)
58  } catch (error) {
59    return { kind: 'error', message: `Could not read the portfolio feed. ${String(error)}` }
60  }
61
62  const found = findProducts(site, query)
63
64  if (found.length === 0) {
65    return { kind: 'none' }
66  }
67
68  if (found.length > 1) {
69    return { kind: 'many', ids: found.map(p => p.id) }
70  }
71
72  const product = found[0]
73  let versions: Record<string, string> = {}
74
75  try {
76    versions = (await fetchJson($, VERSIONS_URL)) as Record<string, string>
77  } catch {
78    // Versions are garnish. The card stands without them.
79  }
80
81  const bannerFile = await downloadBanner($, product.id, bannerUrl(product))
82
83  return {
84    kind: 'ok',
85    view: {
86      id: product.id,
87      title: product.title ?? product.id,
88      link: resolveLink(product),
89      markdown: cardMarkdown(product, versions[product.id]),
90      bannerFile,
91      generation,
92      error: null,
93    },
94  }
95}
96
97async function openInBrowser($: EngineInterface, url: string): Promise<void> {
98  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
99  const isMac = ((await $.env.get('HOME')) ?? '').startsWith('/Users/')
100  const argv = isWindows ? ['rundll32', 'url.dll,FileProtocolHandler', url] : [isMac ? 'open' : 'xdg-open', url]
101
102  await $.process.run(argv)
103}
104
105export const register: Register = on => {
106  on('session.start', async ($, e, next) => {
107    await $.command.register({
108      name: 'page',
109      description: "Open a project's portfolio card in a side pane",
110      argumentHint: '<project>',
111    })
112
113    return next(e)
114  })
115
116  on('command.run', { command: 'page' }, async ($, e) => {
117    const query = e.args.trim()
118
119    if (query === '') {
120      try {
121        return { text: `Projects: ${listIds(await fetchSite($)).join(', ')}` }
122      } catch (error) {
123        return { text: `Could not read the portfolio feed. ${String(error)}` }
124      }
125    }
126
127    const loaded = await loadCard($, query, 0)
128
129    if (loaded.kind === 'error') {
130      return { text: loaded.message }
131    }
132
133    if (loaded.kind === 'none') {
134      return { text: `No project matches "${query}". Run /page with no argument for the list.` }
135    }
136
137    if (loaded.kind === 'many') {
138      return { text: `Several match "${query}": ${loaded.ids.join(', ')}. Be more specific.` }
139    }
140
141    await update($, view, () => loaded.view)
142    await $.ui.open({ id: PANE, title: loaded.view.title })
143
144    return { text: `${loaded.view.title} opened in the pane.` }
145  })
146
147  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
148    const { Box, Button, Image, Link, Markdown, Text } = $.ui.resolve(e)
149    const current = await read($, view)
150
151    if (current === null) {
152      return <Text dimColor>Run /page &lt;project&gt; to open a card.</Text>
153    }
154
155    const refresh = async () => {
156      const loaded = await loadCard($, current.id, current.generation + 1)
157
158      if (loaded.kind === 'ok') {
159        await update($, view, () => loaded.view)
160      } else if (loaded.kind === 'error') {
161        await update($, view, v => (v === null ? v : { ...v, error: loaded.message }))
162      }
163    }
164
165    return (
166      <Box flexDirection="column" gap={1}>
167        <Text bold>{current.title}</Text>
168        {current.error !== null && <Text color="red">{current.error}</Text>}
169        {current.bannerFile !== null && (
170          <Image
171            key="banner"
172            source={{ file: current.bannerFile, format: 'png', generation: current.generation }}
173            columns={60}
174            rows={15}
175            alt={`${current.title} banner`}
176          />
177        )}
178        <Markdown text={current.markdown} />
179        <Link href={current.link} label={current.link} />
180        <Box gap={2}>
181          <Button key="refresh" label="Refresh" onPress={refresh} />
182          <Button key="open" label="Open in browser" onPress={() => openInBrowser($, current.link)} />
183        </Box>
184      </Box>
185    )
186  })
187}
188
hooks/site.ts 87 lines
1// Pure helpers over the 626labs.dev feed. No `$` in here, so these test without an engine.
2
3export const BASE = 'https://626labs.dev/'
4export const SITE_URL = 'https://raw.githubusercontent.com/626Labs-LLC/626Labs-LLC.github.io/main/content/site.json'
5export const VERSIONS_URL = `${BASE}data/plugin-versions.json`
6
7export type Tag = { label?: string; tone?: string }
8
9export type Product = {
10  id: string
11  title?: string
12  tagline?: string
13  description?: string
14  status?: string
15  tags?: Tag[]
16  banner?: string | null
17  productPage?: string | null
18  liveUrl?: string | null
19  storeUrl?: string | null
20  meta?: string | null
21}
22
23export type Site = { products?: Product[] }
24
25const squash = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '')
26
27// A path from the feed is relative to the site root, with or without a leading slash.
28export function absolute(path: string): string {
29  if (/^https?:\/\//i.test(path)) {
30    return path
31  }
32
33  return BASE + path.replace(/^\/+/, '')
34}
35
36// productPage, else liveUrl, else storeUrl, else the home-page row. Same order the site's own cards use.
37export function resolveLink(product: Product): string {
38  if (product.productPage) {
39    return absolute(product.productPage)
40  }
41
42  return product.liveUrl || product.storeUrl || `${BASE}#work`
43}
44
45export function bannerUrl(product: Product): string {
46  return absolute(product.banner || `assets/brand/plugins/${product.id}-banner-1280x640.png`)
47}
48
49export function listIds(site: Site): string[] {
50  return (site.products ?? []).map(p => p.id)
51}
52
53// Exact id or title beats prefix beats substring. Only the best rank comes back, so a tie is a real ambiguity.
54export function findProducts(site: Site, query: string): Product[] {
55  const q = squash(query)
56
57  if (q === '') {
58    return []
59  }
60
61  const ranked = (site.products ?? [])
62    .map(p => {
63      const keys = [squash(p.id), squash(p.title ?? '')]
64      const rank = keys.some(k => k === q) ? 0 : keys.some(k => k.startsWith(q)) ? 1 : keys.some(k => k.includes(q)) ? 2 : 3
65
66      return { p, rank }
67    })
68    .filter(r => r.rank < 3)
69
70  const best = Math.min(...ranked.map(r => r.rank), 3)
71
72  return ranked.filter(r => r.rank === best).map(r => r.p)
73}
74
75export function cardMarkdown(product: Product, version: string | undefined): string {
76  const tags = (product.tags ?? []).map(t => t.label).filter(Boolean)
77  const facts = [product.status, version, ...tags].filter(Boolean).join(' | ')
78  const lines = [
79    product.tagline ? `**${product.tagline}**` : '',
80    facts,
81    product.meta ?? '',
82    product.description ?? '',
83  ].filter(l => l !== '')
84
85  return lines.join('\n\n')
86}
87
types/index.d.ts 16 lines
1export type View = {
2  id: string
3  title: string
4  link: string
5  markdown: string
6  bannerFile: string | null
7  generation: number
8  error: string | null
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'page-pane': { view: View | null }
14  }
15}
16