SLOPSHOPPER

devdeck

Mirrors a running Devdeck dev dashboard: service dots and the latest reload or Convex call above the prompt, /devdeck for the full pane, devdeck_status to read…

newpanebandguardcommandtool
v0.3.0MITupdated 2026-10-12michaelezehi/nw-skills/mods/devdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devdeck
│ ┃ Devdeck ✕ › fix the failing auth test and add an audit log call │ ┃ Devdeck is not running in this project. │ ⏺ 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 │ │ › /devdeck │ ⎿ devdeck: Devdeck pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Devdeck
Devdeck is not running in this project.
README

NW skills

New Ward skills: agent skills and slash commands for Claude Code that I use on every project.

Most are my own. Some started as other people's skills and now carry my changes, mostly an unlazy completion gate and plain-writing rules wired in. Each one is marked in the tables below.

Install

Claude Code plugin

claude plugin marketplace add michaelezehi/nw-skills
claude plugin install nw-skills@nw-skills

Plugin skills and commands are namespaced, so /research becomes /nw-skills:research.

skills.sh

npx skills@latest add michaelezehi/nw-skills

This copies editable skill folders into your project or ~/.claude/skills.

By hand

Copy any folder under skills/<category>/ into ~/.claude/skills/, and any file under commands/ into ~/.claude/commands/.

Several skills point at ~/.claude/skills/unlazy/scripts/gate-check.mjs and ~/.claude/skills/unslop/SKILL.md. Install unlazy and unslop first if you take only a few.

Skills (38)

Engineering

SkillWhat it doesOrigin
app-user-journeyScan a codebase for every user role and map each one's real journey, registration, onboarding, the day-one surface, and a proposed leaner path, then ship it as an...Original
build-speedAudit and fix any project's build + deploy pipeline for speed, kill cache-busting, move builds off the deploy box into CI, wire registry pull mode, and prove every...Original
convex-backup-cronInstall a 4x-daily production backup cron for Convex + DigitalOcean Spaces projects.Original
ogInstall a dynamic Open Graph / Twitter social card route into a Next.js project.Original
own-goalRun a goal or a whole PRD to completion: a verifiable completion condition worked in a loop until met, with an accountability record and a /reviewer gate.Original
project-optimisationFind and fix why a JS/TS monorepo is slow, from dev server memory to first-load JS per route: measure, fan out by cause, fix in parallel, re-measure, ship through staging.Original
qa-casesGenerate a shareable QA test case .md document for the current project.Original
terminal-upgradeTurn pnpm dev / npm run dev / yarn dev / bun dev in every repo on the machine into Devdeck, a calm live terminal dashboard.Original
ts-upgradeMove a repo's type checking to TypeScript 7, the native compiler, without breaking ESLint, tsup, Next, Expo, Remotion, Convex, CI or Docker builds, and measure before and after.Original
unlazyAnti-laziness execution discipline, required on every task sized L or XL (the /prd-ch scale) and run on request for anything smaller.Modified from Leonxlnx/unlazy
vercel-react-best-practicesReact and Next.js performance optimization guidelines from Vercel Engineering.Modified from vercel-labs/agent-skills
walkthroughAdd a guided walkthrough to any web app, one story per user role: a story bar with arrow keys, a journey reader with "show me how to get there", split prompt cards, and an optional demo tier...Original

Design and frontend

SkillWhat it doesOrigin
apple-designApple's approach to interface design and fluid, physical motion, translated for the web.Modified from emilkowalski/skill
brandkitPremium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations.Modified from Leonxlnx/taste-skill
click-burstAdds a performant click-triggered full-viewport color burst transition (scale + opacity only, no animated blur).Original
design-taste-frontendAnti-slop frontend rules for LANDING PAGES, PORTFOLIOS, and MARKETING REDESIGNS: reads the brief, infers direction, ships interfaces that do not look templated, with...Modified from Leonxlnx/taste-skill
emil-design-engEmil Kowalski's design-engineering philosophy, animation decisions, springs, gestures, component feel, and the invisible details that make software feel great.Modified from emilkowalski/skill
fedAlias for frontend-design.Original
find-animation-opportunitiesSearch a codebase or UI for places that don't animate but should, and reject everything that shouldn't.Modified from emilkowalski/skill
frontend-designThe main frontend skill. Distinctive, production-grade UI with high craft in typography, layout, colour and motion.Original
iconizeExtract individual icons from sprite sheets into named, square PNG files organized by category.Original
image-to-codeAlias for imagegen-frontend-web (image-first web flow: generate section images, then code them).Modified from Leonxlnx/taste-skill
imagegen-frontend-mobilePremium mobile app screen concept images, iOS, Android, and cross-platform screens and multi-screen flows (onboarding, auth, home, profile, settings, chat, commerce,...Modified from Leonxlnx/taste-skill
imagegen-frontend-webThe image-first WEB flow, generate one premium horizontal reference image per section (hero, landing page, marketing site, product page, portfolio), deeply analyze...Modified from Leonxlnx/taste-skill
impeccableUse when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise...Modified from pbakaus/impeccable
improve-animationsSurvey a codebase's animation and motion code as a senior motion advisor, then produce a prioritized audit and self-contained implementation plans for other agents...Modified from emilkowalski/skill
industrial-brutalist-uiOPT-IN STYLE PRESET, use ONLY when the user explicitly asks for brutalist / industrial / Swiss-print / terminal aesthetics, or names this skill.Modified from Leonxlnx/taste-skill
minimalist-uiOPT-IN STYLE PRESET, use ONLY when the user explicitly asks for a minimalist / Notion-like / warm-monochrome editorial look, or names this skill.Modified from Leonxlnx/taste-skill
pick-ui-libraryPick the right library for a given frontend task from a curated, opinionated list, numbers, OTP inputs, charts, command menus, virtualization, drag and drop, toasts,...Modified from emilkowalski/skill
prototypeBuild multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one that...Modified from emilkowalski/skill
redesign-existing-projectsUpgrades existing websites and apps to premium quality.Modified from Leonxlnx/taste-skill
review-animationsReviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy.Modified from emilkowalski/skill
sketchesHand-drawn SVG sketch/doodle system with a growing, brand-themeable repertoire.Original
stitch-design-tasteSemantic Design System Skill for Google Stitch.Modified from Leonxlnx/taste-skill
the-broadsideInstall the broadside editorial slide-deck design system into the current React/Next.js project.Original
the-stripe-modalInstall the editorial-press design system (surface card + slide-in modal flow + hero empty state) into the current React/Next.js project.Original
ui-ux-pro-maxUI/UX design intelligence for web and mobile.Modified from nextlevelbuilder/ui-ux-pro-max-skill

Writing

SkillWhat it doesOrigin
humanizerRewrite AI-sounding text so it reads like the writer without changing what it says.Modified from blader/humanizer
rndCreate a shareable internal R&D page from a team brief, a votable consensus spec with a participant intro gate (--consensus), a screen-by-screen product build deck...Original
unslopCut AI tells from any writing.Original
writing-clearly-and-conciselyStrunk companion for prose humans will read.Modified from softaworks/agent-toolkit

Commands

CommandWhat it does
/cfForce-continue a context-exhausted session.
/chHandoff continuity mode: numbered task list, accountability record with every question and decision, optional HTML summary.
/fd-chFrontend design run with reconnaissance, a quality gate and an accountability record, built on the frontend-design skill.
/prRead every PR review comment, fix what is actionable, and reply to each one.
/prd-chCreate a PRD fast and well.
/researchDeep research on any topic.
/reviewerFull audit and fix pass on the current task's changes, using any review skills installed on your machine (never Greptile or CodeRabbit).

Mods

Claude Code mods (plugins with function hooks) in mods/. Each one installs on its own from this marketplace:

claude plugin marketplace add michaelezehi/nw-skills
claude plugin install savvy-progress@nw-skills
ModWhat it doesOrigin
savvy-progressProgress bar above the prompt and an agents panel (/agents-info) with each subagent's model, context, cost and time. Draws in 24-bit colour in any truecolor terminal (Warp, iTerm2, Ghostty, WezTerm, kitty) as well as on the desktop.Modified from johnnyvizz/claude-kit
agent-barTurns the savvy-progress bar on for every task: todos, tasks and subagent launches become its rows. Steps aside when a skill reports its own plan. Needs savvy-progress.Original
guard-railsDenies git commands that sweep other agents' work (git add -A, commit -a, stash, reset, clean, restore, checkout) and dashes in commit or PR text.Original
session-pane/session-pane opens a pane with the files changed this session as a tree and the latest 50 tool calls.Original
turn-chimeA toast and a chime when a turn that ran a minute or longer finishes.Original

The skills and commands report to the progress panel by the rules in references/progress-panel.md. Copy it to ~/.claude/references/. Without savvy-progress installed they skip it.

Handoff hooks (optional)

/ch, /fd-ch and /prd-ch hand work over to a fresh session when context runs low. That needs three hooks in hooks/:

  • context-monitor.sh warns at about 70 and 90 percent context use.
  • precompact.sh writes .claude/handoffs/current.md (todos and modified files) before compaction.
  • session-start.sh feeds that handoff back in when the next session starts.

They are not switched on by installing the plugin. To use them, copy the scripts to ~/.claude/hooks/ and add this to ~/.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/context-monitor.sh" }] }],
    "PreCompact": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/precompact.sh" }] }],
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/session-start.sh" }] }]
  }
}

