SLOPSHOPPER

snyvi

snyvi in a desk's panel: the thread above the prompt, Claude's questions on Your turn, what git and gh did, and /note with no turn spent.

newbandguardcommandtoaststatus
v1.29.0MITupdated 2026-10-09snymrova/snyvi/mod
A shopper browsing a rack in a slop shop
README

<img src="icons/icon.svg" width="96" alt="">

<h1 align="center">snyvi</h1>

A desktop app where your coding agents work, one desk for each project,<br> and everything they write is kept as a page you can read.

<a href="https://github.com/snymrova/snyvi/releases/latest"><img src="https://img.shields.io/github/v/release/snymrova/snyvi?style=flat-square&color=c8420f&label=release" alt="latest release"></a> <a href="https://github.com/snymrova/snyvi/stargazers"><img src="https://img.shields.io/github/stars/snymrova/snyvi?style=flat-square&color=555" alt="stars"></a> <a href="https://github.com/snymrova/snyvi/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/snymrova/snyvi/ci.yml?style=flat-square&label=ci" alt="ci"></a> <img src="https://img.shields.io/badge/linux%20%C2%B7%20macos%20%C2%B7%20windows-555?style=flat-square" alt="Linux, macOS and Windows"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555?style=flat-square" alt="MIT"></a>

<a href="#install"><b>Install</b></a> &nbsp;·&nbsp; <a href="#one-desk-for-each-project">Tour</a> &nbsp;·&nbsp; <a href="#how-it-works">How it works</a> &nbsp;·&nbsp; <a href="docs/GUIDE.md">Guide</a> &nbsp;·&nbsp; <a href="#questions">Questions</a>

<!-- The film is docs/media/demo.mp4, cut in film/ -- see film/README.md. GitHub plays a video inline only from a user-attachments URL, so after a re-cut the file is dropped into a comment box once and the link it gives replaces this one.

The pictures below are the film's own camera: film/shoot.mjs against the release binary, live Claude Code in every panel, at 2x in both themes. -->

https://github.com/user-attachments/assets/71a5b82e-3eaa-4529-9160-dbe453b6b430

You have more passion projects than hours in the day. With coding agents, you can finally build them all, at the same time.

But the work scatters. Plans get lost in terminal scrollback. What's left to do lives in your head. And every project pulls you away from the last.

snyvi keeps it all in order. One desk for each project, its agents side by side, and everything they write kept where you can read it. It runs on your machine, with no account and nothing to configure.

Install

macOSbrew install --cask snymrova/snyvi/snyvi
Linux`curl -fsSL https://raw.githubusercontent.com/snymrova/snyvi/main/install.sh \sh`
Windowswinget install snymrova.snyvi, or scoop bucket add snyvi https://github.com/snymrova/scoop-snyvi then scoop install snyvi, or the [installer][win]

Then open snyvi from your apps, or run snyvi app. It asks what you're working on, makes that project's first desk, and connects Claude Code from the window. From then on it updates itself, once a day, when you're not looking.

The one-liner works on a Mac too, with no root on Linux. It checks every download against its checksum. Read it first. .deb, cargo install snyvi and the rest are in the install guide.

[win]: https://github.com/snymrova/snyvi/releases/latest/download/snyvi-windows-x64-setup.exe

One desk for each project

A project's folder with up to four real shells in it: Claude Code, Codex, your tests, a git log. Every agent on the project works in view, and each panel says whether its agent is working, done, or waiting on you.

<img src="docs/media/desk-light.webp" width="900" alt="The ledger desk in snyvi: four Claude Code sessions side by side in their own panels, the projects in the sidebar, and the desk's panels, documents and notes in the rail">

A friend's snyvi

Pair once, with three words said over a call, and a document's menu gains Send to… It lands in your friend's sidebar under From you within a second, sealed to their key on the way; a relay holds the ciphertext for a week at most, reads none of it, and keeps it for a snyvi that is asleep. An agent can offer a document to a friend, and you press Send. Nothing else travels.

Room to focus

When one agent needs all of you, fold the sidebar and the rail away and give its panel the whole window. ⌃⌥Z puts it back in the grid. The others keep working, and their tabs say when one needs you.

<img src="docs/media/focus-light.webp" width="900" alt="One Claude Code session in full view, filling the window: its review of the rate limiter, with tabs for the desk's other three panels in the head">

