SLOPSHOPPER

Sprites

Use Sprites from Claude Code to create, inspect, and operate remote isolated development environments.

newpanecommandtimer
★ 3v0.2.0MITupdated 2026-10-02superfly/sprites-claude-plugin/plugins/sprites
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sprites
│ ┃ Sprites ✕ › fix the failing auth test and add an audit log call │ ┃ Filter by name prefix ⏎ Find │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Press Find to list your Sprites, or filter ⏺ Update(src/auth.ts) │ ┃ by a name prefix. ⎿ 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 │ │ › /sprites-inspector │ ⎿ sprites: Sprite Inspector opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sprites
Filter by name prefix ⏎ Find Press Find to list your Sprites, or filter by a name prefix.
README

<img src="assets/sprites-logo.svg" alt="" width="72" height="58">

Sprites

The Sprites plugin gives Claude Code hosted MCP access to persistent, isolated development environments, plus skills and confirmation hooks for safe remote workflows.

After installation, run /sprites:status to list visible Sprites and complete OAuth if needed. Run /sprites:smoke for an end-to-end list, create, and exec check with cleanup only after approval. Run /sprites-inspector to browse Sprites, their services, checkpoints, and logs in a read-only pane, and to point Claude at one.

The local Claude Code workspace and the remote Sprite filesystem are separate. Use the plugin-provided MCP tools for Sprite commands, services, checkpoints, and policy. Do not install the Sprites CLI or register a second MCP server for normal plugin use.

See the repository documentation for installation, authentication, safety, and troubleshooting details.

