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.

<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> · <a href="#one-desk-for-each-project">Tour</a> · <a href="#how-it-works">How it works</a> · <a href="docs/GUIDE.md">Guide</a> · <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.
| macOS | brew install --cask snymrova/snyvi/snyvi | |
| Linux | `curl -fsSL https://raw.githubusercontent.com/snymrova/snyvi/main/install.sh \ | sh` |
| Windows | winget 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
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">
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.
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">
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">
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">
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">
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
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
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.
127.0.0.1 only.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
hooks/register.tsx 298 lines1// 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}
298types/index.d.ts 10 lines1// 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