SLOPSHOPPER

memory-status

Whether your Supabase memory is up or degraded, and how many writes wait in its queue: a line above the prompt, and /memory-status with each container and a…

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-status
│ ┃ Memory Status ✕ › fix the failing auth test and add an audit log call │ ┃ ◌ Memory [ │ ┃ No memory server found · checked 0 s ago ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ GATEWAY │ ⎿ Added 2 lines, removed 1 line │ ┃ │ UP http://127.0.0.1:8100 answered 0 │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ CONTAINERS · podman │ ┃ │ STOPPED supabase-kong needed · not found ✻ Worked for 42s · done 4:20 PM │ ┃ │ STOPPED supabase-rest needed · not found │ ┃ │ STOPPED supabase-db needed · not found › /memory-status │ ┃ ╰─────────────────────────────────────────── ⎿ memory-status: Memory unknown: no memory server found · 0 queued │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ QUEUED WRITES │ │ ┃ │ 0 nothing waiting │ │ ┃ │ No queue file known: set one in /plugin │ │ ┃ │ configure memory-status. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ RECENT CHANGES │ ┃ 0 s ago · unknown · no memory server found │ ○ memory: no server found · no memory server found · 0 writes queued ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ memory: no server found · no memory server found · 0 writes queued ⟨Claude Code's own drawing⟩
Pane · Memory Status
◌ Memory [ ↻ Check now ] ✕ No memory server found · checked 0 s ago ╭──────────────────────────────────────────────────────────╮ │ GATEWAY │ │ UP http://127.0.0.1:8100 answered 0 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ CONTAINERS · podman │ │ STOPPED supabase-kong needed · not found │ │ STOPPED supabase-rest needed · not found │ │ STOPPED supabase-db needed · not found │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ QUEUED WRITES │ │ 0 nothing waiting │ │ No queue file known: set one in /plugin configure │ │ memory-status. │ ╰──────────────────────────────────────────────────────────╯ RECENT CHANGES 0 s ago · unknown · no memory server found
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

memory-status

For people who keep agent memory in a self-hosted Supabase. It tells you whether memory is up or degraded, and how many writes are waiting to be replayed.

What it does

  • A line above the prompt: ◉ memory ok, ◉ memory degraded · kong down, or · 3 writes queued.
  • /memory-status shows:
  • the gateway's answer
  • every Supabase container and its status
  • the pending-write queue

It gives one targeted fix, compose up -d --no-deps for only the critical services, with Copy fix and Run fix. Run fix asks for a second press before it runs.

  • It checks every 30 s with an HTTP request to <url>/rest/v1/, podman ps (or docker ps) and a line count of the queue file. A gateway that answers 401 counts as up, because that means it is there and wants a key.

Settings

SettingDefault
urlfoundThe Supabase gateway. Empty reads it from your supabase-memory MCP server, else http://127.0.0.1:8100.
pendingFilefoundThe pending.jsonl that degraded-mode writes wait in.
criticalsupabase-kong,supabase-rest,supabase-dbAny of these stopped means degraded.
onlyProblemsfalseOn hides the line while all is well.
Source 4 files
hooks/register.tsx 289 lines
1// Memory Status: whether the Supabase memory Claude Code reads and writes is up, and how many
2// writes wait in its degraded-mode queue.
3//   - Every 30 s: the gateway over HTTP (any answer means up; it wants a key, so 401 is fine),
4//     the containers memory needs (podman or docker ps), and the lines in pending.jsonl.
5//   - A line above the prompt; /memory-status shows each container, recent changes, and the
6//     targeted restart (only the critical services), to copy or to run with two presses.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import type { Change, Reading } from '../types'
11import { bandsFile, parseOrder, stackBands } from './bands'
12import { countPending, discover, fixCommand, judge, parsePs } from './check'
13
14const NAME = 'memory-status'
15const PANE = 'memory-status'
16const EVERY_MS = 30_000
17const OPTIONAL = ['supabase-auth', 'supabase-meta']
18
19const reading = atom({ plugin: 'memory-status', key: 'reading' } as const, null as Reading | null)
20const history = atom({ plugin: 'memory-status', key: 'history' } as const, [] as Change[])
21const fix = atom({ plugin: 'memory-status', key: 'fix' } as const, '')
22const fixOutput = atom({ plugin: 'memory-status', key: 'fixOutput' } as const, '')
23const armed = atom({ plugin: 'memory-status', key: 'armed' } as const, false)
24const busy = atom({ plugin: 'memory-status', key: 'busy' } as const, false)
25
26type Setup = { url: string; pendingFile: string; workspace: string; critical: string[]; runtime: string | null; isConfigured: boolean }
27
28let options: { url: string; pendingFile: string; critical: string[]; onlyProblems: boolean } = { url: '', pendingFile: '', critical: [], onlyProblems: false }
29let setup: Setup | null = null
30let timer: Timer | undefined
31let bandOrder: Promise<string[]> | undefined
32
33export const register: Register = (on, opts) => {
34  options = {
35    url: typeof opts.url === 'string' ? opts.url.trim() : '',
36    pendingFile: typeof opts.pendingFile === 'string' ? opts.pendingFile.trim() : '',
37    critical: (typeof opts.critical === 'string' && opts.critical.trim() ? opts.critical : 'supabase-kong,supabase-rest,supabase-db').split(',').map(s => s.trim()).filter(Boolean),
38    onlyProblems: opts.onlyProblems === true,
39  }
40  setup = null
41  bandOrder = undefined
42
43  on('session.start', async ($, e, next) => {
44    const r = await next(e)
45    await $.command.register({ name: 'memory-status', description: 'Is the Supabase memory up? Each container, queued writes, and a fix' }).catch(() => {})
46    timer?.cancel()
47    timer = $.clock.every(EVERY_MS, () => {
48      void check($).catch(() => {})
49    })
50    void check($).catch(() => {})
51    return r
52  })
53
54  on('command.run', { command: 'memory-status' }, async $ => {
55    await check($).catch(() => {})
56    await $.ui.open({ id: PANE, title: 'Memory Status', focus: true })
57    const r = await read($, reading)
58    return { text: r ? `Memory ${r.verdict}${r.reason ? `: ${r.reason}` : ''} · ${r.pending} queued` : 'Memory Status opened.' }
59  })
60
61  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
62
63  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64    const rest = await next(e)
65    const r = await read($, reading)
66    if (e.props.hasSurvey || !r) return rest
67    if (options.onlyProblems && r.verdict === 'ok' && r.pending === 0) return rest
68    const { Box, Text, Button } = $.ui.resolve(e)
69    const isOk = r.verdict === 'ok'
70    const mine = (
71      <Box flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
72        <Text color={isOk ? 'success' : r.verdict === 'unknown' ? 'inactive' : 'error'} weight="semibold">
73          {isOk ? '◉ memory ok' : r.verdict === 'unknown' ? '○ memory: no server found' : '◌ memory degraded'}
74        </Text>
75        {r.reason && !isOk && <Text dimColor>{`· ${r.reason}`}</Text>}
76        <Text dimColor={r.pending === 0} color={r.pending > 0 ? 'warning' : undefined}>{`· ${r.pending} write${r.pending === 1 ? '' : 's'} queued`}</Text>
77        {!isOk && r.verdict !== 'unknown' && <Button key="details" label="Fix…" plain onPress={() => $.ui.open({ id: PANE, title: 'Memory Status', focus: true })} />}
78      </Box>
79    )
80    return stackBands(rest, NAME, mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => [])))
81  })
82}
83
84// ── Reading ───────────────────────────────────────────────────────────────
85
86const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
87
88async function home($: EngineInterface): Promise<string> {
89  return slash((await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? '')
90}
91
92// Where memory lives and how to ask about it: settings first, else the supabase-memory MCP server's own.
93async function findSetup($: EngineInterface): Promise<Setup> {
94  if (setup) return setup
95  const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
96  const file = config ? `${slash(config)}/.claude.json` : `${await home($)}/.claude.json`
97  const text = await $.fs.read(file).catch(() => '')
98  let found = null
99  try {
100    found = discover(JSON.parse(typeof text === 'string' ? text : ''))
101  } catch {}
102  let runtime: string | null = null
103  for (const r of ['podman', 'docker']) {
104    const v = await $.process.run([r, '--version'], { timeoutMs: 10_000 }).catch(() => null)
105    if (v?.exitCode === 0) {
106      runtime = r
107      break
108    }
109  }
110  const workspace = found?.workspace ?? ''
111  setup = {
112    url: options.url || found?.url || 'http://127.0.0.1:8100',
113    pendingFile: options.pendingFile ? slash(options.pendingFile) : (found?.pendingFile ?? ''),
114    workspace,
115    critical: options.critical,
116    runtime,
117    isConfigured: Boolean(options.url || found),
118  }
119  const compose = workspace && (await $.fs.exists(`${workspace}/docker/core.yml`).catch(() => false)) ? `${workspace}/docker/core.yml` : ''
120  await update($, fix, () => (runtime ? fixCommand(runtime, compose, options.critical) : ''))
121  return setup
122}
123
124async function check($: EngineInterface) {
125  const s = await findSetup($)
126  const res = await $.http.fetch(`${s.url}/rest/v1/`).catch(() => null)
127  const ps = s.runtime ? await $.process.run([s.runtime, 'ps', '-a', '--format', '{{.Names}}|{{.Status}}'], { timeoutMs: 20_000 }).catch(() => null) : null
128  const queue = s.pendingFile ? await $.fs.read(s.pendingFile).catch(() => '') : ''
129  const next = judge({
130    at: await $.clock.now(),
131    isConfigured: s.isConfigured,
132    gatewayStatus: res ? res.status : null,
133    ps: ps && ps.exitCode === 0 ? parsePs(ps.stdout) : null,
134    critical: s.critical,
135    optional: OPTIONAL,
136    pending: typeof queue === 'string' ? countPending(queue) : 0,
137  })
138  const before = await read($, reading)
139  await update($, reading, () => next)
140  if (!before || before.verdict !== next.verdict) {
141    await update($, history, h => [{ at: next.at, verdict: next.verdict, reason: next.reason }, ...h].slice(0, 8))
142    if (before && next.verdict === 'degraded') $.ui.toast(`Memory degraded: ${next.reason}. /memory-status has the fix`)
143    if (before?.verdict === 'degraded' && next.verdict === 'ok') $.ui.toast('Memory is back up')
144  }
145}
146
147async function readBandOrder($: EngineInterface): Promise<string[]> {
148  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), (await home($)) || undefined)
149  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
150}
151
152// ── The fix ───────────────────────────────────────────────────────────────
153
154async function copyFix($: EngineInterface) {
155  const text = await read($, fix)
156  const viaSurface = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
157  const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
158  const ok = viaSurface.isCopied || (await $.process.run(isWindows ? ['clip'] : ['pbcopy'], { stdin: text, timeoutMs: 5_000 }).catch(() => null))?.exitCode === 0
159  $.ui.toast(ok ? 'Fix command copied' : `Copy it by hand: ${text}`)
160}
161
162// Runs the targeted restart, on the second press; reads memory again after.
163async function runFix($: EngineInterface) {
164  if (!(await read($, armed))) {
165    await update($, armed, () => true)
166    return
167  }
168  await update($, armed, () => false)
169  await update($, busy, () => true)
170  const command = await read($, fix)
171  const isWindows = /^[A-Za-z]:\//.test(slash($.plugin.root))
172  const r = await $.process.run(isWindows ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', command] : ['sh', '-c', command], { timeoutMs: 180_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
173  await update($, fixOutput, () => `${r.exitCode === 0 ? 'Done' : `Exit ${r.exitCode}`}: ${(r.stderr || r.stdout).trim().split(/\r?\n/).slice(-4).join(' · ')}`)
174  await update($, busy, () => false)
175  await check($).catch(() => {})
176}
177
178// ── The pane ──────────────────────────────────────────────────────────────
179
180export function ago(at: number, now: number) {
181  const s = Math.max(0, Math.round((now - at) / 1000))
182  return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`
183}
184
185async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
186  const { Box, Text, Button } = $.ui.resolve(e)
187  const r = await read($, reading)
188  const s = setup
189  const now = await $.clock.now()
190  const command = await read($, fix)
191  const out = await read($, fixOutput)
192  const isArmed = await read($, armed)
193  const isBusy = await read($, busy)
194  const changes = await read($, history)
195  if (!r) return <Text dimColor>Checking memory…</Text>
196  const tone = r.verdict === 'ok' ? 'success' : r.verdict === 'unknown' ? 'inactive' : 'error'
197  const pill = (key: string, text: string, bg: string) => (
198    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
199      {` ${text} `}
200    </Text>
201  )
202
203  return (
204    <Box flexDirection="column" gap={1}>
205      <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
206        <Box flexDirection="column">
207          <Text size="title" weight="bold">
208            <Text color={tone}>{r.verdict === 'ok' ? '◉ ' : '◌ '}</Text>
209            {'Memory'}
210          </Text>
211          <Text size="footnote" dimColor>{`${r.verdict === 'ok' ? 'Up' : r.verdict === 'unknown' ? 'No memory server found' : `Degraded: ${r.reason}`} · checked ${ago(r.at, now)}`}</Text>
212        </Box>
213        <Box flexDirection="row" columnGap={1}>
214          <Button key="check" label="↻ Check now" onPress={() => check($)} />
215          <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
216        </Box>
217      </Box>
218
219      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
220        <Text size="caption" weight="semibold" dimColor>
221          GATEWAY
222        </Text>
223        <Box flexDirection="row" columnGap={1} alignItems="center">
224          {pill('gw', r.gateway === 'up' ? 'UP' : r.gateway === 'error' ? 'ERROR' : 'DOWN', r.gateway === 'up' ? 'success' : 'error')}
225          <Text mono>{s?.url ?? ''}</Text>
226          <Text size="footnote" dimColor>{r.gatewayStatus === null ? 'no answer' : `answered ${r.gatewayStatus}${r.gatewayStatus === 401 ? ' (wants a key: it is up)' : ''}`}</Text>
227        </Box>
228      </Box>
229
230      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
231        <Text size="caption" weight="semibold" dimColor>
232          {`CONTAINERS${s?.runtime ? ` · ${s.runtime}` : ' · no podman or docker found'}`}
233        </Text>
234        {r.containers.map(c => (
235          <Box key={`c-${c.name}`} flexDirection="row" columnGap={1} alignItems="center">
236            {pill(`cs-${c.name}`, c.isUp ? 'UP' : 'STOPPED', c.isUp ? 'success' : c.isCritical ? 'error' : 'inactive')}
237            <Text weight={c.isCritical ? 'semibold' : undefined}>{c.name}</Text>
238            <Text size="footnote" dimColor>{`${c.isCritical ? 'needed' : 'not needed for memory'} · ${c.status}`}</Text>
239          </Box>
240        ))}
241      </Box>
242
243      <Box flexDirection="column" borderStyle="round" borderColor={r.pending > 0 ? 'warning' : 'inactive'} paddingX={1}>
244        <Text size="caption" weight="semibold" dimColor>
245          QUEUED WRITES
246        </Text>
247        <Text>
248          <Text size="heading" weight="semibold" color={r.pending > 0 ? 'warning' : undefined}>{String(r.pending)}</Text>
249          <Text dimColor>{r.pending > 0 ? '  written while memory was down; they replay once it is back' : '  nothing waiting'}</Text>
250        </Text>
251        {s?.pendingFile ? <Text size="caption" mono dimColor>{s.pendingFile}</Text> : <Text size="caption" dimColor>No queue file known: set one in /plugin configure memory-status.</Text>}
252      </Box>
253
254      {command !== '' && (
255        <Box flexDirection="column" borderStyle="round" borderColor={r.verdict === 'degraded' ? 'error' : 'inactive'} paddingX={1} gap={1}>
256          <Text size="caption" weight="semibold" dimColor>
257            FIX · restarts only what memory needs, never a blanket up or down
258          </Text>
259          <Text mono size="footnote" wrap="wrap">
260            {command}
261          </Text>
262          <Box flexDirection="row" columnGap={1} alignItems="center">
263            <Button key="copy-fix" label="⧉ Copy" onPress={() => copyFix($)} />
264            {isBusy ? (
265              <Text color="suggestion">running…</Text>
266            ) : (
267              <Button key="run-fix" label={isArmed ? 'Press again to run it' : '▶ Run fix'} variant={isArmed ? 'primary' : undefined} onPress={() => runFix($)} />
268            )}
269          </Box>
270          {out !== '' && <Text size="footnote" dimColor wrap="wrap">{out}</Text>}
271        </Box>
272      )}
273
274      {changes.length > 0 && (
275        <Box flexDirection="column">
276          <Text size="caption" weight="semibold" dimColor>
277            RECENT CHANGES
278          </Text>
279          {changes.map((c, i) => (
280            <Text key={`h-${i}`} size="footnote" dimColor={i > 0}>
281              {`${ago(c.at, now)} · ${c.verdict}${c.reason ? ` · ${c.reason}` : ''}`}
282            </Text>
283          ))}
284        </Box>
285      )}
286    </Box>
287  )
288}
289
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
hooks/check.ts 88 lines
1// What memory-status reads and how it judges it. Pure: no $, so the tests call it directly.
2
3export type Found = { url: string; workspace: string; pendingFile: string; server: string } | null
4export type Container = { name: string; isUp: boolean; status: string; isCritical: boolean }
5export type Verdict = 'ok' | 'degraded' | 'unknown'
6export type Reading = {
7  at: number
8  verdict: Verdict
9  gateway: 'up' | 'error' | 'down'
10  gatewayStatus: number | null
11  containers: Container[]
12  pending: number
13  reason: string // why it is degraded, in a few words; '' when ok
14}
15
16const slash = (p: string) => p.replace(/\\/g, '/')
17
18// The memory server from a Claude Code config (~/.claude.json): its URL, and the workspace its
19// pending-write queue lives under. Finds a server named like *memory* that sets SUPABASE_MEMORY_URL
20// or runs a supabase-memory script, at the top level or in any project's mcpServers.
21export function discover(claudeJson: unknown): Found {
22  if (!claudeJson || typeof claudeJson !== 'object') return null
23  const root = claudeJson as { mcpServers?: Record<string, unknown>; projects?: Record<string, { mcpServers?: Record<string, unknown> }> }
24  const pools = [root.mcpServers ?? {}, ...Object.values(root.projects ?? {}).map(p => p?.mcpServers ?? {})]
25  for (const pool of pools) {
26    for (const [name, raw] of Object.entries(pool)) {
27      const s = raw as { args?: unknown; env?: Record<string, unknown> }
28      const env = s?.env ?? {}
29      const args = Array.isArray(s?.args) ? s.args.filter((a): a is string => typeof a === 'string').map(slash) : []
30      const script = args.find(a => /\/supabase-memory\//i.test(a))
31      if (!/memory/i.test(name) && typeof env.SUPABASE_MEMORY_URL !== 'string' && !script) continue
32      const url = (typeof env.SUPABASE_MEMORY_URL === 'string' ? env.SUPABASE_MEMORY_URL : 'http://127.0.0.1:8100').replace(/\/+$/, '')
33      // The queue is <workspace>/output/supabase-memory-pending/pending.jsonl; the workspace is set,
34      // or is the folder that holds projects/supabase-memory.
35      const workspace = typeof env.SUPABASE_MEMORY_WORKSPACE === 'string' ? slash(env.SUPABASE_MEMORY_WORKSPACE) : script?.match(/^(.*)\/projects\/supabase-memory\//i)?.[1] ?? ''
36      return { url, workspace, pendingFile: workspace ? `${workspace}/output/supabase-memory-pending/pending.jsonl` : '', server: name }
37    }
38  }
39  return null
40}
41
42// `podman ps -a --format {{.Names}}|{{.Status}}` (or docker's) as name → status text.
43export function parsePs(stdout: string): Map<string, string> {
44  const out = new Map<string, string>()
45  for (const line of stdout.split(/\r?\n/)) {
46    const at = line.indexOf('|')
47    if (at > 0) out.set(line.slice(0, at).trim(), line.slice(at + 1).trim())
48  }
49  return out
50}
51
52export function countPending(text: string): number {
53  return text.split(/\r?\n/).filter(l => l.trim()).length
54}
55
56// One reading from what was seen: the gateway answered (any status) or not, the containers'
57// states, the queue. Degraded when the gateway is down or a critical container is not up.
58export function judge(args: {
59  at: number
60  isConfigured: boolean
61  gatewayStatus: number | null // null: no answer
62  ps: Map<string, string> | null // null: no container runtime to ask
63  critical: string[]
64  optional: string[]
65  pending: number
66}): Reading {
67  const { at, gatewayStatus, ps, critical, optional, pending } = args
68  const gateway: Reading['gateway'] = gatewayStatus === null ? 'down' : gatewayStatus >= 500 ? 'error' : 'up'
69  const containers: Container[] = ps
70    ? [...critical.map(n => [n, true] as const), ...optional.map(n => [n, false] as const)]
71        .filter(([n, isCritical]) => isCritical || ps.has(n))
72        .map(([name, isCritical]) => {
73          const status = ps.get(name) ?? 'not found'
74          return { name, isCritical, status, isUp: /^up\b/i.test(status) }
75        })
76    : []
77  const stopped = containers.filter(c => c.isCritical && !c.isUp).map(c => c.name)
78  if (!args.isConfigured) return { at, verdict: 'unknown', gateway, gatewayStatus, containers, pending, reason: 'no memory server found' }
79  const reason = gateway === 'down' ? `gateway not answering${stopped.length ? ` (${stopped.join(', ')} stopped)` : ''}` : gateway === 'error' ? `gateway error ${gatewayStatus}` : stopped.length ? `${stopped.join(', ')} stopped` : ''
80  return { at, verdict: reason ? 'degraded' : 'ok', gateway, gatewayStatus, containers, pending, reason }
81}
82
83// The targeted restart for a compose file: only the critical services, never a blanket up or down.
84export function fixCommand(runtime: string, composeFile: string, critical: string[]): string {
85  const services = critical.map(c => c.replace(/^supabase-/, '')).join(' ')
86  return composeFile ? `${runtime} compose -f "${composeFile}" up -d --no-deps ${services}` : ''
87}
88
types/index.d.ts 26 lines
1// One container memory needs, and one reading of the whole; hooks/check.ts holds the same shapes.
2export type Container = { name: string; isUp: boolean; status: string; isCritical: boolean }
3export type Reading = {
4  at: number
5  verdict: 'ok' | 'degraded' | 'unknown'
6  gateway: 'up' | 'error' | 'down'
7  gatewayStatus: number | null
8  containers: Container[]
9  pending: number
10  reason: string
11}
12export type Change = { at: number; verdict: 'ok' | 'degraded' | 'unknown'; reason: string }
13
14declare module 'claude-code' {
15  interface PluginState {
16    'memory-status': {
17      reading: Reading | null
18      history: Change[] // the last state changes, newest first
19      fix: string // the restart command, '' when there is none
20      fixOutput: string // what the last run of the fix printed
21      armed: boolean // Run fix was pressed once; a second press runs it
22      busy: boolean
23    }
24  }
25}
26