SLOPSHOPPER

mesh-toaster

A toaster above the prompt: empty with no mesh mail, toast popping up when mail arrives

newbandprocesstimer
v0.1.0no licenseupdated 2026-10-09Mark-Hickey/claude-nine/mesh-toaster
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mesh-toaster
› fix the failing auth test and add an audit log call ⏺ 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 ╭─▁▁▁▁─▁▁▁▁─╮ mesh: no mail │ │▪ ╰┬─────────┬╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─▁▁▁▁─▁▁▁▁─╮ mesh: no mail │ │▪ ╰┬─────────┬╯ ⟨Claude Code's own drawing⟩
README

mesh-toaster

A small toaster above the Claude Code prompt that shows your cortex mesh mail.

What it does

  • With no new mail, the toaster is empty and dim.
  • When new mail comes, a toast comes up out of the toaster, with the sender and the subject of the message next to it.
  • Push Got it to put the toast down. This changes only the toaster. The message stays unread on the mesh.

Every minute, it reads your mesh inbox with cortex-mesh.py inbox --peek. It uses --peek, so it never marks a message as read on the mesh.

Requirements

  • The cortex-join plugin, installed and joined (/cortex-join). The mod uses its cortex-mesh.py script.
  • Tailscale connected, so the mesh can be reached.

Install

/plugin install mesh-toaster --marketplace Mark-Hickey/claude-mods

The toaster shares the rows above the prompt with other mods. linear-watch folds its cards to one line when there are not sufficient rows for the two mods.

Develop

claude plugin validate mesh-toaster
claude plugin test mesh-toaster
Source 2 files
hooks/register.tsx 144 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Mail } from '../types'
5
6// Reads the inbox with --peek, so checking never marks anything read on the mesh.
7const INBOX = [
8  'bash',
9  '-c',
10  'python3 "$(ls ~/.claude/plugins/cache/cortex/cortex-join/*/scripts/cortex-mesh.py | sort | tail -1)" inbox --peek',
11]
12const EVERY_MS = 60_000
13const SEEN_KEY = 'seen'
14// The toast rises out of the slot over these steps when mail arrives.
15const LIFT_STEPS = 3
16const LIFT_MS = 180
17
18const unseen = atom({ plugin: 'mesh-toaster', key: 'unseen' } as const, [])
19const lift = atom({ plugin: 'mesh-toaster', key: 'lift' } as const, 0)
20
21// "  [1791070009649-0] msg <uuid> from chris@todie.io @ session-…: subject"
22const LINE = /^\s*\[[^\]]+\]\s+\S+\s+(\S+)\s+from\s+(\S+)\s+@\s+([^:]+):\s*(.*)$/
23
24export function parseInbox(stdout: string): Mail[] {
25  return stdout.split('\n').flatMap(line => {
26    const m = LINE.exec(line)
27    return m ? [{ id: m[1], from: m[2], peer: m[3].trim(), subject: m[4] }] : []
28  })
29}
30
31export const notSeen = (mail: Mail[], seen: string[]) => mail.filter(m => !seen.includes(m.id))
32
33// The toaster, as rows of text. With no mail its slots are empty; with mail, toast
34// stands up out of the slots, `height` rows high (0 to LIFT_STEPS).
35export function toaster(height: number): string[] {
36  const toast = ['  ▗▄▄▖ ▗▄▄▖', '  ▐██▌ ▐██▌', '  ▐██▌ ▐██▌'].slice(LIFT_STEPS - height)
37  const slots = height > 0 ? ' ╭─▐██▌─▐██▌─╮' : ' ╭─▁▁▁▁─▁▁▁▁─╮'
38  return [...toast, slots, ' │           │▪', ' ╰┬─────────┬╯']
39}
40
41// The slot row in pieces, so the toast inside the slots can be drawn yellow like the rest of it.
42export function slotSegments(height: number): { text: string; isToast: boolean }[] {
43  if (height === 0) return [{ text: ' ╭─▁▁▁▁─▁▁▁▁─╮', isToast: false }]
44  return [
45    { text: ' ╭─', isToast: false },
46    { text: '▐██▌', isToast: true },
47    { text: '─', isToast: false },
48    { text: '▐██▌', isToast: true },
49    { text: '─╮', isToast: false },
50  ]
51}
52
53export function caption(mail: Mail[]): string {
54  if (mail.length === 0) return 'mesh: no mail'
55  const [first] = mail
56  const who = first.from.split('@')[0]
57  const more = mail.length > 1 ? ` (+${mail.length - 1} more)` : ''
58  return `mesh: ${mail.length} new · ${who}: ${first.subject}${more}`
59}
60
61export const register: Register = on => {
62  let isChecking = false
63
64  on('session.start', async ($, e, next) => {
65    const check = async () => {
66      if (isChecking) return
67      isChecking = true
68      try {
69        const { exitCode, stdout } = await $.process.run(INBOX, { timeoutMs: 20_000 })
70        if (exitCode !== 0) return
71        const seen = ((await $.store.get(SEEN_KEY)) ?? []) as string[]
72        const fresh = notSeen(parseInbox(stdout), seen)
73        const before = await read($, unseen)
74        await update($, unseen, () => fresh)
75        // Pop the toast up only when new mail arrives, not on every check.
76        if (fresh.some(m => !before.some(b => b.id === m.id))) {
77          for (let step = 1; step <= LIFT_STEPS; step++) {
78            await update($, lift, () => step)
79            await $.clock.sleep(LIFT_MS)
80          }
81        }
82        if (fresh.length === 0) await update($, lift, () => 0)
83      } finally {
84        isChecking = false
85      }
86    }
87
88    void check()
89    $.clock.every(EVERY_MS, () => void check())
90    return next(e)
91  })
92
93  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94    // Draw the toaster, then let the mods beneath draw their lines under it.
95    const below = await next(e)
96    if (e.props.hasSurvey) return below
97
98    const mail = await read($, unseen)
99    const height = mail.length > 0 ? await read($, lift) : 0
100    const rows = toaster(height)
101    const { Box, Button, Text } = $.ui.resolve(e)
102
103    return (
104      <Box flexDirection="column">
105        <Box flexDirection="row">
106          <Box flexDirection="column">
107            {rows.map((row, i) =>
108              i === rows.length - 3 ? (
109                <Box key={`r${i}`} flexDirection="row">
110                  {slotSegments(height).map((seg, j) => (
111                    <Text key={`s${j}`} color={seg.isToast ? 'yellow' : undefined} dimColor={mail.length === 0}>
112                      {seg.text}
113                    </Text>
114                  ))}
115                </Box>
116              ) : (
117                <Text key={`r${i}`} color={i < rows.length - 3 ? 'yellow' : undefined} dimColor={mail.length === 0}>
118                  {row}
119                </Text>
120              ),
121            )}
122          </Box>
123          <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
124            <Text dimColor={mail.length === 0} wrap="truncate-end">{caption(mail)}</Text>
125            {mail.length > 0 && (
126              <Button
127                key="got-it"
128                label="Got it"
129                onPress={async () => {
130                  const seen = ((await $.store.get(SEEN_KEY)) ?? []) as string[]
131                  await $.store.set(SEEN_KEY, [...seen, ...mail.map(m => m.id)])
132                  await update($, unseen, () => [])
133                  await update($, lift, () => 0)
134                }}
135              />
136            )}
137          </Box>
138        </Box>
139        {below}
140      </Box>
141    )
142  })
143}
144
types/index.d.ts 8 lines
1export type Mail = { id: string; from: string; peer: string; subject: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'mesh-toaster': { unseen: Mail[]; lift: number }
6  }
7}
8