Without them the commands still run; you just lose the automatic handoff.

Licence

My own skills and commands are MIT, see LICENSE. Modified skills keep their upstream licence and credit; see NOTICE.md.

Source 10 files
hooks/register.tsx 357 lines
1// devdeck: a small live mirror of the Devdeck dev dashboard. Polls
2// <repo>/.devdeck/devdeck.sock, draws one line above the prompt, opens a pane
3// on /devdeck, and answers the devdeck_status tool. Every function that
4// takes `$` lives in this file: the engine follows `$` across no import.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, Timer } from 'claude-code'
7
8import type { Snap } from '../types'
9
10import { RESTART_URL, RUN_URL, refusedText, resultText, waitSeconds, waitText, waitUrl } from './actions'
11import { band } from './band'
12import { ANIMATE_MS, activityBetween, isFresh, parseErrors, seenOf, trimSnapshot } from './model'
13import type { Seen } from './model'
14import { pane } from './pane'
15import { DEFAULT_PALETTE, THEME_MS, parsePalette, samePalette, themePath } from './palette'
16import { ERRORS_URL, FETCH_TIMEOUT_MS, FRAME_MS, POLL_MS, RETRY_MS, STATE_URL, ancestors, socketIn } from './socket'
17import { NOT_RUNNING, summaryText } from './summary'
18
19const PANE = 'devdeck'
20const TOOL = 'mcp__devdeck__devdeck_status'
21const RUN_TOOL = 'mcp__devdeck__devdeck_run'
22const RESTART_TOOL = 'mcp__devdeck__devdeck_restart'
23const WAIT_TOOL = 'mcp__devdeck__devdeck_wait'
24
25const snapAtom = atom({ plugin: 'devdeck', key: 'snap' } as const, null)
26const expandedAtom = atom({ plugin: 'devdeck', key: 'expanded' } as const, [])
27const clockAtom = atom({ plugin: 'devdeck', key: 'clock' } as const, 0)
28const frameAtom = atom({ plugin: 'devdeck', key: 'frame' } as const, 0)
29const activityAtom = atom({ plugin: 'devdeck', key: 'activity' } as const, null)
30const paletteAtom = atom({ plugin: 'devdeck', key: 'palette' } as const, DEFAULT_PALETTE)
31
32/** The theme file's text, or null when there is no home to find it in or it cannot be read. */
33async function themeText($: EngineInterface): Promise<string | null> {
34  const xdg = await $.env.get('XDG_CONFIG_HOME').catch(() => undefined)
35  const home = await $.env.get('HOME').catch(() => undefined)
36  const path = themePath(xdg, home)
37  if (path === null) return null
38  return $.fs.read(path).then(
39    out => (typeof out === 'string' ? out : null),
40    () => null,
41  )
42}
43
44/** The nearest Devdeck socket at or above `cwd`, or null. */
45async function findSocket($: EngineInterface, cwd: string): Promise<string | null> {
46  if (!cwd.startsWith('/')) return null
47  for (const dir of ancestors(cwd)) {
48    const path = socketIn(dir)
49    const isThere = await $.fs.exists(path).catch(() => false)
50    if (isThere) return path
51  }
52  return null
53}
54
55/**
56 * GET over the socket: the body on a 2xx, else null. Never rejects, and gives up after
57 * FETCH_TIMEOUT_MS: a Devdeck that accepts the connection and never answers must not
58 * hold the poller busy, or the tool call, for good.
59 */
60async function getText($: EngineInterface, url: string, socketPath: string): Promise<string | null> {
61  const res = await ask($, url, socketPath)
62  return res !== null && res.status >= 200 && res.status < 300 ? res.text : null
63}
64
65/** Any request over the socket, with its status; null when it failed or ran past `timeoutMs`. */
66async function ask(
67  $: EngineInterface,
68  url: string,
69  socketPath: string,
70  init: { method?: string; body?: string } = {},
71  timeoutMs = FETCH_TIMEOUT_MS,
72): Promise<{ status: number; text: string } | null> {
73  let giveUp: (value: null) => void = () => undefined
74  const timedOut = new Promise<null>(resolve => {
75    giveUp = resolve
76  })
77  const timer = $.clock.after(timeoutMs, () => giveUp(null))
78  const headers = init.body === undefined ? undefined : { 'content-type': 'application/json' }
79  const fetched = $.http.fetch(url, { ...init, ...(headers ? { headers } : {}), socketPath }).then(
80    res => ({ status: res.status, text: res.text }),
81    () => null,
82  )
83  try {
84    return await Promise.race([fetched, timedOut])
85  } finally {
86    timer.cancel()
87  }
88}
89
90/** Wait on a card over the socket: the tool's answer text. */
91async function waitOn($: EngineInterface, socket: string, id: string, until: 'exit' | 'ready', seconds: number): Promise<string> {
92  // A little past the socket's own deadline, so the socket answers "timeout" itself.
93  const res = await ask($, waitUrl(id, until, seconds * 1000), socket, {}, seconds * 1000 + 5000)
94  if (res === null) return `Devdeck did not answer while waiting on ${id}.`
95  return res.status === 404 ? `Devdeck has no card "${id}".` : waitText(res.text)
96}
97
98/** The answer for devdeck_run, devdeck_restart and devdeck_wait. */
99async function actionText($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<string> {
100  const cwd = await $.session.cwd().catch(() => '')
101  const socket = await findSocket($, cwd)
102  if (socket === null) return `${NOT_RUNNING} Start it with pnpm dev in a terminal first.`
103  const seconds = waitSeconds(args.timeoutSec)
104  const wait = args.wait !== false
105
106  if (tool === RUN_TOOL) {
107    const script = typeof args.script === 'string' ? args.script : ''
108    const res = await ask($, RUN_URL, socket, { method: 'POST', body: JSON.stringify({ script }) })
109    if (res === null) return 'Devdeck did not answer POST /run.'
110    if (res.status !== 200) return refusedText(res.text)
111    if (!wait) return resultText(res.text)
112    const id = (JSON.parse(res.text) as { id: string }).id
113    return waitOn($, socket, id, 'exit', seconds)
114  }
115  if (tool === RESTART_TOOL) {
116    const id = typeof args.id === 'string' ? args.id : ''
117    const res = await ask($, RESTART_URL, socket, { method: 'POST', body: JSON.stringify({ id }) })
118    if (res === null) return 'Devdeck did not answer POST /restart.'
119    if (res.status !== 200) return refusedText(res.text)
120    return wait ? waitOn($, socket, id, 'ready', seconds) : `Restarted ${id}.`
121  }
122  const id = typeof args.id === 'string' ? args.id : ''
123  return waitOn($, socket, id, args.until === 'ready' ? 'ready' : 'exit', seconds)
124}
125
126/** The tool's answer, read fresh so it works whether or not the poller has run. */
127async function statusText($: EngineInterface): Promise<string> {
128  const cwd = await $.session.cwd().catch(() => '')
129  const socket = await findSocket($, cwd)
130  if (socket === null) return NOT_RUNNING
131
132  const clock = await $.clock.now().catch(() => 0)
133  const text = await getText($, STATE_URL, socket)
134  const snap = text === null ? null : trimSnapshot(text, clock)
135  if (snap === null) return `Devdeck's socket is at ${socket} but it did not answer GET /state.`
136
137  const full = await getText($, ERRORS_URL, socket)
138  const errors = (full === null ? null : parseErrors(full)) ?? snap.errors
139  return summaryText(snap, errors, socket, clock)
140}
141
142/**
143 * One module load's loop: a 1s tick that keeps the band's clock, polls when
144 * due (1s while Devdeck answers, 10s while it does not), reads the theme file
145 * on the first tick and then every 10s, and runs the sprite for 2s after
146 * something happens. Returns the stop.
147 */
148function startPoller($: EngineInterface, cwd: string): () => void {
149  let socket: string | null = null
150  let nextAt = 0
151  let themeAt = 0
152  let isBusy = false
153  let seen: Seen | null = null
154  let anim: Timer | null = null
155  // Set by the stop: a poll still in flight then writes nothing.
156  let isStopped = false
157
158  const now = (): Promise<number> => $.clock.now().catch(() => 0)
159
160  const animate = (startedAt: number): void => {
161    anim?.cancel()
162    let frame = 0
163    const timer = $.clock.every(FRAME_MS, () => {
164      frame += 1
165      void (async () => {
166        const at = await now()
167        // A tick that resolves after a newer animation started leaves that one alone.
168        if (anim !== timer) return timer.cancel()
169        if (at - startedAt >= ANIMATE_MS) {
170          timer.cancel()
171          anim = null
172        }
173        await update($, frameAtom, () => frame)
174      })().catch(() => undefined)
175    })
176    anim = timer
177  }
178
179  const fail = async (at: number): Promise<void> => {
180    socket = null
181    seen = null
182    nextAt = at + RETRY_MS
183    if ((await read($, snapAtom)) !== null) await update($, snapAtom, () => null)
184  }
185
186  const poll = async (at: number): Promise<void> => {
187    socket ??= await findSocket($, cwd)
188    if (socket === null) return fail(at)
189
190    const text = await getText($, STATE_URL, socket)
191    if (isStopped) return
192    const snap: Snap | null = text === null ? null : trimSnapshot(text, at)
193    if (snap === null) return fail(at)
194
195    const next = seenOf(snap)
196    const happened = seen === null ? null : activityBetween(seen, next, at)
197    seen = next
198    nextAt = at + POLL_MS
199    await update($, clockAtom, () => at)
200    await update($, snapAtom, () => snap)
201    if (happened) {
202      await update($, activityAtom, () => happened)
203      animate(at)
204    }
205  }
206
207  /** Stores the theme file's palette when it differs from the one drawn. */
208  const loadTheme = async (): Promise<void> => {
209    const palette = parsePalette(await themeText($))
210    if (isStopped || samePalette(palette, await read($, paletteAtom))) return
211    await update($, paletteAtom, () => palette)
212  }
213
214  const tick = async (): Promise<void> => {
215    const at = await now()
216    if (at >= themeAt) {
217      themeAt = at + THEME_MS
218      void loadTheme().catch(() => undefined)
219    }
220    if ((await read($, snapAtom)) !== null) await update($, clockAtom, () => at)
221    if (isBusy || at < nextAt) return
222    isBusy = true
223    try {
224      await poll(at)
225    } finally {
226      isBusy = false
227    }
228  }
229
230  const run = (): void => void tick().catch(() => undefined)
231  const timer = $.clock.every(POLL_MS, run)
232  run()
233
234  return () => {
235    isStopped = true
236    timer.cancel()
237    anim?.cancel()
238    anim = null
239  }
240}
241
242export const register: Register = on => {
243  // Module scope is fine: session.start runs again on every (re)load, and a hot reload
244  // cancels the old module's clock timers with its environment (see `$.clock` in the
245  // engine types), so the previous poller's tick and animation stop with it. The engine
246  // has no unload event to hook; `stop` covers a second session.start within one load.
247  let stop: (() => void) | null = null
248
249  on('session.start', async ($, e, next) => {
250    const started = await next(e)
251    try {
252      await $.command.register({
253        name: 'devdeck',
254        description: 'Show the Devdeck pane: services, Convex calls, events and errors',
255      })
256      await $.tool.register({
257        name: 'devdeck_status',
258        description:
259          'Read-only. Summarises the running Devdeck dev dashboard for this project: each service and its status, ' +
260          'recent Convex calls, and every open dev error in full (compile, type, runtime, Convex). ' +
261          'Use it to answer what is failing or erroring in local dev instead of reading logs. Takes no input.',
262        inputSchema: { type: 'object', properties: {} },
263      })
264      const timeoutSec = { type: 'number', description: 'Most seconds to wait. Default 120, at most 600.' }
265      await $.tool.register({
266        name: 'devdeck_run',
267        description:
268          'Runs a package.json script (build, test:ci, lint, ship) as a card in the running Devdeck, where the user sees its ' +
269          'progress and answers its prompts, then waits for it to end and returns the exit code, its errors and last 40 lines. ' +
270          'Prefer it to Bash for package scripts while Devdeck runs. Only script names from package.json are accepted.',
271        inputSchema: {
272          type: 'object',
273          properties: {
274            script: { type: 'string', description: 'The script name, as in package.json.' },
275            wait: { type: 'boolean', description: 'Wait for it to end. Default true.' },
276            timeoutSec,
277          },
278          required: ['script'],
279        },
280      })
281      await $.tool.register({
282        name: 'devdeck_restart',
283        description: 'Restarts a Devdeck service by id (see devdeck_status) and waits until it is ready or fails.',
284        inputSchema: {
285          type: 'object',
286          properties: { id: { type: 'string' }, wait: { type: 'boolean', description: 'Default true.' }, timeoutSec },
287          required: ['id'],
288        },
289      })
290      await $.tool.register({
291        name: 'devdeck_wait',
292        description: 'Waits on a Devdeck card (a script run or a service) until it exits, or is ready, or stops at a prompt.',
293        inputSchema: {
294          type: 'object',
295          properties: { id: { type: 'string' }, until: { type: 'string', enum: ['exit', 'ready'] }, timeoutSec },
296          required: ['id'],
297        },
298      })
299    } catch {
300      // A refused registration leaves the band working.
301    }
302    stop?.()
303    stop = startPoller($, e.cwd)
304    return started
305  })
306
307  on('command.run', { command: 'devdeck' }, async $ => {
308    const opened = await $.ui.open({ id: PANE, title: 'Devdeck' })
309    return { text: opened.isPlaced ? 'Devdeck pane opened.' : 'Devdeck pane opens once the terminal is wide enough.' }
310  }).catch(() => ({ text: 'Devdeck pane could not open.' }))
311
312  on('tool.call', { tool: TOOL }, async $ => ({ result: await statusText($) })).catch(() => ({
313    result: 'Devdeck status could not be read.',
314  }))
315
316  for (const tool of [RUN_TOOL, RESTART_TOOL, WAIT_TOOL]) {
317    on('tool.call', { tool }, async ($, e) => ({ result: await actionText($, tool, e as unknown as Record<string, unknown>) })).catch(() => ({
318      result: 'Devdeck could not be reached.',
319    }))
320  }
321
322  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
323    if (e.props.hasSurvey) return next(e)
324    const snap = await read($, snapAtom)
325    const clock = await read($, clockAtom)
326    if (!isFresh(snap, clock)) return next(e)
327
328    return band({
329      ui: $.ui.resolve(e),
330      Raster: e.surface === 'terminal' ? $.ui.resolve(e).Raster : null,
331      snap,
332      clock,
333      frame: await read($, frameAtom),
334      activity: await read($, activityAtom),
335      palette: await read($, paletteAtom),
336      columns: e.props.bodyColumns,
337    })
338  }).catch(($, e, next) => next(e))
339
340  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
341    const snap = await read($, snapAtom)
342    const clock = await read($, clockAtom)
343    return pane({
344      ui: $.ui.resolve(e),
345      snap: isFresh(snap, clock) ? snap : null,
346      clock,
347      columns: e.props.bodyColumns,
348      expanded: await read($, expandedAtom),
349      palette: await read($, paletteAtom),
350      toggle: id =>
351        void update($, expandedAtom, ids => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])).catch(
352          () => undefined,
353        ),
354    })
355  }).catch(($, e, next) => next(e))
356}
357
hooks/actions.ts 89 lines
1// The run, restart and wait tools: their socket routes and the text Claude reads back.
2// Pure, so every answer shape is tested without an engine.
3
4export const RUN_URL = 'http://devdeck/run'
5export const RESTART_URL = 'http://devdeck/restart'
6export const waitUrl = (id: string, until: 'exit' | 'ready', timeoutMs: number): string =>
7  `http://devdeck/wait?id=${encodeURIComponent(id)}&until=${until}&timeoutMs=${timeoutMs}`
8
9/** How long a tool waits by default, and at most; the socket caps its own wait at 10 min. */
10export const DEFAULT_WAIT_S = 120
11export const MAX_WAIT_S = 600
12
13/** Seconds from a tool argument, clamped; the default when it is missing or not a number. */
14export function waitSeconds(raw: unknown): number {
15  const n = typeof raw === 'number' ? raw : Number(raw)
16  return Number.isFinite(n) && n > 0 ? Math.min(MAX_WAIT_S, Math.round(n)) : DEFAULT_WAIT_S
17}
18
19interface Prompt {
20  kind: string
21  question: string
22  options?: string[]
23}
24
25interface Result {
26  id: string
27  label: string
28  status: string
29  running: boolean
30  exitCode?: number | null
31  ranMs?: number
32  progress?: { fraction?: number; label: string }
33  prompt?: Prompt
34  errors: { title: string; body: string[]; file?: string }[]
35  tail: string[]
36}
37
38const secs = (ms: number | undefined): string => (ms === undefined ? '' : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
39
40function promptLine(label: string, p: Prompt): string {
41  const choices = p.kind === 'confirm' ? ' (yes/no)' : p.options ? ` (one of: ${p.options.join(', ')})` : p.kind === 'secret' ? ' (a password)' : ''
42  return `${label} is waiting for input: "${p.question}"${choices}. Only a person can answer it, in the Devdeck terminal: ask the user to answer there, then call devdeck_wait.`
43}
44
45/** The tool's answer for one result, with why the wait ended when there was one. */
46export function resultText(raw: string, reason?: string): string {
47  let r: Result
48  try {
49    r = JSON.parse(raw) as Result
50  } catch {
51    return 'Devdeck answered with something that is not a result.'
52  }
53  const lines: string[] = []
54  if (r.prompt) lines.push(promptLine(r.label, r.prompt))
55  else if (reason === 'timeout') lines.push(`${r.label} is still running after the wait (${secs(r.ranMs)} so far). Call devdeck_wait again to keep waiting.`)
56  else if (!r.running) lines.push(`${r.label} ${r.exitCode === 0 ? 'finished' : `failed with exit code ${r.exitCode ?? '?'}`} in ${secs(r.ranMs)}.`)
57  else if (reason === 'ready') lines.push(`${r.label} is ${r.status}.`)
58  else lines.push(`${r.label} is ${r.status}${r.ranMs !== undefined ? `, running ${secs(r.ranMs)}` : ''}. Its card is id "${r.id}".`)
59  if (r.progress) lines.push(`Progress: ${r.progress.fraction !== undefined ? `${Math.round(r.progress.fraction * 100)}% ` : ''}${r.progress.label}`)
60  if (r.errors.length) {
61    lines.push('', `Errors (${r.errors.length}):`)
62    for (const e of r.errors) lines.push(`${e.title}${e.file ? ` at ${e.file}` : ''}`, ...e.body.filter(b => b !== e.title).map(b => `    ${b}`))
63  }
64  if (r.tail.length) lines.push('', `Last ${r.tail.length} lines:`, ...r.tail)
65  return lines.join('\n')
66}
67
68/** The wait route's answer: `{ reason, result }`. */
69export function waitText(raw: string): string {
70  try {
71    const { reason, result } = JSON.parse(raw) as { reason?: string; result?: unknown }
72    if (!result) return 'Devdeck has no card with that id.'
73    return resultText(JSON.stringify(result), reason)
74  } catch {
75    return 'Devdeck answered with something that is not a result.'
76  }
77}
78
79/** A 404 from /run or /restart, naming what does exist. */
80export function refusedText(raw: string): string {
81  try {
82    const { error, scripts, services } = JSON.parse(raw) as { error?: string; scripts?: string[]; services?: string[] }
83    const known = scripts ? ` Scripts: ${scripts.join(', ')}.` : services ? ` Services: ${services.join(', ')}.` : ''
84    return `${error ?? 'Devdeck refused it.'}.${known}`.replace('..', '.')
85  } catch {
86    return 'Devdeck refused it.'
87  }
88}
89
hooks/band.tsx 103 lines
1// The one-line band above the prompt: sprite, DEV, a dot per service, the
2// latest reload or Convex call, the open error count, total memory.
3import type { Elements } from 'claude-code'
4
5import type { Activity, Palette, Snap } from '../types'
6
7import { ANIMATE_MS, DIAMOND, DOT, bandServices, fmtMem, latestOf, statusColor } from './model'
8import { SPRITE_COLS, spriteCells } from './sprite'
9
10type Table = Elements['terminal'] | Elements['desktop'] | Elements['vscode'] | Elements['mobile']
11
12export type BandInput = {
13  ui: Table
14  /** The terminal's Raster, null on a surface that has none. */
15  Raster: Elements['terminal']['Raster'] | null
16  snap: Snap
17  clock: number
18  frame: number
19  activity: Activity | null
20  palette: Palette
21  columns: number
22}
23
24const LABEL = 'DEV'
25const MIN_ACTIVITY = 8
26
27export function band(p: BandInput): JSX.Element {
28  const { Box, Text } = p.ui
29  const colors = p.palette
30  const services = bandServices(p.snap)
31  const latest = latestOf(p.snap, p.clock, colors)
32  const errors = p.snap.errorCount
33  const errText = `${DIAMOND} ${errors}`
34  const mem = fmtMem(p.snap.memMb)
35
36  // Fixed pieces in the order they give way when the line is too narrow.
37  let showMem = true
38  let showSprite = p.Raster !== null
39  const dotsW = Math.max(1, services.length * 2 - 1)
40  const fixed = (): number => {
41    const widths = [LABEL.length, dotsW]
42    if (showSprite) widths.push(SPRITE_COLS)
43    if (errors > 0) widths.push(errText.length)
44    if (showMem) widths.push(mem.length)
45    return widths.reduce((a, b) => a + b, 0) + widths.length - 1
46  }
47  let room = p.columns - fixed() - 1
48  if (room < MIN_ACTIVITY && showMem) showMem = false
49  room = p.columns - fixed() - 1
50  if (room < MIN_ACTIVITY && showSprite) showSprite = false
51  room = p.columns - fixed() - 1
52  const showActivity = latest !== null && room >= MIN_ACTIVITY
53
54  const isAnimating = p.activity !== null && p.clock - p.activity.at < ANIMATE_MS
55  const Raster = p.Raster
56  const sprite =
57    showSprite && Raster ? (
58      <Raster
59        key="devdeck-sprite"
60        columns={SPRITE_COLS}
61        rows={1}
62        cells={spriteCells(p.activity?.kind ?? null, isAnimating ? p.frame : 0, colors)}
63      />
64    ) : null
65
66  return (
67    <Box key="devdeck-band" flexDirection="row" gap={1} overflow="hidden">
68      {sprite}
69      <Box flexShrink={0}>
70        <Text bold color={colors.accent}>
71          {LABEL}
72        </Text>
73      </Box>
74      <Box key="devdeck-dots" flexDirection="row" gap={1} flexShrink={0}>
75        {services.map(s => (
76          <Box key={`dot-${s.id}`}>
77            <Text color={statusColor(s.status, colors)} dimColor={s.status === 'stopped'}>
78              {DOT}
79            </Text>
80          </Box>
81        ))}
82      </Box>
83      {showActivity && latest && (
84        <Box key="devdeck-latest" width={Math.min(room, latest.text.length)} flexShrink={1}>
85          <Text color={latest.color} wrap="truncate-end">
86            {latest.text}
87          </Text>
88        </Box>
89      )}
90      {errors > 0 && (
91        <Box key="devdeck-errors" flexShrink={0}>
92          <Text color={colors.error}>{errText}</Text>
93        </Box>
94      )}
95      {showMem && (
96        <Box key="devdeck-mem" flexShrink={0}>
97          <Text color={colors.muted}>{mem}</Text>
98        </Box>
99      )}
100    </Box>
101  )
102}
103
hooks/model.ts 175 lines
1// Pure helpers over the trimmed snapshot.
2import type { Activity, Call, Err, Ev, Palette, ServiceStatus, Snap, Svc } from '../types'
3
4/** The reload marker: a text glyph, since U+26A1 draws as an emoji. */
5export const BOLT = '↯'
6export const DOT = '●'
7export const DIAMOND = '◆'
8
9export const FRESH_MS = 5000
10export const RELOAD_SHOWN_MS = 10_000
11export const ANIMATE_MS = 2000
12
13const MAX_EVENTS = 8
14const MAX_CALLS = 8
15const MAX_ERRORS = 30
16const MAX_BODY = 80
17
18export function statusColor(status: ServiceStatus, palette: Palette): string {
19  if (status === 'ready') return palette.accent
20  if (status === 'compiling' || status === 'starting') return palette.activity
21  if (status === 'error' || status === 'crashed') return palette.error
22  return palette.muted
23}
24
25export const isFresh = (snap: Snap | null, clock: number): snap is Snap =>
26  snap !== null && clock - snap.fetchedAt < FRESH_MS
27
28/** How long ago a Devdeck timestamp was, on the session's clock. */
29export const ageOf = (snap: Snap, at: number, clock: number): number =>
30  Math.max(0, snap.now - at + (clock - snap.fetchedAt))
31
32/** The services the band shows: every non-watch service, plus a watch in error. */
33export const bandServices = (snap: Snap): Svc[] =>
34  snap.services.filter(s => s.kind !== 'watch' || s.status === 'error' || s.status === 'crashed')
35
36const BADGE: Record<string, string> = { Query: 'Q', Mutation: 'M', Action: 'A', HttpAction: 'H' }
37export const badgeOf = (type: string): string => BADGE[type] ?? '?'
38
39export function fmtMem(mb: number): string {
40  if (mb >= 1024) return `${(mb / 1024).toFixed(1)}G`
41  return `${Math.round(mb)}M`
42}
43
44export function fmtAge(ms: number): string {
45  const s = Math.floor(ms / 1000)
46  if (s < 60) return `${s}s`
47  const m = Math.floor(s / 60)
48  if (m < 60) return `${m}m`
49  return `${Math.floor(m / 60)}h`
50}
51
52export const fmtMs = (ms: number | null): string => (ms === null ? '' : `${Math.round(ms)}ms`)
53
54export type Latest = { text: string; color: string; isFailed: boolean }
55
56/** The band's activity text: a reload within 10s or the latest call, whichever is newer. */
57export function latestOf(snap: Snap, clock: number, palette: Palette): Latest | null {
58  let reload: { svc: Svc; at: number } | null = null
59  for (const svc of snap.services) {
60    if (svc.reloadAt === null) continue
61    if (ageOf(snap, svc.reloadAt, clock) > RELOAD_SHOWN_MS) continue
62    if (reload === null || svc.reloadAt > reload.at) reload = { svc, at: svc.reloadAt }
63  }
64  const call = snap.calls[snap.calls.length - 1] ?? null
65
66  if (reload && (call === null || reload.at >= call.at)) {
67    const parts = [BOLT, reload.svc.id, reload.svc.reloadRoute ?? '', fmtMs(reload.svc.reloadMs)]
68    return { text: parts.filter(Boolean).join(' '), color: palette.activity, isFailed: false }
69  }
70  if (call) {
71    const text = `${badgeOf(call.type)} ${call.fn} ${fmtMs(call.ms)}`
72    return { text, color: call.ok ? palette.text : palette.error, isFailed: !call.ok }
73  }
74  return null
75}
76
77// --- turning the wire snapshot into the trimmed one, defensively.
78
79type Raw = Record<string, unknown>
80const isObj = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
81const num = (v: unknown, d = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
82const numOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
83const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : d)
84const strOrNull = (v: unknown): string | null => (typeof v === 'string' ? v : null)
85const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(isObj) : [])
86
87const STATUSES: ServiceStatus[] = ['starting', 'ready', 'compiling', 'error', 'stopped', 'crashed']
88const statusOf = (v: unknown): ServiceStatus => (STATUSES.includes(v as ServiceStatus) ? (v as ServiceStatus) : 'stopped')
89
90function svcOf(r: Raw): Svc {
91  const reload = isObj(r.lastReload) ? r.lastReload : null
92  return {
93    id: str(r.id),
94    label: str(r.label, str(r.id)),
95    kind: str(r.kind),
96    port: numOrNull(r.port),
97    status: statusOf(r.status),
98    memMb: num(r.memMb),
99    reloadAt: reload ? numOrNull(reload.at) : null,
100    reloadRoute: reload ? strOrNull(reload.route) : null,
101    reloadMs: reload ? numOrNull(reload.ms) : null,
102  }
103}
104
105const evOf = (r: Raw): Ev => ({
106  id: num(r.id), at: num(r.at), service: str(r.service), kind: str(r.kind), text: str(r.text), ms: numOrNull(r.ms),
107})
108
109const callOf = (r: Raw): Call => ({
110  id: num(r.id), at: num(r.at), type: str(r.type), fn: str(r.fn), ms: num(r.ms), ok: r.ok !== false, error: strOrNull(r.error),
111})
112
113export const errOf = (r: Raw, maxBody = MAX_BODY): Err => ({
114  id: num(r.id),
115  at: num(r.at),
116  service: str(r.service),
117  title: str(r.title),
118  body: (Array.isArray(r.body) ? r.body : []).map(line => String(line)).slice(0, maxBody),
119  file: strOrNull(r.file),
120})
121
122/** Parses GET /state. Null when the text is not a version 1 DeckSnapshot. */
123export function trimSnapshot(text: string, fetchedAt: number): Snap | null {
124  let raw: unknown
125  try {
126    raw = JSON.parse(text)
127  } catch {
128    return null
129  }
130  if (!isObj(raw) || raw.version !== 1 || !Array.isArray(raw.services)) return null
131  const meta = isObj(raw.meta) ? raw.meta : {}
132  const totals = isObj(raw.totals) ? raw.totals : {}
133  return {
134    fetchedAt,
135    now: num(raw.now, fetchedAt),
136    root: str(meta.root),
137    services: list(raw.services).map(svcOf),
138    events: list(raw.events).slice(-MAX_EVENTS).map(evOf),
139    calls: list(raw.calls).slice(-MAX_CALLS).map(callOf),
140    errors: list(raw.errors).slice(-MAX_ERRORS).map(r => errOf(r)),
141    // Every open error; the list above keeps only the latest few.
142    errorCount: num(totals.errors, list(raw.errors).length),
143    memMb: num(totals.memMb),
144  }
145}
146
147/** Parses GET /errors: a bare array, or an object holding `errors`. */
148export function parseErrors(text: string): Err[] | null {
149  try {
150    const raw: unknown = JSON.parse(text)
151    const rows = Array.isArray(raw) ? raw : isObj(raw) && Array.isArray(raw.errors) ? raw.errors : null
152    return rows === null ? null : rows.filter(isObj).map(r => errOf(r, Number.MAX_SAFE_INTEGER))
153  } catch {
154    return null
155  }
156}
157
158export type Seen = { reloadAt: number; callId: number; errorId: number }
159
160export function seenOf(snap: Snap): Seen {
161  return {
162    reloadAt: Math.max(0, ...snap.services.map(s => s.reloadAt ?? 0)),
163    callId: Math.max(0, ...snap.calls.filter(c => c.type === 'Mutation').map(c => c.id)),
164    errorId: Math.max(0, ...snap.errors.map(e => e.id)),
165  }
166}
167
168/** What happened between two polls, the loudest kind first. */
169export function activityBetween(prev: Seen, next: Seen, at: number): Activity | null {
170  if (next.errorId > prev.errorId) return { kind: 'error', at }
171  if (next.reloadAt > prev.reloadAt) return { kind: 'reload', at }
172  if (next.callId > prev.callId) return { kind: 'mutation', at }
173  return null
174}
175
hooks/pane.tsx 165 lines
1// The /devdeck pane: services, the last Convex calls and events, and the open
2// errors, each one line until pressed, then its full body.
3import type { Elements } from 'claude-code'
4
5import type { Call, Err, Ev, Palette, Snap, Svc } from '../types'
6
7import { DOT, ageOf, badgeOf, fmtAge, fmtMem, fmtMs, statusColor } from './model'
8
9type Table = Elements['terminal'] | Elements['desktop'] | Elements['vscode'] | Elements['mobile']
10
11export type PaneInput = {
12  ui: Table
13  snap: Snap | null
14  clock: number
15  columns: number
16  expanded: number[]
17  toggle: (id: number) => void
18  palette: Palette
19}
20
21export const NOT_RUNNING_PANE = 'Devdeck is not running in this project.'
22
23const BADGE_TOKEN: Record<string, keyof Palette> = { Q: 'text', M: 'accent', A: 'activity', H: 'muted' }
24
25const clip = (text: string, width: number): string =>
26  text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
27
28function section(p: PaneInput, key: string, title: string, rows: JSX.Element[], empty: string): JSX.Element {
29  const { Box, Text } = p.ui
30  return (
31    <Box key={key} flexDirection="column" marginBottom={1}>
32      <Text bold color={p.palette.text}>
33        {title}
34      </Text>
35      {rows.length ? rows : <Text color={p.palette.muted}>{empty}</Text>}
36    </Box>
37  )
38}
39
40function serviceRow(ui: Table, p: PaneInput, snap: Snap, s: Svc): JSX.Element {
41  const { Box, Text } = ui
42  const colors = p.palette
43  const reload =
44    s.reloadAt === null
45      ? ''
46      : [s.reloadRoute, fmtMs(s.reloadMs), `${fmtAge(ageOf(snap, s.reloadAt, p.clock))} ago`].filter(Boolean).join(' ')
47  return (
48    <Box key={`svc-${s.id}`} flexDirection="row" gap={1}>
49      <Text color={statusColor(s.status, colors)} dimColor={s.status === 'stopped'}>
50        {DOT}
51      </Text>
52      <Box width={12} flexShrink={0}>
53        <Text wrap="truncate-end">{s.label}</Text>
54      </Box>
55      <Box width={6} flexShrink={0}>
56        <Text color={colors.muted}>{s.port === null ? '' : `:${s.port}`}</Text>
57      </Box>
58      <Box width={9} flexShrink={0}>
59        <Text color={statusColor(s.status, colors)}>{s.status}</Text>
60      </Box>
61      <Box width={6} flexShrink={0}>
62        <Text color={colors.muted}>{fmtMem(s.memMb)}</Text>
63      </Box>
64      <Box flexShrink={1}>
65        <Text color={colors.muted} wrap="truncate-end">
66          {reload}
67        </Text>
68      </Box>
69    </Box>
70  )
71}
72
73function callRow(ui: Table, colors: Palette, c: Call): JSX.Element {
74  const { Box, Text } = ui
75  const badge = badgeOf(c.type)
76  const color = c.ok ? undefined : colors.error
77  return (
78    <Box key={`call-${c.id}`} flexDirection="row" gap={1}>
79      <Text bold color={c.ok ? colors[BADGE_TOKEN[badge] ?? 'text'] : colors.error}>
80        {badge}
81      </Text>
82      <Box flexShrink={1}>
83        <Text color={color} wrap="truncate-end">
84          {c.fn}
85          {c.ok || !c.error ? '' : ` · ${c.error}`}
86        </Text>
87      </Box>
88      <Box flexShrink={0}>
89        <Text color={color ?? colors.muted}>{fmtMs(c.ms)}</Text>
90      </Box>
91    </Box>
92  )
93}
94
95function eventRow(ui: Table, p: PaneInput, snap: Snap, ev: Ev): JSX.Element {
96  const { Box, Text } = ui
97  const colors = p.palette
98  const color = ev.kind === 'error' || ev.kind === 'exit' ? colors.error : ev.kind === 'reload' ? colors.activity : undefined
99  return (
100    <Box key={`ev-${ev.id}`} flexDirection="row" gap={1}>
101      <Box width={4} flexShrink={0}>
102        <Text color={colors.muted}>{fmtAge(ageOf(snap, ev.at, p.clock))}</Text>
103      </Box>
104      <Box width={8} flexShrink={0}>
105        <Text color={colors.muted} wrap="truncate-end">
106          {ev.service}
107        </Text>
108      </Box>
109      <Box flexShrink={1}>
110        <Text color={color} wrap="truncate-end">
111          {[ev.text, fmtMs(ev.ms)].filter(Boolean).join(' ')}
112        </Text>
113      </Box>
114    </Box>
115  )
116}
117
118function errorRow(ui: Table, p: PaneInput, snap: Snap, e: Err): JSX.Element {
119  const { Box, Text, Button } = ui
120  const colors = p.palette
121  const isOpen = p.expanded.includes(e.id)
122  const age = fmtAge(ageOf(snap, e.at, p.clock))
123  const label = clip(`${isOpen ? '▾' : '▸'} ${e.service} · ${e.title} · ${age}`, Math.max(10, p.columns - 2))
124  return (
125    <Box key={`errrow-${e.id}`} flexDirection="column">
126      <Button key={`err-${e.id}`} label={label} plain onPress={() => p.toggle(e.id)} />
127      {isOpen && (
128        <Box key={`errbody-${e.id}`} flexDirection="column" paddingLeft={2}>
129          {e.file && <Text color={colors.muted}>{e.file}</Text>}
130          {e.body.map((line, i) => (
131            <Text key={`errline-${e.id}-${i}`} color={colors.error}>
132              {line || ' '}
133            </Text>
134          ))}
135        </Box>
136      )}
137    </Box>
138  )
139}
140
141export function pane(p: PaneInput): JSX.Element {
142  const { Box, Text } = p.ui
143  const snap = p.snap
144  if (snap === null) {
145    return (
146      <Box key="devdeck-pane" flexDirection="column">
147        <Text color={p.palette.muted}>{NOT_RUNNING_PANE}</Text>
148      </Box>
149    )
150  }
151
152  const calls = [...snap.calls].reverse().map(c => callRow(p.ui, p.palette, c))
153  const events = [...snap.events].reverse().map(ev => eventRow(p.ui, p, snap, ev))
154  const errors = [...snap.errors].reverse().map(e => errorRow(p.ui, p, snap, e))
155
156  return (
157    <Box key="devdeck-pane" flexDirection="column">
158      {section(p, 'services', `Services · ${fmtMem(snap.memMb)}`, snap.services.map(s => serviceRow(p.ui, p, snap, s)), 'No services.')}
159      {section(p, 'calls', 'Convex calls', calls, 'No calls yet.')}
160      {section(p, 'events', 'Events', events, 'No events yet.')}
161      {section(p, 'errors', snap.errorCount ? `Errors · ${snap.errorCount}` : 'Errors', errors, 'No errors.')}
162    </Box>
163  )
164}
165
hooks/palette.ts 55 lines
1// The colours the band, pane and sprite draw with: the palette the person
2// picked in Devdeck, read from its theme.json, each token checked on its own.
3import type { Palette } from '../types'
4
5/** Devdeck's own brand palette, used for any token the theme file lacks. */
6export const DEFAULT_PALETTE: Palette = {
7  accent: '#fde047',
8  activity: '#f5a524',
9  text: '#ecebe4',
10  muted: '#8a887f',
11  line: '#3d3c38',
12  error: '#f87171',
13}
14
15const TOKENS = Object.keys(DEFAULT_PALETTE) as (keyof Palette)[]
16const HEX = /^#[0-9a-fA-F]{6}$/
17
18/** How often the poll loop reads the theme file again. */
19export const THEME_MS = 10_000
20
21const THEME_FILE = 'devdeck/theme.json'
22
23/**
24 * Where Devdeck saves the palette: `$XDG_CONFIG_HOME/devdeck/theme.json`, else
25 * `~/.config/devdeck/theme.json`. Null without an absolute home to start from.
26 */
27export function themePath(xdgConfigHome: string | undefined, home: string | undefined): string | null {
28  if (xdgConfigHome?.startsWith('/')) return `${xdgConfigHome.replace(/\/+$/, '')}/${THEME_FILE}`
29  if (home?.startsWith('/')) return `${home.replace(/\/+$/, '')}/.config/${THEME_FILE}`
30  return null
31}
32
33/** The palette in a theme file's text; null text, bad JSON or a bad token gives that token's default. */
34export function parsePalette(text: string | null): Palette {
35  let raw: unknown = null
36  try {
37    raw = text === null ? null : JSON.parse(text)
38  } catch {
39    raw = null
40  }
41  const colors = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>).colors : null
42  const given = typeof colors === 'object' && colors !== null ? (colors as Record<string, unknown>) : {}
43  const out = { ...DEFAULT_PALETTE }
44  for (const token of TOKENS) {
45    const value = given[token]
46    if (typeof value === 'string' && HEX.test(value)) out[token] = value.toLowerCase()
47  }
48  return out
49}
50
51export const samePalette = (a: Palette, b: Palette): boolean => TOKENS.every(token => a[token] === b[token])
52
53/** `#rrggbb` as the 0xRRGGBB number a Raster cell takes. */
54export const rgbOf = (hex: string): number => Number.parseInt(hex.slice(1), 16)
55
hooks/socket.ts 30 lines
1// Where Devdeck listens and how often the mod asks. The loop itself lives in
2// register.tsx, since the engine follows `$` only within one file.
3
4export const SOCKET = '.devdeck/devdeck.sock'
5export const STATE_URL = 'http://devdeck/state'
6export const ERRORS_URL = 'http://devdeck/errors'
7
8export const POLL_MS = 1000
9export const RETRY_MS = 10_000
10export const FRAME_MS = 250
11/** A GET that has not answered by then counts as a failed poll. */
12export const FETCH_TIMEOUT_MS = 1500
13const MAX_DEPTH = 24
14
15/** Every folder from `dir` up to the root, nearest first. */
16export function ancestors(dir: string): string[] {
17  const out: string[] = []
18  let at = dir.replace(/\/+$/, '') || '/'
19  for (let i = 0; i < MAX_DEPTH; i++) {
20    out.push(at)
21    if (at === '/') break
22    const cut = at.lastIndexOf('/')
23    at = cut <= 0 ? '/' : at.slice(0, cut)
24  }
25  return out
26}
27
28/** The socket path Devdeck would use with `dir` as the repo root. */
29export const socketIn = (dir: string): string => `${dir === '/' ? '' : dir}/${SOCKET}`
30
hooks/summary.ts 38 lines
1// The devdeck_status tool's text: a compact summary plus every error in full.
2import type { Err, Snap } from '../types'
3
4import { ageOf, badgeOf, fmtAge, fmtMem, fmtMs } from './model'
5
6export const NOT_RUNNING = 'Devdeck is not running in this project.'
7
8function errorBlock(snap: Snap, e: Err, clock: number): string {
9  const head = `[${e.service}] ${e.title} (${fmtAge(ageOf(snap, e.at, clock))} ago)${e.file ? ` at ${e.file}` : ''}`
10  const body = e.body.filter(line => line !== e.title)
11  return body.length ? `${head}\n${body.map(line => `    ${line}`).join('\n')}` : head
12}
13
14export function summaryText(snap: Snap, errors: Err[], socket: string, clock: number): string {
15  const lines: string[] = [`Devdeck at ${snap.root || socket}, ${fmtMem(snap.memMb)} total.`, '', 'Services:']
16
17  for (const s of snap.services) {
18    const port = s.port === null ? '' : ` :${s.port}`
19    const reload =
20      s.reloadAt === null
21        ? ''
22        : `, last reload ${[s.reloadRoute, fmtMs(s.reloadMs)].filter(Boolean).join(' ')} ${fmtAge(ageOf(snap, s.reloadAt, clock))} ago`
23    lines.push(`  ${s.id}${port} ${s.status}, ${fmtMem(s.memMb)}${reload}`)
24  }
25
26  if (snap.calls.length) {
27    lines.push('', 'Recent Convex calls:')
28    for (const c of snap.calls) {
29      const fail = c.ok ? '' : ` FAILED${c.error ? `: ${c.error}` : ''}`
30      lines.push(`  ${badgeOf(c.type)} ${c.fn} ${fmtMs(c.ms)}${fail}`)
31    }
32  }
33
34  lines.push('', errors.length ? `Errors (${errors.length}):` : 'Errors: none.')
35  for (const e of errors) lines.push(errorBlock(snap, e, clock), '')
36  return lines.join('\n').trimEnd()
37}
38
hooks/sprite.ts 74 lines
1// The band's six-cell sprite: a 6x2 pixel drawing packed into one row of half
2// blocks for a terminal Raster. Each kind has four frames; frame 0 is its rest.
3import type { ActivityKind, Palette } from '../types'
4
5import { rgbOf } from './palette'
6
7export const SPRITE_COLS = 6
8
9/** The terminal's own colour, not a palette one. */
10const DEFAULT = 0x01000000
11
12// '#' the kind's colour, '+' a text-coloured highlight, '.' empty; two rows each.
13const FRAMES: Record<ActivityKind, string[][]> = {
14  reload: [
15    ['..###.', '.###..'],
16    ['+.###.', '.###.+'],
17    ['..+##.', '.##+..'],
18    ['.+###.', '.###+.'],
19  ],
20  mutation: [
21    ['..#...', '......'],
22    ['.+#+..', '..+...'],
23    ['...#..', '.+...#'],
24    ['#....+', '...#..'],
25  ],
26  error: [
27    ['..##..', '..##..'],
28    ['.####.', '..##..'],
29    ['######', '.####.'],
30    ['.####.', '..##..'],
31  ],
32}
33
34const TOKEN: Record<ActivityKind, keyof Palette> = { reload: 'activity', mutation: 'accent', error: 'error' }
35
36const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
37
38function base64(bytes: Uint8Array): string {
39  let out = ''
40  for (let i = 0; i < bytes.length; i += 3) {
41    const a = bytes[i] ?? 0
42    const b = bytes[i + 1] ?? 0
43    const c = bytes[i + 2] ?? 0
44    const n = (a << 16) | (b << 8) | c
45    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
46    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
47    out += i + 2 < bytes.length ? B64[n & 63]! : '='
48  }
49  return out
50}
51
52/**
53 * The Raster cells for one frame. `kind` null is the idle sprite: the
54 * mutation spark at rest, in the muted colour.
55 */
56export function spriteCells(kind: ActivityKind | null, frame: number, palette: Palette): string {
57  const rows = kind === null ? FRAMES.mutation[0]! : FRAMES[kind][frame % 4]!
58  const tint = rgbOf(kind === null ? palette.muted : palette[TOKEN[kind]])
59  const highlight = rgbOf(palette.text)
60  const pixel = (ch: string | undefined): number => (ch === '#' ? tint : ch === '+' ? highlight : -1)
61  const words = new Uint32Array(SPRITE_COLS * 3)
62
63  for (let x = 0; x < SPRITE_COLS; x++) {
64    const top = pixel(rows[0]?.[x])
65    const bottom = pixel(rows[1]?.[x])
66    let cell: [number, number, number] = [0x20, DEFAULT, DEFAULT]
67    if (top >= 0 && bottom >= 0) cell = top === bottom ? [0x2588, top, DEFAULT] : [0x2580, top, bottom]
68    else if (top >= 0) cell = [0x2580, top, DEFAULT]
69    else if (bottom >= 0) cell = [0x2584, bottom, DEFAULT]
70    words.set(cell, x * 3)
71  }
72  return base64(new Uint8Array(words.buffer))
73}
74
types/index.d.ts 74 lines
1// The trimmed Devdeck snapshot the mod keeps in plugin state. The source shape
2// is DeckSnapshot in founder-x packages/devdeck/src/types.ts.
3
4export type ServiceStatus = 'starting' | 'ready' | 'compiling' | 'error' | 'stopped' | 'crashed'
5
6export type Svc = {
7  id: string
8  label: string
9  kind: string
10  port: number | null
11  status: ServiceStatus
12  memMb: number
13  reloadAt: number | null
14  reloadRoute: string | null
15  reloadMs: number | null
16}
17
18export type Ev = { id: number; at: number; service: string; kind: string; text: string; ms: number | null }
19
20export type Call = { id: number; at: number; type: string; fn: string; ms: number; ok: boolean; error: string | null }
21
22export type Err = { id: number; at: number; service: string; title: string; body: string[]; file: string | null }
23
24export type Snap = {
25  /** `$.clock.now()` when this snapshot arrived. */
26  fetchedAt: number
27  /** Devdeck's own clock when it wrote the snapshot. */
28  now: number
29  root: string
30  services: Svc[]
31  events: Ev[]
32  calls: Call[]
33  errors: Err[]
34  /** Every open error Devdeck holds (`totals.errors`); `errors` keeps only the latest few. */
35  errorCount: number
36  memMb: number
37}
38
39export type ActivityKind = 'reload' | 'mutation' | 'error'
40
41export type Activity = { kind: ActivityKind; at: number }
42
43/** The palette picked in Devdeck (its theme.json `colors`), every token `#rrggbb`. */
44export type Palette = {
45  /** Ready dots, the DEV label, mutation badges. */
46  accent: string
47  /** Compiling and starting, the reload bolt. */
48  activity: string
49  /** Main text. */
50  text: string
51  /** Labels, ports, ages, memory. */
52  muted: string
53  line: string
54  /** Errors and failed calls. */
55  error: string
56}
57
58declare module 'claude-code' {
59  interface PluginState {
60    devdeck: {
61      snap: Snap | null
62      /** Error ids expanded in the pane. */
63      expanded: number[]
64      /** `$.clock.now()` at the last tick, so a stale snapshot hides. */
65      clock: number
66      /** The sprite's animation frame, ticking only while it animates. */
67      frame: number
68      activity: Activity | null
69      /** Read at session start, then at most every 10s by the poll loop. */
70      palette: Palette
71    }
72  }
73}
74