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…

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.
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.
npx skills@latest add michaelezehi/nw-skills
This copies editable skill folders into your project or ~/.claude/skills.
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.
| Skill | What it does | Origin |
|---|---|---|
app-user-journey | Scan 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-speed | Audit 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-cron | Install a 4x-daily production backup cron for Convex + DigitalOcean Spaces projects. | Original |
og | Install a dynamic Open Graph / Twitter social card route into a Next.js project. | Original |
own-goal | Run 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-optimisation | Find 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-cases | Generate a shareable QA test case .md document for the current project. | Original |
terminal-upgrade | Turn pnpm dev / npm run dev / yarn dev / bun dev in every repo on the machine into Devdeck, a calm live terminal dashboard. | Original |
ts-upgrade | Move 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 |
unlazy | Anti-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-practices | React and Next.js performance optimization guidelines from Vercel Engineering. | Modified from vercel-labs/agent-skills |
walkthrough | Add 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 |
| Skill | What it does | Origin |
|---|---|---|
apple-design | Apple's approach to interface design and fluid, physical motion, translated for the web. | Modified from emilkowalski/skill |
brandkit | Premium 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-burst | Adds a performant click-triggered full-viewport color burst transition (scale + opacity only, no animated blur). | Original |
design-taste-frontend | Anti-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-eng | Emil Kowalski's design-engineering philosophy, animation decisions, springs, gestures, component feel, and the invisible details that make software feel great. | Modified from emilkowalski/skill |
fed | Alias for frontend-design. | Original |
find-animation-opportunities | Search a codebase or UI for places that don't animate but should, and reject everything that shouldn't. | Modified from emilkowalski/skill |
frontend-design | The main frontend skill. Distinctive, production-grade UI with high craft in typography, layout, colour and motion. | Original |
iconize | Extract individual icons from sprite sheets into named, square PNG files organized by category. | Original |
image-to-code | Alias for imagegen-frontend-web (image-first web flow: generate section images, then code them). | Modified from Leonxlnx/taste-skill |
imagegen-frontend-mobile | Premium 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-web | The 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 |
impeccable | Use 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-animations | Survey 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-ui | OPT-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-ui | OPT-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-library | Pick 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 |
prototype | Build 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-projects | Upgrades existing websites and apps to premium quality. | Modified from Leonxlnx/taste-skill |
review-animations | Reviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. | Modified from emilkowalski/skill |
sketches | Hand-drawn SVG sketch/doodle system with a growing, brand-themeable repertoire. | Original |
stitch-design-taste | Semantic Design System Skill for Google Stitch. | Modified from Leonxlnx/taste-skill |
the-broadside | Install the broadside editorial slide-deck design system into the current React/Next.js project. | Original |
the-stripe-modal | Install 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-max | UI/UX design intelligence for web and mobile. | Modified from nextlevelbuilder/ui-ux-pro-max-skill |
| Skill | What it does | Origin |
|---|---|---|
humanizer | Rewrite AI-sounding text so it reads like the writer without changing what it says. | Modified from blader/humanizer |
rnd | Create 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 |
unslop | Cut AI tells from any writing. | Original |
writing-clearly-and-concisely | Strunk companion for prose humans will read. | Modified from softaworks/agent-toolkit |
| Command | What it does |
|---|---|
/cf | Force-continue a context-exhausted session. |
/ch | Handoff continuity mode: numbered task list, accountability record with every question and decision, optional HTML summary. |
/fd-ch | Frontend design run with reconnaissance, a quality gate and an accountability record, built on the frontend-design skill. |
/pr | Read every PR review comment, fix what is actionable, and reply to each one. |
/prd-ch | Create a PRD fast and well. |
/research | Deep research on any topic. |
/reviewer | Full audit and fix pass on the current task's changes, using any review skills installed on your machine (never Greptile or CodeRabbit). |
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
| Mod | What it does | Origin |
|---|---|---|
savvy-progress | Progress 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-bar | Turns 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-rails | Denies 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-chime | A 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.
/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.
My own skills and commands are MIT, see LICENSE. Modified skills keep their upstream licence and credit; see NOTICE.md.
hooks/register.tsx 357 lines1// 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}
357hooks/actions.ts 89 lines1// 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}
89hooks/band.tsx 103 lines1// 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}
103hooks/model.ts 175 lines1// 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}
175hooks/pane.tsx 165 lines1// 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}
165hooks/palette.ts 55 lines1// 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)
55hooks/socket.ts 30 lines1// 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}`
30hooks/summary.ts 38 lines1// 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}
38hooks/sprite.ts 74 lines1// 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}
74types/index.d.ts 74 lines1// 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