Nothing scrolls away

Plans and reviews land on the desk that wrote them, marked with the panel they came from, and open as clean pages. They're filed under their project and kept with every earlier version. c shows what changed.

<img src="docs/media/over-light.webp" width="900" alt="A plan open over the ledger desk: the document in the middle, and the rail still listing the desk's four panels, what they sent, and its notes">

Out of your head

Each desk keeps its own notes, so what's left to do stays with the project. The agents at that desk read them, say how far they've got, and tick one off with the commit it went into. Home shows every desk's list and where each was left.

<img src="docs/media/home-light.webp" width="900" alt="snyvi's Home: the ledger desk offered to pick up, with where it was left and its two open notes, one being worked on and one planned; the other desks below with theirs; a parked project; and how much of Claude's window is left">

Just as you left it

Switch projects and each desk is exactly as you left it. The agents you walked away from keep working. ⌘K searches everything any agent ever sent.

<img src="docs/media/switch-light.webp" width="900" alt="The gateway desk: two Claude Code sessions on a different project, with its own documents and notes in the rail">

Any agent

Claude Code, Codex, Cursor, Gemini, and anything else that speaks MCP.

snyvi init-claude --auto   # Claude Code
snyvi init codex           # also cursor, claude-desktop, gemini, windsurf, vscode, zed

Your sidebars

Arrange both sidebars on /sidebars: drag, switch off, reset. Give a desk a small status box an agent keeps up to date, or one a script fills, or one snyvi runs for you on a timer, like the branch you're on and how far ahead it is. Write a widget.

snyvi widget set backups --tone ok "Nightly backup **done**"
snyvi widget new git && snyvi widget check git

How it works

flowchart LR
  A["An agent in a panel<br/>Claude Code, Codex, a shell"] -- "send_document, notes, leave_off<br/>over MCP" --> D["snyvi<br/>one binary on 127.0.0.1"]
  A -. "hooks: working, done, needs you" .-> D
  D --> K["The desk<br/>panels, its documents, its notes"]
  D --> L["The library<br/>every version, searchable"]
  D --> H["Home<br/>where each project was left"]

snyvi runs the panels itself, so an agent in one is a real terminal session on your machine. What the agent writes arrives over MCP, is filed by the folder it came from, and lands on the desk of the panel that sent it. A document sent from anywhere else lands in the inbox, under its project.

Private, and fast

  • One static binary, and a small window that opens it, listening on 127.0.0.1 only.
  • No account, no telemetry, and nothing leaves your machine.
  • Up in 11 ms, and every budget is checked on every push.
  • Updates are signed, and checked before they're applied.

Questions

Yes, and open source under the MIT licence. There is no paid tier.

No. snyvi listens only on 127.0.0.1, has no account and sends no telemetry. The one request it makes to the outside is the daily update check, and snyvi update off turns that off.

No. Any agent anywhere can send snyvi what it writes, and it lands in the inbox under its project. Desks are for when you want the agents in view.

The Windows installer isn't signed yet, so SmartScreen asks once: More info, then Run anyway. Scoop installs without the prompt.

The one in the corner is snyvi, and the app icon is the same drawing. It has six faces and speaks only when spoken to. They're MIT like the rest, so use them for anything about snyvi.


<a href="docs/GUIDE.md">Guide</a> · <a href="docs/DESK.md">Desks</a> · <a href="docs/ROADMAP.md">Roadmap</a> · <a href="film/README.md">The film</a> · MIT