Source 2 files
hooks/register.tsx 655 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3
4import type { CheckpointRow, InspectorState, Section, ServiceRow, SpriteSummary } from '../types'
5
6// A read-only Sprite Inspector: the Claude Code counterpart of the MCP App the
7// hosted server serves at ui://sprites/inspector.html. It calls the same tools
8// over the session's own connection, so it needs no credentials of its own.
9
10const PANE = 'sprites-inspector'
11const COMMAND = 'sprites-inspector'
12const SERVER = 'sprites'
13const LOG_LINES = 100
14const LOG_CHARS = 10000
15// Focus that returns sooner than this after a listing does not list again.
16const REFRESH_GAP_MS = 10_000
17
18// From this many columns the list and the details sit side by side.
19const WIDE_COLUMNS = 64
20const LIST_COLUMNS = 30
21const LABEL_COLUMNS = 8
22// Room for the longest checkpoint id, `Current`.
23const CHECKPOINT_ID_COLUMNS = 8
24
25// Theme keys, so the colors follow the person's light or dark theme.
26const STATUS_COLORS: Record<string, string> = { running: 'success', warm: 'warning', cold: 'inactive' }
27
28const section = <T,>(state: Section<T>['state'], items: T[] = [], message = ''): Section<T> => ({ state, items, message })
29const idle = <T,>(): Section<T> => section<T>('idle')
30
31const CLEARED = {
32  selected: null,
33  isChecking: false,
34  services: idle<ServiceRow>(),
35  checkpoints: idle<CheckpointRow>(),
36  logs: null,
37} satisfies Partial<InspectorState>
38
39const inspector = atom({ plugin: 'sprites', key: 'inspector' } as const, {
40  status: '',
41  statusTone: 'info',
42  prefix: '',
43  sprites: [],
44  cursor: null,
45  listedAt: null,
46  isListing: false,
47  attachedId: null,
48  ...CLEARED,
49} as InspectorState)
50
51// Each counter discards answers that arrive after a newer request of its kind.
52let listRun = 0
53let selectRun = 0
54let runtimeRun = 0
55let logsRun = 0
56let server: string | undefined
57// The pane's focus as the latest drawing saw it, to notice it coming back.
58let wasFocused = false
59
60async function connection($: EngineInterface): Promise<string> {
61  if (server) return server
62  const connected = await $.mcp.connect(SERVER)
63  if (!connected.isConnected) {
64    throw new Error(
65      connected.reason === 'auth'
66        ? 'The Sprites MCP server needs sign-in. Authenticate it in /mcp, then press Find again.'
67        : connected.message,
68    )
69  }
70  server = connected.server
71  return server
72}
73
74async function call($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<McpToolResult> {
75  let result: McpToolResult
76  try {
77    result = await $.mcp.call(await connection($), tool, args)
78  } catch (error) {
79    server = undefined
80    throw error
81  }
82  if (result.isError) throw new Error(textOf(result) || `${tool} failed.`)
83  return result
84}
85
86function textOf(result: McpToolResult): string {
87  return result.content
88    .filter(block => block.type === 'text')
89    .map(block => block.text ?? '')
90    .join('\n')
91}
92
93// Extension tools return structuredContent; the generated runtime tools return JSON as text.
94function dataOf(result: McpToolResult): unknown {
95  return result.structuredContent ?? JSON.parse(textOf(result))
96}
97
98function errorText(error: unknown): string {
99  return error instanceof Error ? error.message : String(error)
100}
101
102const info = (status: string) => ({ status, statusTone: 'info' as const })
103const failure = (error: unknown) => ({ status: errorText(error), statusTone: 'error' as const })
104
105function record(value: unknown): Record<string, unknown> {
106  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
107}
108
109function str(value: unknown): string {
110  return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
111}
112
113// A bare array, or an object holding one under `key`.
114function listOf(value: unknown, key: string, what: string): unknown[] {
115  const list = Array.isArray(value) ? value : record(value)[key]
116  if (!Array.isArray(list)) throw new Error(`Invalid ${what} response.`)
117  return list
118}
119
120function summary(value: unknown): SpriteSummary | null {
121  const v = record(value)
122  if (!str(v.id) || !str(v.name)) return null
123  return {
124    id: str(v.id),
125    name: str(v.name),
126    organization: str(v.organization),
127    orgId: str(v.org_id),
128    url: str(v.url),
129    status: str(v.status),
130    version: str(v.version),
131    created: str(v.created_at),
132    uri: str(record(v.resource).uri),
133  }
134}
135
136function safeUrl(value: string): string | null {
137  try {
138    const url = new URL(value)
139    return url.protocol === 'https:' && !url.username && !url.password ? url.href : null
140  } catch {
141    return null
142  }
143}
144
145// Logs can carry terminal escapes; Code and Text take tab and newline as their only control characters.
146function printable(text: string): string {
147  return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
148}
149
150const day = (iso: string) => iso.slice(0, 10)
151
152function timeOfDay(ms: number): string {
153  try {
154    return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
155  } catch {
156    return new Date(ms).toISOString().slice(11, 16)
157  }
158}
159const statusColor = (status: string) => STATUS_COLORS[status] ?? 'warning'
160
161async function loadPage($: EngineInterface, append: boolean, nextPrefix?: string): Promise<void> {
162  const before = await read($, inspector)
163  // Ignore a queued Load more press after another search has reset its cursor.
164  if (append && (before.isListing || !before.cursor)) return
165  const run = ++listRun
166  const prefix = append ? before.prefix : (nextPrefix ?? before.prefix).trim()
167  if (!append) {
168    ++selectRun
169    ++runtimeRun
170    ++logsRun
171  }
172  await update($, inspector, s => ({
173    ...s,
174    ...(append ? {} : { ...CLEARED, prefix, sprites: [], cursor: null, listedAt: null }),
175    ...info(''),
176    isListing: true,
177  }))
178  try {
179    const page = record(
180      dataOf(await call($, 'open_sprite_inspector', { prefix, continuation_token: append ? (before.cursor ?? '') : '' })),
181    )
182    const incoming = listOf(page, 'sprites', 'Sprite listing')
183      .map(summary)
184      .filter((one): one is SpriteSummary => one !== null)
185    const listedAt = await $.clock.now()
186    if (run !== listRun) return
187    await update($, inspector, s => {
188      const sprites = append ? [...s.sprites, ...incoming.filter(one => !s.sprites.some(old => old.id === one.id))] : incoming
189      return {
190        ...s,
191        ...info(sprites.length ? '' : 'No matching Sprites.'),
192        sprites,
193        cursor: str(page.next_continuation_token) || null,
194        listedAt,
195        prefix: typeof page.prefix === 'string' ? page.prefix : s.prefix,
196        isListing: false,
197      }
198    })
199  } catch (error) {
200    if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error), isListing: false }))
201  }
202}
203
204// The quiet re-list the pane runs when it regains focus: it changes only what
205// the listing says now, and keeps the selection, runtime details and logs.
206async function refresh($: EngineInterface): Promise<void> {
207  const before = await read($, inspector)
208  if (before.isListing || before.listedAt === null) return
209  if ((await $.clock.now()) - before.listedAt < REFRESH_GAP_MS) return
210  const run = ++listRun
211  try {
212    const page = record(dataOf(await call($, 'open_sprite_inspector', { prefix: before.prefix, continuation_token: '' })))
213    const fresh = listOf(page, 'sprites', 'Sprite listing')
214      .map(summary)
215      .filter((one): one is SpriteSummary => one !== null)
216    const listedAt = await $.clock.now()
217    if (run !== listRun) return
218    const byId = new Map(fresh.map(one => [one.id, one]))
219    await update($, inspector, s => {
220      // Restart pagination with this page's cursor: inserts or deletions can
221      // move rows across the old page boundaries while the pane is unfocused.
222      const listed = s.selected && byId.get(s.selected.id)
223      return {
224        ...s,
225        ...(s.statusTone === 'error' ? info('') : {}),
226        sprites: fresh,
227        cursor: str(page.next_continuation_token) || null,
228        listedAt,
229        selected: s.selected && listed ? { ...s.selected, status: listed.status } : s.selected,
230      }
231    })
232  } catch (error) {
233    if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error) }))
234  }
235}
236
237async function selectSprite($: EngineInterface, sprite: SpriteSummary): Promise<void> {
238  const run = ++selectRun
239  ++runtimeRun
240  ++logsRun
241  await update($, inspector, s => ({ ...s, ...CLEARED, ...info(''), selected: sprite, isChecking: true }))
242  try {
243    const checked = summary(dataOf(await call($, 'get_sprite_info', { sprite: sprite.name, sprite_id: sprite.id })))
244    if (!checked) throw new Error('Invalid Sprite info response.')
245    if (run !== selectRun) return
246    // get_sprite_info's status can disagree with the listing's live runtime
247    // state (a running Sprite reads cold), so the listing's status stands.
248    const fresh = { ...checked, status: sprite.status || checked.status }
249    await update($, inspector, s => ({
250      ...s,
251      selected: fresh,
252      isChecking: false,
253      sprites: s.sprites.map(one => (one.id === fresh.id ? fresh : one)),
254    }))
255  } catch (error) {
256    if (run === selectRun) await update($, inspector, s => ({ ...s, ...CLEARED, ...failure(error) }))
257  }
258}
259
260// The counterpart of the MCP App's ui/update-model-context: a row the model
261// reads and the person does not see as typed. Rows add up rather than replace,
262// so the newest attachment is the one that counts.
263async function attach($: EngineInterface): Promise<void> {
264  const { selected } = await read($, inspector)
265  if (!selected) return
266  const identity = {
267    sprite: selected.name,
268    sprite_id: selected.id,
269    org_id: selected.orgId,
270    organization: selected.organization,
271    uri: selected.uri,
272  }
273  const text =
274    `The user selected this Sprite in the Sprite Inspector pane: ${JSON.stringify(identity)}. ` +
275    'Treat it as the target when they refer to "the Sprite" or "this Sprite". Verify the identity with get_sprite_info ' +
276    'before operating on it, and pass sprite_id alongside sprite on Sprite-scoped tool calls.'
277  let refused: string | undefined
278  try {
279    refused = (await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })).deny
280  } catch (error) {
281    refused = errorText(error)
282  }
283  await update($, inspector, s =>
284    refused
285      ? { ...s, ...failure(`Could not attach the Sprite: ${refused}`) }
286      : { ...s, ...info(''), attachedId: selected.id },
287  )
288}
289
290async function loadRuntime($: EngineInterface): Promise<void> {
291  const { selected } = await read($, inspector)
292  if (!selected) return
293  const selection = selectRun
294  const run = ++runtimeRun
295  const current = () => selection === selectRun && run === runtimeRun
296  await update($, inspector, s => ({ ...s, services: section<ServiceRow>('loading'), checkpoints: section<CheckpointRow>('loading') }))
297  const args = { sprite: selected.name, sprite_id: selected.id }
298
299  await Promise.all([
300    (async () => {
301      try {
302        const items = listOf(dataOf(await call($, 'service_list', args)), 'services', 'services').map(one => {
303          const v = record(one)
304          return { name: str(v.name), status: str(record(v.state).status) || 'unknown' }
305        })
306        if (current()) await update($, inspector, s => ({ ...s, services: section('ready', items) }))
307      } catch (error) {
308        if (current()) await update($, inspector, s => ({ ...s, services: section('error', [], errorText(error)) }))
309      }
310    })(),
311    (async () => {
312      try {
313        const items = listOf(dataOf(await call($, 'checkpoint_list', args)), 'checkpoints', 'checkpoints').map(one => {
314          const v = record(one)
315          return { id: str(v.id), comment: str(v.comment), created: str(v.create_time) }
316        })
317        if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('ready', items) }))
318      } catch (error) {
319        if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('error', [], errorText(error)) }))
320      }
321    })(),
322  ])
323}
324
325async function loadLogs($: EngineInterface, service: string): Promise<void> {
326  const { selected } = await read($, inspector)
327  if (!selected) return
328  const selection = selectRun
329  const run = ++logsRun
330  const current = () => selection === selectRun && run === logsRun
331  await update($, inspector, s => ({ ...s, logs: { service, state: 'loading' as const, text: '' } }))
332  try {
333    const result = await call($, 'service_logs', {
334      sprite: selected.name,
335      sprite_id: selected.id,
336      service_name: service,
337      lines: LOG_LINES,
338      duration: '0s',
339    })
340    // The endpoint streams NDJSON events; `data` carries the output itself.
341    const text = printable(
342      textOf(result)
343        .split('\n')
344        .filter(Boolean)
345        .map(line => {
346          try {
347            const event = record(JSON.parse(line))
348            return typeof event.data === 'string' ? event.data : event.type === 'error' ? `${JSON.stringify(event)}\n` : ''
349          } catch {
350            return `${line}\n`
351          }
352        })
353        .join(''),
354    )
355      .replace(/\n+$/, '')
356      .slice(-LOG_CHARS)
357    if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'ready' as const, text } }))
358  } catch (error) {
359    if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'error' as const, text: errorText(error) } }))
360  }
361}
362
363// MCP calls can outlast the dispatch that asked for them; a timer runs them on their own.
364function later($: EngineInterface, work: () => Promise<void>): void {
365  $.clock.after(0, () => void work())
366}
367
368function listing(s: InspectorState): string {
369  if (!s.sprites.length) return s.status || 'No Sprites loaded.'
370  const rows = s.sprites.map(one => `- ${one.name} · ${one.status || 'unknown'}${one.organization ? ` · ${one.organization}` : ''}`)
371  return [`${s.sprites.length} Sprite${s.sprites.length === 1 ? '' : 's'}${s.cursor ? ' (more available)' : ''}`, ...rows].join('\n')
372}
373
374export const register: Register = on => {
375  on('session.start', async ($, e, next) => {
376    await $.command.register({
377      name: COMMAND,
378      description: 'Browse your Sprites, their services, checkpoints, and logs in a read-only pane',
379      argumentHint: '[name prefix]',
380    })
381    // State outlives a reload or resume, but the requests in flight did not.
382    await update($, inspector, s => ({
383      ...s,
384      ...info(''),
385      isListing: false,
386      isChecking: false,
387      services: s.services.state === 'loading' ? idle<ServiceRow>() : s.services,
388      checkpoints: s.checkpoints.state === 'loading' ? idle<CheckpointRow>() : s.checkpoints,
389      logs: s.logs?.state === 'loading' ? null : s.logs,
390    }))
391    return next(e)
392  })
393
394  on('command.run', { command: COMMAND }, async ($, e) => {
395    const opened = await $.ui.open({ id: PANE, title: 'Sprites' })
396    const before = await read($, inspector)
397    const prefix = e.args.trim()
398
399    // Where no pane can draw (the VS Code panel, claude -p), answer in text.
400    if (!opened.isPlaced) {
401      await loadPage($, false, prefix)
402      return { text: listing(await read($, inspector)) }
403    }
404    // Opening always lists afresh: statuses change while the pane sits idle.
405    if (!before.isListing || prefix) later($, () => loadPage($, false, prefix || undefined))
406    return { text: 'Sprite Inspector opened.' }
407  })
408
409  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
410    const { Box, Text, Button, Link, Code } = $.ui.resolve(e)
411    const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
412    // Coming back to the pane lists again; a drawing only schedules it, since it may not write.
413    if (e.props.isFocused && !wasFocused) later($, () => refresh($))
414    wasFocused = e.props.isFocused
415
416    const s = await read($, inspector)
417    const selected = s.selected
418    const isWide = e.props.bodyColumns >= WIDE_COLUMNS
419    const hasRuntime = s.services.state !== 'idle' || s.checkpoints.state !== 'idle'
420    const isRuntimeLoading = s.services.state === 'loading' || s.checkpoints.state === 'loading'
421
422    const field = (label: string, value: string) => (
423      <Box flexDirection="row">
424        <Box width={LABEL_COLUMNS} flexShrink={0}>
425          <Text dimColor>{label}</Text>
426        </Box>
427        <Text wrap="truncate-middle">{value}</Text>
428      </Box>
429    )
430
431    const heading = (title: string, detail?: string) => (
432      <Box flexDirection="row" justifyContent="space-between">
433        <Text bold>{title}</Text>
434        {detail ? <Text dimColor>{detail}</Text> : null}
435      </Box>
436    )
437
438    const search = (
439      <Box flexDirection="column">
440        {Input && (
441          <Input
442            key="prefix"
443            placeholder="Filter by name prefix"
444            value={s.prefix}
445            submitLabel="Find"
446            onSubmit={value => later($, () => loadPage($, false, value))}
447          />
448        )}
449        {s.isListing && <Text dimColor>Loading Sprites…</Text>}
450        {s.status && (
451          <Text color={s.statusTone === 'error' ? 'error' : undefined} dimColor={s.statusTone !== 'error'} wrap="wrap">
452            {s.status}
453          </Text>
454        )}
455      </Box>
456    )
457
458    const list = (
459      <Box flexDirection="column" width={isWide ? LIST_COLUMNS : undefined} flexShrink={0}>
460        {s.sprites.length > 0 &&
461          heading(
462            'Sprites',
463            `${s.sprites.length}${s.cursor ? '+' : ''}${s.listedAt !== null ? ` · as of ${timeOfDay(s.listedAt)}` : ''}`,
464          )}
465        {!s.sprites.length && !s.isListing && !s.status && (
466          <Text dimColor>Press Find to list your Sprites, or filter by a name prefix.</Text>
467        )}
468        {s.sprites.map(one => {
469          const isSelected = one.id === selected?.id
470          return (
471            <Box key={`row-${one.id}`} flexDirection="row" gap={1}>
472              <Text color="claude">{isSelected ? '▌' : ' '}</Text>
473              <Text color={statusColor(one.status)}>●</Text>
474              <Box flexGrow={1} flexShrink={1} minWidth={0}>
475                {/* The selected row stays a Button: a focused element that leaves the
476                    tree takes the pane's focus with it, and on desktop the next click
477                    into an unfocused pane only focuses. */}
478                <Button
479                  key={`sprite-${one.id}`}
480                  plain
481                  label={one.name}
482                  onPress={() => {
483                    if (!isSelected) later($, () => selectSprite($, one))
484                  }}
485                />
486              </Box>
487              {one.id === s.attachedId && <Text color="suggestion">in chat</Text>}
488              <Text dimColor>{one.status || 'unknown'}</Text>
489            </Box>
490          )
491        })}
492        {s.cursor && (
493          <Box marginTop={1}>
494            <Button key="more" label={s.isListing ? 'Loading…' : 'Load more'} onPress={() => later($, () => loadPage($, true))} />
495          </Box>
496        )}
497      </Box>
498    )
499
500    const details = selected ? (
501      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} gap={1}>
502        <Box flexDirection="row" justifyContent="space-between" gap={1}>
503          <Box flexShrink={1}>
504            <Text bold wrap="truncate-end">
505              {selected.name}
506            </Text>
507          </Box>
508          <Box flexDirection="row" gap={1} flexShrink={0}>
509            {selected.id === s.attachedId && <Text color="suggestion">in chat</Text>}
510            <Text color={statusColor(selected.status)}>● {s.isChecking ? 'checking…' : selected.status || 'unknown'}</Text>
511          </Box>
512        </Box>
513
514        <Box flexDirection="column">
515          {selected.organization && field('Org', selected.organization)}
516          {selected.created && field('Created', day(selected.created))}
517          {safeUrl(selected.url) && (
518            <Box flexDirection="row">
519              <Box width={LABEL_COLUMNS} flexShrink={0}>
520                <Text dimColor>URL</Text>
521              </Box>
522              <Link href={safeUrl(selected.url) ?? ''} label={selected.url.replace(/^https:\/\//, '').replace(/\/$/, '')} />
523            </Box>
524          )}
525        </Box>
526
527        {!s.isChecking && (
528          <Box flexDirection="column">
529            <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
530              <Button
531                key="attach"
532                variant={selected.id === s.attachedId ? 'secondary' : 'primary'}
533                label={selected.id === s.attachedId ? 'In chat ✓' : 'Use in chat'}
534                onPress={() => later($, () => attach($))}
535              />
536              <Button
537                key="runtime"
538                label={isRuntimeLoading ? 'Loading…' : hasRuntime ? 'Refresh runtime' : 'Load runtime'}
539                onPress={() => later($, () => loadRuntime($))}
540              />
541            </Box>
542            {!hasRuntime && selected.status !== 'running' && (
543              <Text color="warning" wrap="wrap">
544                Loading runtime wakes this {selected.status || 'idle'} Sprite.
545              </Text>
546            )}
547          </Box>
548        )}
549      </Box>
550    ) : isWide && s.sprites.length > 0 ? (
551      <Text dimColor>Select a Sprite to see its details.</Text>
552    ) : null
553
554    const services = selected && hasRuntime && (
555      <Box flexDirection="column">
556        {heading('Services', s.services.state === 'ready' ? String(s.services.items.length) : undefined)}
557        {s.services.state === 'loading' && <Text dimColor>Loading…</Text>}
558        {s.services.state === 'error' && <Text color="error" wrap="wrap">{s.services.message}</Text>}
559        {s.services.state === 'ready' && !s.services.items.length && <Text dimColor>No services configured.</Text>}
560        {s.services.items.map(service => (
561          <Box key={`svc-${service.name}`} flexDirection="row" gap={1}>
562            <Text color={service.status === 'running' ? 'success' : service.status === 'stopped' ? 'inactive' : 'warning'}>●</Text>
563            <Box flexGrow={1} flexShrink={1} minWidth={0}>
564              <Text wrap="truncate-end">{service.name}</Text>
565            </Box>
566            <Text dimColor>{service.status}</Text>
567            <Button
568              key={`logs-${service.name}`}
569              label={s.logs?.service === service.name ? 'Logs ✓' : 'Logs'}
570              onPress={() => later($, () => loadLogs($, service.name))}
571            />
572          </Box>
573        ))}
574      </Box>
575    )
576
577    const checkpoints = selected && hasRuntime && (
578      <Box flexDirection="column">
579        {heading('Checkpoints', s.checkpoints.state === 'ready' ? String(s.checkpoints.items.length) : undefined)}
580        {s.checkpoints.state === 'loading' && <Text dimColor>Loading…</Text>}
581        {s.checkpoints.state === 'error' && <Text color="error" wrap="wrap">{s.checkpoints.message}</Text>}
582        {s.checkpoints.state === 'ready' && !s.checkpoints.items.length && <Text dimColor>No checkpoints.</Text>}
583        {s.checkpoints.items.map(checkpoint => (
584          <Box key={`cp-${checkpoint.id}`} flexDirection="row" gap={1}>
585            <Box width={CHECKPOINT_ID_COLUMNS} flexShrink={0}>
586              <Text bold wrap="truncate-end">
587                {checkpoint.id}
588              </Text>
589            </Box>
590            <Box flexGrow={1} flexShrink={1} minWidth={0}>
591              <Text wrap="truncate-end">{checkpoint.comment || 'No comment'}</Text>
592            </Box>
593            {checkpoint.created && (
594              <Box flexShrink={0}>
595                <Text dimColor>{day(checkpoint.created)}</Text>
596              </Box>
597            )}
598          </Box>
599        ))}
600      </Box>
601    )
602
603    const logs = selected && s.logs && (
604      <Box flexDirection="column">
605        <Box flexDirection="row" justifyContent="space-between">
606          <Text bold>
607            Logs <Text dimColor>· {s.logs.service} · last {LOG_LINES} lines</Text>
608          </Text>
609          <Button
610            key="hide-logs"
611            plain
612            label="Hide"
613            onPress={() =>
614              later($, async () => {
615                // Hide leaves the tree with the logs; hand the ring to a Button that stays.
616                await $.ui.focus({ requestId: PANE, key: 'runtime' })
617                await update($, inspector, st => ({ ...st, logs: null }))
618              })
619            }
620          />
621        </Box>
622        {s.logs.state === 'loading' && <Text dimColor>Loading…</Text>}
623        {s.logs.state === 'error' && <Text color="error" wrap="wrap">{s.logs.text}</Text>}
624        {s.logs.state === 'ready' && (s.logs.text ? <Code source={s.logs.text} /> : <Text dimColor>No recent logs.</Text>)}
625      </Box>
626    )
627
628    const detailColumn = (
629      <Box flexDirection="column" gap={1} flexGrow={1} flexShrink={1}>
630        {details}
631        {services}
632        {checkpoints}
633        {logs}
634      </Box>
635    )
636
637    return (
638      <Box flexDirection="column" gap={1}>
639        {search}
640        {isWide ? (
641          <Box flexDirection="row" gap={2}>
642            {list}
643            {detailColumn}
644          </Box>
645        ) : (
646          <Box flexDirection="column" gap={1}>
647            {list}
648            {detailColumn}
649          </Box>
650        )}
651      </Box>
652    )
653  })
654}
655
types/index.d.ts 50 lines
1export type SpriteSummary = {
2  id: string
3  name: string
4  organization: string
5  orgId: string
6  url: string
7  status: string
8  version: string
9  created: string
10  uri: string
11}
12
13export type ServiceRow = { name: string; status: string }
14
15export type CheckpointRow = { id: string; comment: string; created: string }
16
17export type Section<T> = {
18  state: 'idle' | 'loading' | 'ready' | 'error'
19  items: T[]
20  message: string
21}
22
23export type LogsView = {
24  service: string
25  state: 'loading' | 'ready' | 'error'
26  text: string
27}
28
29export type InspectorState = {
30  status: string
31  statusTone: 'info' | 'error'
32  prefix: string
33  sprites: SpriteSummary[]
34  cursor: string | null
35  listedAt: number | null
36  isListing: boolean
37  selected: SpriteSummary | null
38  isChecking: boolean
39  attachedId: string | null
40  services: Section<ServiceRow>
41  checkpoints: Section<CheckpointRow>
42  logs: LogsView | null
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    sprites: { inspector: InspectorState }
48  }
49}
50