SLOPSHOPPER

clayfold-monitor

Clayfold server and frontend statistics and the Claude Code instances the server runs, in a pane

newpaneguardcommandtoaststatus
v0.3.0AGPL-3.0updated 2026-10-04VinniZP/clayfold/.claude/skills/clayfold-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clayfold-monitor
│ ┃ Clayfold ✕ › fix the failing auth test and add an audit log call │ ┃ Backend down 127.0.0.1:4317 · HTTP 0 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Start server ] ⎿ Read 6 lines │ ┃ Runs `bun run dev:server` in the project ⏺ Update(src/auth.ts) │ ┃ folder. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Frontend ⎿ 3 pass, 1 fail │ ┃ Vite :5173 down │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /clayfold │ ⎿ clayfold-monitor: Clayfold pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Clayfold
Backend down 127.0.0.1:4317 · HTTP 0 [ Start server ] Runs `bun run dev:server` in the project folder. Frontend Vite :5173 down
README

Clayfold

A local, single-learner learning platform. You type what you want to learn; Claude Code, running headless, interviews you, collects sources, builds a knowledge map and generates interactive lessons. Every lesson step passes quality gates before you see it. Lessons come with a tutor, flashcards with spaced repetition, notes and a memory of your progress.

How lessons are built and why: docs/learning-design.md. System layout and contracts: docs/architecture.md.

Requirements

  • Bun 1.3 or later
  • Claude Code 2.1.288 or later, signed in (claude auth status). Lessons run on your subscription or API key.

Run

bun install
bun run build     # builds the UI into web/dist
bun run start     # http://127.0.0.1:4317

When origin/main has new commits, the top bar shows "New version". The update pulls them, rebuilds the UI and restarts the server, either at once or after Claude finishes its current work. Claude turns that a restart cuts off run again after it. The update needs a clean checkout of main and a server started with bun run start.

The app speaks English by default; switch to Russian with the language button in the sidebar. The language also sets what Claude writes from the next run on: interviews, missions, lessons, cards and the tutor. Content written earlier keeps its language.

Data lives in data/ (SQLite database and one workspace folder per topic with MISSION.md, RESOURCES.md, GLOSSARY.md, NOTES.md, learning-records/). Back it up to keep your progress; it is not in git.

Settings

VariableDefaultMeaning
CLAYFOLD_PORT4317Server port
CLAYFOLD_DATA_DIR./dataDatabase and workspaces
CLAYFOLD_MODELopusDefault model for onboarding, lessons, the tutor and review; the Settings page overrides it per role
CLAYFOLD_CRITIC_MODELsonnetDefault model for the critic, answer grading and narration scripts; the Settings page overrides it per role
CLAYFOLD_MAX_BUDGET_USD5Spend ceiling per Claude run
CLAYFOLD_CLAUDE_BINclaudeClaude Code executable
OPENALEX_API_KEYnoneFree OpenAlex key for paper discovery; raises the daily free budget from $0.10 to $1

An Exa key, added under Settings → Source search, lets Claude also search the web by meaning when it collects sources. It is optional and kept in the system keychain.

Set up with an AI agent

Clone the repository, open your coding agent (Claude Code, Codex, Cursor or another) in its folder, and paste this prompt:

Set up Clayfold from this repository and start it. Work from the repository root, run the steps in order, and check each result before the next step. If a check fails and you cannot fix it, stop and tell me what failed.