Source 2 files
hooks/register.tsx 298 lines
1// The snyvi mod (#90): loaded only in a snyvi desk's panels, through the
2// CLAUDE_CODE_PLUGIN_DIRS the daemon sets on the panel it starts, and the
3// same version as the snyvi that wrote it (src/claude_mod.rs).
4//
5// It talks to one place, the local daemon, with the pane's own token, about
6// the pane it runs in. It never starts a turn ($.prompt.submit), never
7// approves or blocks a tool, and never changes what Claude asked or ran.
8// When the daemon is away every call fails quietly to one status line, and
9// Claude goes on as if there were no mod.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14const band = atom({ plugin: 'snyvi', key: 'band' } as const, '')
15
16/** Where the daemon is and the pane this session runs in; null outside a
17 *  snyvi panel, where every hook passes straight through. */
18type Link = { url: string; token: string; pane: string }
19let link: Link | null = null
20/** The pane's thread, as the band last read it: what the CI timer watches. */
21let thread: { pr?: string; ci?: string; merged?: string; stage?: string } | null = null
22let away = false
23
24async function connect($: EngineInterface): Promise<Link | null> {
25  const pane = await $.env.get('SNYVI_SESSION')
26  if (!pane || !/^[0-9a-f]{32}$/.test(pane)) return null
27  try {
28    const cfg = JSON.parse(await $.fs.read(`${$.plugin.root}/snyvi.json`))
29    const token = (await $.fs.read(cfg.token_file)).trim()
30    return { url: cfg.url, token, pane }
31  } catch {
32    return null
33  }
34}
35
36/** One call to the pane's own routes. Null when the daemon is away or said
37 *  no; the status line says so once, and clears when it answers again. */
38async function call(
39  $: EngineInterface,
40  path: string,
41  body?: unknown,
42): Promise<{ status: number; json: any } | null> {
43  if (!link) return null
44  try {
45    const r = await $.http.fetch(`${link.url}/api/panes/${link.pane}/${path}`, {
46      method: body === undefined ? 'GET' : 'POST',
47      headers: { authorization: `Bearer ${link.token}`, 'content-type': 'application/json' },
48      body: body === undefined ? undefined : JSON.stringify(body),
49    })
50    if (away) {
51      away = false
52      $.ui.status(undefined)
53    }
54    let json: any = null
55    try {
56      json = r.text ? JSON.parse(r.text) : null
57    } catch {}
58    return { status: r.status, json }
59  } catch {
60    if (!away) {
61      away = true
62      $.ui.status('snyvi not reachable')
63    }
64    return null
65  }
66}
67
68/** The tag of the band the daemon last gave (`v`), sent back so the daemon
69 *  holds the next call until the band differs. */
70let tag = ''
71/** The round of the band's long-poll in flight, so a reload or a second
72 *  session.start never leaves two chains running. */
73let round: { cancel(): void } | null = null
74
75/** The band taken from an answer: the thread, the tag, and the line drawn
76 *  when it changed. */
77async function took($: EngineInterface, j: any) {
78  thread = j.thread
79  if (typeof j.v === 'string') tag = j.v
80  const line: string = j.line || ''
81  if (line !== (await read($, band))) await update($, band, () => line)
82}
83
84async function refresh($: EngineInterface) {
85  const r = await call($, 'band')
86  if (!r || r.status !== 200) return
87  await took($, r.json)
88}
89
90/** How long until the next round, from what this one came to: at once on a
91 *  band or a 204 (the daemon held the call), half a minute after anything
92 *  else, so a daemon that is away is asked twice a minute and not a
93 *  thousand times. */
94export function nextRoundIn(r: { status: number } | null): number {
95  return r && (r.status === 200 || r.status === 204) ? 0 : 30_000
96}
97
98/** One round of the band's long-poll: the tag of the band this panel has
99 *  goes up, and the daemon answers when the band differs, or 204 after 25 s.
100 *  Each round sets up the next through the clock, so a reload of the mod,
101 *  which cancels its pending waits, ends the chain with it. */
102async function bandRound($: EngineInterface) {
103  const r = await call($, `band?v=${tag}`)
104  if (r && r.status === 200) await took($, r.json)
105  round = $.clock.after(nextRoundIn(r), () => void bandRound($))
106}
107
108/** What a `git`/`gh` command did, read from the command and its output. */
109export function sawIn(command: string, stdout: string): Record<string, unknown> | null {
110  const c = command.replace(/\s+/g, ' ')
111  const made = /\bgit (?:switch -c|checkout -b|worktree add(?: [^|;&]*)? -b) ([^\s;&|]+)/.exec(c)
112  if (made) return { branch: made[1] }
113  if (/\bgit commit\b/.test(c) && !/--dry-run/.test(c)) {
114    const m = /^\[([^\s\]]+)(?: \(root-commit\))? ([0-9a-f]{7,40})\]/m.exec(stdout)
115    if (m) return { branch: m[1], commits: 1 }
116    return null
117  }
118  if (/\bgh pr create\b/.test(c)) {
119    const m = /https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/.exec(stdout)
120    if (m) return { pr: m[1] }
121  }
122  return null
123}
124
125/** The checks as one word: `9/13` while running, `passing`, `failing`. */
126export function ciOf(rollup: Array<{ conclusion?: string; status?: string; state?: string }>): string {
127  if (!rollup.length) return ''
128  const done = rollup.filter(r => (r.status || 'COMPLETED') === 'COMPLETED' || r.state)
129  const bad = rollup.some(r => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(r.conclusion || r.state || ''))
130  if (bad) return 'failing'
131  if (done.length < rollup.length) return `${done.length}/${rollup.length}`
132  return 'passing'
133}
134
135/** The PR whose checks the watch gave up on -- closed or merged -- so gh is
136 *  not asked about it every minute for the rest of the session. A new PR on
137 *  the thread is watched afresh. */
138let settledPr = ''
139
140/** What the watch should do with what gh said, given what the band last
141 *  showed: the sighting to file (only when the checks or the merge differ
142 *  from the thread), and whether to stop watching this PR. */
143export function prSighting(
144  j: { state?: string; statusCheckRollup?: any[]; mergeCommit?: { oid?: string } },
145  t: { ci?: string; merged?: string },
146): { seen: Record<string, unknown> | null; settled: boolean } {
147  const ci = ciOf(j.statusCheckRollup || [])
148  const merged = j.state === 'MERGED' && j.mergeCommit?.oid ? j.mergeCommit.oid : ''
149  const seen: Record<string, unknown> = {}
150  if (ci && ci !== (t.ci || '')) seen.ci = ci
151  if (merged && merged !== (t.merged || '')) seen.merged = merged
152  return { seen: Object.keys(seen).length ? seen : null, settled: j.state === 'MERGED' || j.state === 'CLOSED' }
153}
154
155async function watchPr($: EngineInterface) {
156  if (!thread || !thread.pr || thread.merged || thread.pr === settledPr) return
157  let out
158  try {
159    out = await $.process.run(['gh', 'pr', 'view', thread.pr, '--json', 'state,statusCheckRollup,mergeCommit'])
160  } catch {
161    return
162  }
163  if (out.exitCode !== 0) return
164  try {
165    const { seen, settled } = prSighting(JSON.parse(out.stdout), thread)
166    if (settled) settledPr = thread.pr
167    if (seen) {
168      await call($, 'seen', seen)
169      await refresh($)
170    }
171  } catch {}
172}
173
174/** Wait for the reader's answer in snyvi, a long-poll at a time, until there
175 *  is one, the question is put away, or the hook is abandoned. */
176async function answered($: EngineInterface, turn: number, signal: AbortSignal): Promise<string | null> {
177  while (!signal.aborted) {
178    const r = await call($, `turns/${turn}`)
179    if (!r) return null
180    if (r.status === 200 && r.json?.answer) return r.json.answer
181    if (r.status !== 204) return null
182  }
183  return null
184}
185
186export const register: Register = on => {
187  on('session.start', async ($, e, next) => {
188    link = await connect($)
189    if (link) {
190      await $.command.register({ name: 'note', description: 'Add a line to this desk’s notes, with no turn spent', argumentHint: '[text]', immediate: true })
191      await $.command.register({ name: 'turn', description: 'What is waiting on you on this desk', immediate: true })
192      await $.command.register({ name: 'park', description: 'Park this panel’s thread, with the next step', argumentHint: '[next step]', immediate: true })
193      await $.command.register({ name: 'thread', description: 'This panel’s thread, in a few lines', immediate: true })
194      round?.cancel()
195      round = $.clock.after(0, () => void bandRound($))
196      $.clock.every(60_000, () => watchPr($))
197    }
198    return next(e)
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    const line = await read($, band)
203    if (!link || !line) return next(e)
204    const { Box, Text } = $.ui.resolve(e)
205    return (
206      <Box>
207        <Text dimColor>{line}</Text>
208      </Box>
209    )
210  })
211
212  // Claude's own question, mirrored to Your turn in snyvi and answered from
213  // either side: whichever answer comes first is the tool's result, and the
214  // other side is told and closes. One question with single choice only; a
215  // form of several is the terminal's.
216  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
217    const qs = e.questions || []
218    if (!link || qs.length !== 1 || qs[0].multiSelect) return next(e)
219    const q = qs[0]
220    const asked = await call($, 'ask', {
221      kind: 'decide',
222      via: 'dialog',
223      text: q.question,
224      options: q.options.map(o => o.label),
225    })
226    if (!asked || asked.status !== 201) return next(e)
227    const turn: number = asked.json.turn.id
228    const native = next(e).then(r => ({ from: 'panel' as const, r }))
229    const snyvi = answered($, turn, next.signal).then(a => ({ from: 'snyvi' as const, a }))
230    // snyvi with no answer (put away, or the daemon gone) leaves the dialog
231    // to settle it.
232    const first = await Promise.race([native, snyvi.then(s => (s.a ? s : native))])
233    if (first.from === 'snyvi') {
234      $.ui.toast('Answered in snyvi')
235      void refresh($)
236      return { result: { questions: e.questions, answers: { [q.question]: first.a } } } as any
237    }
238    const a = (first.r as any)?.result?.answers?.[q.question]
239    await call($, `turns/${turn}`, a ? { answer: String(a) } : { drop: true })
240    void refresh($)
241    return first.r
242  }).catch(($, e, next) => next(e))
243
244  // What git and gh did in the panel, seen after the command ran: a branch,
245  // a commit, a PR. Observe only: the call and its result go on unchanged.
246  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
247    const r = await next(e)
248    if (!link) return r
249    const command = String((e as any).command || '')
250    if (!/\b(git|gh)\b/.test(command)) return r
251    const seen = sawIn(command, String((r as any)?.result?.stdout || ''))
252    if (seen) {
253      await call($, 'seen', seen)
254      void refresh($)
255    }
256    return r
257  }).catch(($, e, next) => next(e))
258
259  on('command.run', { command: 'note' }, async ($, e) => {
260    const text = String(e.args || '').trim()
261    if (!text) return { text: 'Usage: /note what to remember' }
262    const r = await call($, 'note', { text })
263    if (r?.status === 201) $.ui.toast(`Note #${r.json.note.id} added to the desk`)
264    else $.ui.toast(r?.json?.error ? `Not added: ${r.json.error}` : 'snyvi is not reachable')
265    return {}
266  })
267
268  on('command.run', { command: 'turn' }, async $ => {
269    const r = await call($, 'band')
270    const waiting: Array<{ kind: string; text: string }> = r?.json?.turns || []
271    if (!waiting.length) $.ui.log('Nothing is waiting on you on this desk.')
272    for (const w of waiting) $.ui.log(`your turn · ${w.kind} · ${w.text}`)
273    return {}
274  })
275
276  on('command.run', { command: 'park' }, async ($, e) => {
277    const r = await call($, 'thread/move', { stage: 'parked', next: String(e.args || '').trim(), reader: true })
278    if (r?.status === 200) $.ui.toast(`Parked: ${r.json.thread.name}`)
279    else $.ui.toast(r?.json?.error || 'snyvi is not reachable')
280    void refresh($)
281    return {}
282  })
283
284  on('command.run', { command: 'thread' }, async $ => {
285    const r = await call($, 'band')
286    const t = r?.json?.thread
287    if (!t) {
288      $.ui.log('This panel has no thread yet. Claude starts one with start_thread.')
289      return {}
290    }
291    $.ui.log(`${t.name} · ${t.stage}${t.notes?.length ? ` · ${t.notes.map((n: number) => `#${n}`).join(' ')}` : ''}`)
292    if (t.folder || t.branch) $.ui.log(`${t.folder || ''}${t.folder && t.branch ? ' · ' : ''}${t.branch || ''}${t.commits ? ` · ${t.commits} commits` : ''}`)
293    if (t.pr) $.ui.log(`PR ${t.pr}${t.ci ? ` · CI ${t.ci}` : ''}${t.merged ? ` · merged ${t.merged.slice(0, 7)}` : ''}`)
294    if (t.next) $.ui.log(`next: ${t.next}`)
295    return {}
296  })
297}
298
types/index.d.ts 10 lines
1// The snyvi mod's state: the band's line above the prompt, as the daemon last
2// gave it (`GET /api/panes/{id}/band`). Empty draws nothing.
3export type Band = string
4
5declare module 'claude-code' {
6  interface PluginState {
7    snyvi: { band: Band }
8  }
9}
10