1. `bun --version` prints 1.3 or later. Otherwise install Bun: `curl -fsSL https://bun.com/install | bash` (Windows: `powershell -c "irm bun.sh/install.ps1|iex"`), then use a new shell.
2. `claude --version` prints 2.1.288 or later. Otherwise install or update Claude Code: `curl -fsSL https://claude.ai/install.sh | bash` (Windows: `irm https://claude.ai/install.ps1 | iex`).
3. `claude auth status` reports `"loggedIn": true`. Otherwise ask me to run `claude` once and finish the browser login, then check again.
4. `bun install` exits 0.
5. `bun run build` exits 0 and `web/dist/index.html` exists.
6. Start `bun run start` in the background. It prints `Clayfold on http://127.0.0.1:4317`. If the port is taken, set `CLAYFOLD_PORT` to a free port.
7. `curl -s http://127.0.0.1:4317/api/settings` (with your port) returns JSON with a `language` field.
8. Give me the address to open. Tell me that lessons run Claude Code on my account, each run capped at CLAYFOLD_MAX_BUDGET_USD (default 5 USD), and that the app language, English or Russian, is switched with the button in the sidebar.

Contribute

Development setup, project layout, translations and the pull request process: CONTRIBUTING.md. Coding agents read AGENTS.md. Report vulnerabilities privately as described in SECURITY.md.

License

GNU Affero General Public License v3.0 or later.

Source 2 files
hooks/register.tsx 349 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClaudeInstance, FinishedRun, Samples, Snapshot, SystemView } from '../types'
5
6const PANE = 'clayfold'
7const COMMAND = 'clayfold'
8const TOOL = 'server_state'
9const POLL_MS = 2000
10const KEPT_SAMPLES = 60
11const SHOWN_ACTIVITIES = 3
12const SHOWN_FINISHED = 3
13const SPARK_LABEL = 6
14
15const snapshot = atom({ plugin: 'clayfold-monitor', key: 'snapshot' } as const, null)
16const autoOpened = atom({ plugin: 'clayfold-monitor', key: 'autoOpened' } as const, false)
17const samples = atom({ plugin: 'clayfold-monitor', key: 'samples' } as const, { cpu: [], rssMb: [] })
18const notifiedUntil = atom({ plugin: 'clayfold-monitor', key: 'notifiedUntil' } as const, null)
19
20type Config = { port: number; vitePort: number; longRunMs: number }
21
22const mb = (v: number) => `${v.toFixed(v < 10 ? 1 : 0)} MB`
23const pct = (v: number) => `${v.toFixed(1)}%`
24const usd = (v: number) => `$${v.toFixed(v < 0.1 ? 3 : 2)}`
25
26/** "m:ss", or "h:mm:ss" from an hour on. */
27export function elapsed(ms: number): string {
28  const s = Math.max(0, Math.floor(ms / 1000))
29  const h = Math.floor(s / 3600)
30  const m = Math.floor((s % 3600) / 60)
31  const ss = String(s % 60).padStart(2, '0')
32  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
33}
34
35const since = (at: number, iso: string) => elapsed(at - Date.parse(iso))
36
37const appUrl = (port: number, path = '/') => `http://localhost:${port}${path}`
38
39/** Where an instance or a finished run lives in the app: its lesson, else its topic. */
40function pagePath(run: { lessonId: string | null; topicId: string | null }): string | null {
41  if (run.lessonId) return `/lessons/${encodeURIComponent(run.lessonId)}`
42  if (run.topicId) return `/topics/${encodeURIComponent(run.topicId)}`
43  return null
44}
45
46const instanceMeta = (it: ClaudeInstance) =>
47  [
48    `PID ${it.pid}`,
49    it.model,
50    it.rssMb !== null ? mb(it.rssMb) : null,
51    it.cpuPercent !== null ? `CPU ${pct(it.cpuPercent)}` : null,
52    it.queued ? `${it.queued} queued` : null,
53  ]
54    .filter(Boolean)
55    .join(' · ')
56
57export function statusLine(snap: Snapshot | null): string | undefined {
58  const sys = snap?.system
59  if (!sys) return undefined
60  return `clayfold: ${sys.instances.length} claude · ${mb(sys.backend.rssMb)} · cpu ${pct(sys.backend.cpuPercent)}`
61}
62
63export function finishedText(run: FinishedRun): string {
64  const what = `${run.kind} "${run.topicTitle}"`
65  const took = elapsed(Date.parse(run.finishedAt) - Date.parse(run.startedAt))
66  const cost = run.costUsd !== null ? `, ${usd(run.costUsd)}` : ''
67  if (run.cancelled) return `Clayfold: ${what} stopped after ${took}`
68  if (run.error) return `Clayfold: ${what} failed after ${took}: ${run.error.slice(0, 120)}`
69  return `Clayfold: ${what} finished in ${took}${cost}`
70}
71
72/** Runs finished after `until`, oldest first. */
73export function newlyFinished(finished: FinishedRun[], until: string): FinishedRun[] {
74  return finished.filter(run => run.finishedAt > until).reverse()
75}
76
77/** One terminal row of block characters, scaled from `min` to `max`, as RasterProps.cells. */
78export function sparkCells(values: number[], min: number, max: number, color: number): string {
79  const span = max - min || 1
80  const words = new Uint32Array(values.length * 3)
81  values.forEach((v, i) => {
82    const level = Math.round(((Math.min(max, Math.max(min, v)) - min) / span) * 7)
83    words.set([0x2581 + level, color, 0x01000000], i * 3)
84  })
85  let binary = ''
86  for (const byte of new Uint8Array(words.buffer)) binary += String.fromCharCode(byte)
87  return btoa(binary)
88}
89
90async function fetchSystem($: EngineInterface, port: number): Promise<{ system: SystemView | null; error: string | null }> {
91  try {
92    const r = await $.http.fetch(`http://127.0.0.1:${port}/api/system`)
93    if (!r.ok) return { system: null, error: `HTTP ${r.status}` }
94    const system = JSON.parse(r.text) as Partial<SystemView>
95    // A server started before /api/system gained these fields keeps answering without them until it restarts.
96    if (!Array.isArray(system.finished) || !system.instances?.every(it => Array.isArray(it.activities))) {
97      return { system: null, error: 'the server runs older code; restart it' }
98    }
99    return { system: system as SystemView, error: null }
100  } catch (err) {
101    return { system: null, error: err instanceof Error ? err.message : String(err) }
102  }
103}
104
105async function poll($: EngineInterface, config: Config): Promise<void> {
106  const [answer, viteUp] = await Promise.all([
107    fetchSystem($, config.port),
108    $.http.fetch(`http://localhost:${config.vitePort}/`).then(
109      r => r.ok,
110      () => false,
111    ),
112  ])
113  const next: Snapshot = { at: await $.clock.now(), ...answer, viteUp }
114  await update($, snapshot, () => next)
115  $.ui.status(statusLine(next))
116  const system = answer.system
117  if (!system) return
118
119  await update($, samples, (old): Samples => ({
120    cpu: [...(old?.cpu ?? []), system.backend.cpuPercent].slice(-KEPT_SAMPLES),
121    rssMb: [...(old?.rssMb ?? []), system.backend.rssMb].slice(-KEPT_SAMPLES),
122  }))
123
124  // The first answer of a session only sets the mark, so runs finished before it are not announced.
125  const until = await read($, notifiedUntil)
126  const newest = system.finished[0]?.finishedAt ?? ''
127  if (until !== null) for (const run of newlyFinished(system.finished, until)) $.ui.toast(finishedText(run), { timeoutMs: 8000 })
128  if (until === null || newest > until) await update($, notifiedUntil, () => newest)
129}
130
131async function stopRun($: EngineInterface, port: number, conversationId: string): Promise<void> {
132  try {
133    const r = await $.http.fetch(`http://127.0.0.1:${port}/api/conversations/${encodeURIComponent(conversationId)}/cancel`, {
134      method: 'POST',
135    })
136    $.ui.toast(r.ok ? 'Clayfold: stopping the run…' : `Clayfold: stop failed, HTTP ${r.status}`)
137  } catch (err) {
138    $.ui.toast(`Clayfold: stop failed: ${err instanceof Error ? err.message : String(err)}`)
139  }
140}
141
142/**
143 * Starts `bun run dev:server` detached, so it outlives this mod and this session; works on macOS, Linux and Windows.
144 * The mod lives in `.claude/skills/clayfold-monitor` of the checkout it starts.
145 */
146async function startServer($: EngineInterface, config: Config): Promise<void> {
147  const dir = `${$.plugin.root}/../../..`
148  if (!(await $.fs.exists(`${dir}/server/index.ts`))) {
149    $.ui.toast(`Clayfold: no server/index.ts in ${dir}`)
150    return
151  }
152  const script = [
153    "const { spawn } = require('node:child_process')",
154    "const fs = require('node:fs')",
155    "fs.mkdirSync('data', { recursive: true })",
156    "const log = fs.openSync('data/dev-server.log', 'a')",
157    `spawn('bun', ['run', 'dev:server'], { detached: true, windowsHide: true, stdio: ['ignore', log, log], env: { ...process.env, CLAYFOLD_PORT: '${config.port}' } }).unref()`,
158  ].join(';')
159  const ran = await $.process.run(['bun', '-e', script], { cwd: dir, timeoutMs: 15000 })
160  $.ui.toast(
161    ran.exitCode === 0
162      ? 'Clayfold: starting the server; its log is data/dev-server.log'
163      : `Clayfold: start failed: ${(ran.stderr || ran.stdout).slice(0, 160)}`,
164  )
165}
166
167export const register: Register = (on, options) => {
168  const config: Config = {
169    port: Number(options.port ?? 4317),
170    vitePort: Number(options.vitePort ?? 5173),
171    longRunMs: Number(options.longRunMinutes ?? 10) * 60_000,
172  }
173
174  on('session.start', async ($, e, next) => {
175    await $.command.register({
176      name: COMMAND,
177      description: 'Show the Clayfold server, its frontend and the Claude Code instances it runs',
178    })
179    await $.tool.register({
180      name: TOOL,
181      description:
182        'Reads the local Clayfold server state: backend process stats, frontend build, the running Claude Code ' +
183        'instances with their latest activities, and recently finished conversation turns with duration, cost and ' +
184        'error. Use it to find out why a lesson, onboarding or tutor run is slow, stuck or failed.',
185      inputSchema: { type: 'object', properties: {} },
186    })
187    void poll($, config)
188    $.clock.every(POLL_MS, () => void poll($, config))
189    // A reload fires session.start again; the pane opens unasked once per session.
190    if (!(await read($, autoOpened))) {
191      await update($, autoOpened, () => true)
192      void $.ui.open({ id: PANE, title: 'Clayfold' })
193    }
194    return next(e)
195  })
196
197  on('command.run', { command: COMMAND }, async $ => {
198    const opened = await $.ui.open({ id: PANE, title: 'Clayfold' })
199    return { text: opened.isPlaced ? 'Clayfold pane opened.' : 'Clayfold pane is waiting for a wider terminal.' }
200  })
201
202  // The tool is registered at session start, so this build's tool names do not list it for a matcher.
203  on('tool.call', async ($, e, next) => {
204    if (String(e.tool) !== `mcp__clayfold-monitor__${TOOL}`) return next(e)
205    const { system, error } = await fetchSystem($, config.port)
206    if (!system) return { result: `The Clayfold server at 127.0.0.1:${config.port} did not answer: ${error}` }
207    return { result: JSON.stringify(system, null, 2) }
208  })
209
210  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211    const elements = $.ui.resolve(e)
212    const { Box, Text, Button, Link } = elements
213    const snap = await read($, snapshot)
214
215    if (!snap) return <Text dimColor>Connecting to 127.0.0.1:{config.port}…</Text>
216
217    const vite = (
218      <Text>
219        Vite :{config.vitePort} <Text color={snap.viteUp ? 'green' : 'gray'}>{snap.viteUp ? 'up' : 'down'}</Text>
220      </Text>
221    )
222
223    if (!snap.system) {
224      return (
225        <Box flexDirection="column">
226          <Text>
227            <Text bold>Backend</Text> <Text color="red">down</Text>{' '}
228            <Text dimColor>
229              127.0.0.1:{config.port} · {snap.error}
230            </Text>
231          </Text>
232          <Box marginTop={1}>
233            <Button key="start" hotkey="s" variant="primary" onPress={() => startServer($, config)}>
234              Start server
235            </Button>
236          </Box>
237          <Text dimColor>Runs `bun run dev:server` in the project folder.</Text>
238          <Text> </Text>
239          <Text bold>Frontend</Text>
240          {vite}
241        </Box>
242      )
243    }
244
245    const { backend: b, frontend: f, instances, finished } = snap.system
246    const history = await read($, samples)
247    const width = Math.max(1, Math.min(KEPT_SAMPLES, (e.viewport?.columns ?? 40) - SPARK_LABEL - 12))
248    const cpu = history?.cpu.slice(-width) ?? []
249    const rss = history?.rssMb.slice(-width) ?? []
250    const sparks =
251      e.surface === 'terminal' && cpu.length > 1 && 'Raster' in elements
252        ? (() => {
253            const { Raster } = elements
254            return (
255              <Box flexDirection="column">
256                <Box flexDirection="row">
257                  <Text dimColor>{'cpu'.padEnd(SPARK_LABEL)}</Text>
258                  <Raster key="cpu" columns={cpu.length} rows={1} cells={sparkCells(cpu, 0, Math.max(5, ...cpu), 0x5fd75f)} />
259                  <Text dimColor> max {pct(Math.max(...cpu))}</Text>
260                </Box>
261                <Box flexDirection="row">
262                  <Text dimColor>{'mem'.padEnd(SPARK_LABEL)}</Text>
263                  <Raster key="rss" columns={rss.length} rows={1} cells={sparkCells(rss, Math.min(...rss), Math.max(...rss), 0x5fafff)} />
264                  <Text dimColor> max {mb(Math.max(...rss))}</Text>
265                </Box>
266              </Box>
267            )
268          })()
269        : null
270
271    let hotkey = 0
272    return (
273      <Box flexDirection="column">
274        <Text>
275          <Text bold>Backend</Text> <Text color="green">up</Text> {since(snap.at, b.startedAt)}{' '}
276          <Link href={appUrl(config.port)} label="open app" />
277        </Text>
278        <Text wrap="truncate-end">
279          PID {b.pid} · Bun {b.bun} · :{b.port}
280        </Text>
281        <Text wrap="truncate-end">
282          {mb(b.rssMb)} (heap {mb(b.heapMb)}) · CPU {pct(b.cpuPercent)} · DB {mb(b.dbMb)}
283        </Text>
284        <Text dimColor wrap="truncate-end">
285          {b.model}, critic {b.criticModel} · {usd(b.maxBudgetUsd)} per run
286        </Text>
287        {sparks}
288        <Text> </Text>
289        <Text bold>Frontend</Text>
290        <Text wrap="truncate-end">web/dist {f.builtAt ? `built ${since(snap.at, f.builtAt)} ago` : 'not built'}</Text>
291        {vite}
292        <Text dimColor>
293          {f.streams} open event stream{f.streams === 1 ? '' : 's'}
294        </Text>
295        <Text> </Text>
296        <Text bold>Claude Code instances: {instances.length}</Text>
297        {instances.length === 0 && <Text dimColor>None running.</Text>}
298        {instances.map(it => {
299          const age = snap.at - Date.parse(it.startedAt)
300          const isLong = age > config.longRunMs
301          const path = pagePath(it)
302          const key = it.conversationId && hotkey < 9 ? String(++hotkey) : undefined
303          const shown = it.activities.slice(-SHOWN_ACTIVITIES)
304          return (
305            <Box flexDirection="column" marginTop={1}>
306              <Text wrap="truncate-end">
307                <Text color="magenta">{it.kind}</Text> <Text bold>{it.topicTitle ?? '—'}</Text>{' '}
308                <Text color={isLong ? 'yellow' : undefined} dimColor={!isLong}>
309                  {elapsed(age)}
310                  {isLong ? ' long run' : ''}
311                </Text>
312              </Text>
313              {shown.map((label, i) => (
314                <Text dimColor={i < shown.length - 1} wrap="truncate-end">
315                  {i < shown.length - 1 ? '  ' : '▸ '}
316                  {label}
317                </Text>
318              ))}
319              <Text dimColor wrap="truncate-end">
320                {instanceMeta(it)}
321              </Text>
322              {(it.conversationId || path) && (
323                <Box flexDirection="row" gap={2}>
324                  {it.conversationId && (
325                    <Button key={`stop-${it.conversationId}`} hotkey={key} dimColor onPress={() => stopRun($, config.port, it.conversationId!)}>
326                      stop
327                    </Button>
328                  )}
329                  {path && <Link href={appUrl(config.port, path)} label="open in browser" />}
330                </Box>
331              )}
332            </Box>
333          )
334        })}
335        {finished.length > 0 && (
336          <Box flexDirection="column" marginTop={1}>
337            <Text bold>Recently finished</Text>
338            {finished.slice(0, SHOWN_FINISHED).map(run => (
339              <Text wrap="truncate-end" color={run.error && !run.cancelled ? 'red' : undefined} dimColor={!run.error || run.cancelled}>
340                {finishedText(run).replace(/^Clayfold: /, '')} · {since(snap.at, run.finishedAt)} ago
341              </Text>
342            ))}
343          </Box>
344        )}
345      </Box>
346    )
347  })
348}
349
types/index.d.ts 75 lines
1// The response of the Clayfold server's GET /api/system (SystemView in shared/api.ts).
2
3export type ClaudeInstance = {
4  pid: number
5  kind: 'onboard' | 'lesson' | 'tutor' | 'review' | 'critic' | 'grading'
6  model: string
7  startedAt: string
8  conversationId: string | null
9  topicId: string | null
10  topicTitle: string | null
11  lessonId: string | null
12  /** Oldest first; the last one is current. */
13  activities: string[]
14  queued: number
15  rssMb: number | null
16  cpuPercent: number | null
17}
18
19export type SystemView = {
20  backend: {
21    pid: number
22    startedAt: string
23    bun: string
24    port: number
25    rssMb: number
26    heapMb: number
27    cpuPercent: number
28    dbMb: number
29    model: string
30    criticModel: string
31    maxBudgetUsd: number
32  }
33  frontend: { builtAt: string | null; streams: number }
34  instances: ClaudeInstance[]
35  /** Newest first. */
36  finished: FinishedRun[]
37}
38
39export type FinishedRun = {
40  conversationId: string
41  kind: 'onboard' | 'lesson' | 'tutor' | 'review'
42  topicId: string
43  topicTitle: string
44  lessonId: string | null
45  startedAt: string
46  finishedAt: string
47  costUsd: number | null
48  error: string | null
49  cancelled: boolean
50}
51
52export type Snapshot = {
53  /** Clock time of the poll, in milliseconds. */
54  at: number
55  /** Null when the server did not answer. */
56  system: SystemView | null
57  error: string | null
58  viteUp: boolean
59}
60
61/** Backend samples for the sparklines, oldest first. */
62export type Samples = { cpu: number[]; rssMb: number[] }
63
64declare module 'claude-code' {
65  interface PluginState {
66    'clayfold-monitor': {
67      snapshot: Snapshot | null
68      autoOpened: boolean
69      samples: Samples
70      /** finishedAt of the newest finished run already announced; null until the first answer. */
71      notifiedUntil: string | null
72    }
73  }
74}
75