SLOPSHOPPER

space-channel

SpaceChannel bridge: mirrors the session to the SpaceNotes app and delivers phone messages as typed prompts

newguardpromptprocesstimer
★ 7v0.3.0GPL-3.0updated 2026-10-08mikaelwills/SpaceNotes/space-channel/plugin
A shopper browsing a rack in a slop shop
README

<img src="assets/android/mipmap-xxxhdpi/spacenotes2.png" width="128" alt="SpaceNotes Logo" />

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

Yet another note-taking system... 🙄

But notes, files and passwords synced across all your devices in real time. No cost. No Obsidian. No cloud. No storage limits.

Your vault is plain files on your own filesystem: markdown notes, media, PDFs and a pass-compatible password store. Portable, no lock-in, no subscription, nothing to migrate off if you ever want to walk away. A built-in MCP server lets AI assistants like Claude Code and Cursor read and write it.

Contributions welcome.

Desktop Notes View

<img src="assets/screenshots/mobile-notes.png" width="45%" alt="Mobile Notes View" />

How it compares

FeatureSpaceNotesObsidian SyncNotionNotesnookBasic Memory
Self-hostedYesNoNoYesNo
Real-time syncYesYesYesYesYes
Mobile + WebYesMobile onlyYesYesWeb only
AI integrationMCPNoneBuilt-inNoneMCP
Plain filesYesYesNoPartialYes
Data ownershipFullPartialNoneFullPartial
CostFree$8/moFree/$10/moFree/$5/moPaid

Requirements:

  • A server or laptop.
  • Comfort with Docker and basic command line
  • A private network setup (Tailscale, WireGuard, or similar)

Current limitations:

  • No hosted option - you must run your own server
  • No E2E encryption for notes - security comes from self-hosting on a private network (passwords are GPG-encrypted at rest)
  • No multi-user collaboration yet
  • Early-stage software - expect rough edges

Architecture

One Docker container on your server runs everything:

  • SpacetimeDB holds the notes: file metadata and markdown content.
  • The sync daemon keeps the vault folder and SpacetimeDB in step in both directions, and serves every file byte over HTTP.
  • The MCP server gives AI assistants read/write access to the vault.
  • nginx serves the web client and proxies /files, /thumbnails and /uploads to the daemon.

The Flutter client (iOS, Android, macOS, Windows, Linux, web) subscribes to SpacetimeDB for notes and talks to the daemon over HTTP for file bytes.

The vault on disk is the ground truth. The database can be wiped and rebuilt from it; a note's identity (its UUID) is kept in the daemon's own journal, so vault files stay byte-for-byte what you wrote, with nothing injected into them.

Components

  • SpacetimeDB - Real-time database holding the notes. Clients connect once and receive instant updates.
  • Filesystem sync daemon - Watches the vault and syncs bidirectionally with SpacetimeDB. Also the file server: ranged downloads, resumable uploads, and video/image thumbnails (ffmpeg).
  • MCP server - Lets Claude Code, Cursor and other assistants search, read, write and organise the vault, and hand large files in and out.
  • SpaceChannel - Links a running Claude Code session to the app's chat: a Claude Code plugin plus a small bridge binary. Messages, the agent's progress notes, tool calls, permission prompts and questions all flow through SpacetimeDB.
  • Flutter client - Native apps for iOS, Android, macOS, Windows, Linux, and web.

Standard Ports

  • 5050 - SpacetimeDB (WebSocket/HTTP). The Flutter client connects here.
  • 5051 - HTTP: the web client, plus /files/ (downloads, whole-file uploads), /uploads (resumable uploads) and /thumbnails/.
  • 5052 - MCP server (HTTP), /mcp.

All ports are configurable via docker-compose.yml.

Files, downloads and uploads

The vault isn't only markdown. Images (jpg, png, gif, webp, heic), audio (mp3, wav, m4a, aac, flac, ogg), video (mp4, mov, m4v, webm), PDFs and .gpg files are all first-class; anything else is ignored.

  • Large files never go through the database. Binaries under 20KB are stored inline; anything bigger is a few hundred bytes of metadata in SpacetimeDB, with the bytes on disk and served over HTTP. An 80MB video doesn't touch the commitlog.
  • Downloads are on demand and resumable. Opening a file downloads it to the device and caches it; an interrupted download resumes from where it stopped via HTTP Range. A download that slows to a crawl reconnects itself, and reopening a file cancels any stale transfer before resuming. Downloads are verified by size and SHA-256 before they count as complete.
  • Offloading. Downloaded files can be offloaded from the device (Settings → downloaded files) and fetched again when needed.
  • Uploads are resumable. Files over 8MB go up in 4MB chunks over a tus-style protocol (POST/HEAD/PATCH /uploads). An upload survives the app being backgrounded or killed and continues on next launch from the server's offset.
  • No silent overwrites. Uploading onto an existing name is refused (HTTP 409) by the server, not the client, so it holds even before the app has synced.
  • Thumbnails are generated server-side for images and video.
  • Multiple files at once. Multi-select in the file grid to move or delete several files together.

The web client is notes-only: downloading and uploading binary files needs a native app.

Password manager

SpaceNotes can hold a pass-compatible password store: GPG-encrypted .gpg files under .password-store/ in the vault, synced like any other file.

  • Import your GPG private key on each device (Settings → password manager) to reveal passwords. The key stays on that device; the server only ever sees ciphertext.
  • Browse and search credentials from the key icon in the nav, view/copy fields, and create new entries with a built-in password generator.
  • The whole feature can be switched off per device (Settings → preferences), which removes it from the nav, the desktop sidebar and settings.

Flutter Client Features

Notes and files:

  • Real-time sync across all devices via SpacetimeDB, with an offline cache
  • Recents page: Recently Viewed (what this device opened, tracked locally) and Recently Updated (what changed in the vault)
  • Fuzzy search, folders, favourite folders, masonry grid of file cards
  • Markdown editing; generative-UI "dashboard" notes (KPIs, charts, editable fields)
  • Viewers for images and video; PDFs and other files download to the device
  • Audio player with a native parametric EQ, scrolling waveform scrubber, background/lock-screen playback and a persistent mini player

Agent chat:

  • Chat with a Claude Code session from the app; the agent's short progress notes appear between its tool calls as it works
  • Tool calls show in the chat, with Edit and Write rendered as a red/green diff
  • Approve or deny tool permissions and answer the agent's questions from the app
  • Stop a running turn, or background a long-running tool

Mobile (iOS/Android):

  • Recents, folders and passwords one tap apart in the nav bar

Desktop (macOS/Windows/Linux/Web):

  • Finder-style layout: sidebar and tabbed notes with back navigation
  • Drag and drop file organisation, keyboard navigation (Shift+Tab cycles screens)

Quick Start

  1. Download docker-compose.yml: ``bash curl -O https://raw.githubusercontent.com/mikaelwills/SpaceNotes/master/docker-compose.yml ``
  1. Edit it - set your notes folder and the address clients reach the server on: ```yaml volumes:
  2. /path/to/your/notes:/vault environment:
  3. DATA_DIR=/data
  4. SPACENOTES_FILES_HOST=http://<your-server-ip>:5051 `` SPACENOTES_FILES_HOST` is required: the MCP server hands URLs on this host to AI assistants for file transfers, so it must be the address your other machines use (e.g. the server's Tailscale IP). The MCP server won't start without it.
  1. Start: ``bash docker-compose up -d `` Docker pulls the pre-built image. First run takes a minute to download.
  1. Verify it's running: ``bash docker logs spacenotes `` You should see "Watcher started on /vault" when ready.
  1. Access SpaceNotes:
  2. Web client: http://<your-server-ip>:5051
  3. Mobile/desktop app: enter <your-server-ip> in Settings → server
  4. MCP server: http://<your-server-ip>:5052/mcp

Updating

docker-compose pull && docker-compose up -d

Your notes are safe - they live on your filesystem, not in the database. Keep the named volumes: spacetime-config holds the database owner identity (lose it and the module can't be republished), and spacenotes-daemon-data holds the note identity journal.

MCP Integration (Claude Code)

Configure Claude Code

claude mcp add spacenotes-mcp --type http --url "http://<your-server-ip>:5052/mcp" --scope user

Or add to ~/.claude.json:

{
  "mcpServers": {
    "spacenotes-mcp": {
      "type": "http",
      "url": "http://<your-server-ip>:5052/mcp"
    }
  }
}

Available MCP Tools

Find and read:

  • search_files - search by title, path or content
  • search_files_content - search and return excerpts around matches
  • get_file / get_files - full content of one or several files, by id or path (optionally one heading or a line range)
  • list_folder - immediate subfolders and files of a folder
  • vault_index - every folder path, plus files under chosen folders
  • get_backlinks / get_outbound_links - link graph for a note

Write:

  • create_file - create a note
  • edit_file - find-and-replace, one or many edits in one commit
  • regex_replace, replace_across_files - pattern replace in one note or across many
  • append_to_file / prepend_to_file

Organise:

  • move_file, move_files_to_folder
  • delete_file, delete_files
  • create_folder, move_folder, delete_folder, empty_folder

Binary files:

  • upload_file / download_file - return a plain HTTP URL; the assistant moves the bytes itself (curl -T / curl -o), so large files never pass through a tool call

Configuration

Environment variables (set in docker-compose.yml):

  • SPACENOTES_FILES_HOST - required. Externally reachable base URL of port 5051, used in upload_file/download_file URLs
  • DATA_DIR - daemon state (note identity journal); point at the /data volume
  • VAULT_PATH - path to the vault inside the container (default: /vault)
  • SPACETIME_HOST - SpacetimeDB URL, internal (default: http://127.0.0.1:3000)
  • SPACETIME_DB - database name (default: spacenotes)

License

GPL-3.0 - This project is free software. Any derivative work must also be open source under the same license.

Source 1 files
hooks/register.ts 386 lines
1import type { Register } from 'claude-code'
2
3const BRIDGE = 'space-channel'
4const POLL_TIMEOUT_MS = 20_000
5const PERMISSION_TIMEOUT_MS = 9.5 * 60_000
6const QUESTION_TIMEOUT_MS = 10 * 60_000
7
8const INTERNAL_TOOLS = new Set([
9  'push_message',
10  'edit_message',
11  'push_status',
12  'push_tool_event',
13  'push_context_usage',
14  'next_message',
15  'request_permission',
16  'poll_permission',
17  'request_question',
18  'poll_question',
19])
20
21type Inbound = { id: string; text: string; source: string; sender: string; imagePaths?: string[] }
22
23let server = ''
24let turnRunning = false
25let lastPromptFromPhone = false
26let currentTurnId: string | undefined
27let draining = false
28let toolsRunning = 0
29let heldProgress = ''
30let herdrPane: string | undefined
31let herdrBin: string | undefined
32const deliveries: Inbound[] = []
33
34function internalToolName(tool: string): string | undefined {
35  const m = /^mcp__.+__([a-z_]+)$/.exec(tool)
36  if (!m) return undefined
37  return INTERNAL_TOOLS.has(m[1]!) ? m[1] : undefined
38}
39
40function toolArguments(e: Record<string, unknown>): Record<string, unknown> {
41  const { tool: _tool, tool_use_id: _id, consent: _consent, agentId: _agent, ...rest } = e
42  return rest
43}
44
45async function bridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
46  if (!server) throw new Error('bridge not connected')
47  const r = await $.mcp.call(server, tool, args)
48  const text = r.content.find((b: any) => b.type === 'text')?.text ?? ''
49  if (r.isError) throw new Error(text || `${tool} failed`)
50  try {
51    return JSON.parse(text)
52  } catch {
53    return text
54  }
55}
56
57const BRIDGE_TIMEOUT_MS = 3_000
58const LONG_POLLS = new Set(['next_message', 'poll_question', 'poll_permission'])
59
60function withTimeout<T>(call: Promise<T>, ms: number, what: string): Promise<T> {
61  return new Promise((resolve, reject) => {
62    const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
63    call.then(
64      (v) => {
65        clearTimeout(timer)
66        resolve(v)
67      },
68      (e) => {
69        clearTimeout(timer)
70        reject(e)
71      },
72    )
73  })
74}
75
76async function tryBridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
77  try {
78    const call = bridge($, tool, args)
79    return await (LONG_POLLS.has(tool) ? call : withTimeout(call, BRIDGE_TIMEOUT_MS, tool))
80  } catch (err) {
81    $.ui.log(`${tool} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
82    return undefined
83  }
84}
85
86function touchActivity(fromPhone: boolean) {
87  lastPromptFromPhone = fromPhone
88}
89
90async function deliver($: any, m: Inbound) {
91  touchActivity(true)
92  const fromAgent = m.source.startsWith('agent:')
93  let text = m.text.trim()
94  const images = m.imagePaths ?? []
95  if (!text && images.length > 0) text = images.length === 1 ? '(image)' : '(images)'
96  if (images.length === 1) text += `\n\n(image attached at ${images[0]} — read it with the Read tool)`
97  if (images.length > 1) {
98    text += `\n\n(${images.length} images attached — read each with the Read tool:\n${images.map(p => `- ${p}`).join('\n')})`
99  }
100  if (fromAgent) {
101    text += `\n\n(a2a message from agent '${m.sender}' — to answer THEM use send_to_agent('${m.sender}'); plain text goes to your own human's chat, not to them)`
102    await $.prompt.submit({ text })
103    return
104  }
105  if (images.length === 0 && (await runSlashCommand($, text))) return
106  await $.prompt.submit({ text, asUser: true })
107}
108
109async function runSlashCommand($: any, text: string): Promise<boolean> {
110  const match = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/.exec(text)
111  if (!match) return false
112  while (turnRunning) await $.clock.sleep(500)
113  let result: any
114  try {
115    result = await $.command.run({ command: match[1], args: match[2] ?? '' })
116  } catch (err) {
117    $.ui.log(`/${match[1]} not run as a command: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
118    return false
119  }
120  const output = typeof result?.text === 'string' ? result.text.trim() : ''
121  if (output) await tryBridge($, 'push_message', { role: 'assistant', text: output, source: 'notice' })
122  return true
123}
124
125function flushProgress($: any) {
126  const text = heldProgress
127  heldProgress = ''
128  if (!text) return
129  void tryBridge($, 'push_message', { role: 'assistant', text, source: 'progress' })
130}
131
132function responseText(content: any[]): string {
133  return content
134    .filter((b) => b?.type === 'text' && typeof b.text === 'string')
135    .map((b) => b.text.trim())
136    .filter(Boolean)
137    .join('\n\n')
138}
139
140async function drainDeliveries($: any) {
141  if (draining) return
142  draining = true
143  try {
144    while (deliveries.length > 0) {
145      const m = deliveries.shift()!
146      await deliver($, m)
147    }
148  } finally {
149    draining = false
150  }
151}
152
153async function reportTurnEnd($: any, reason: string, answer: string) {
154  if (reason === 'aborted') {
155    await tryBridge($, 'push_message', { role: 'assistant', text: 'Stopped', source: 'notice' })
156  } else if (reason === 'answer' && answer) {
157    await tryBridge($, 'push_message', { role: 'assistant', text: answer, source: 'mcp' })
158  } else if (reason === 'error' || reason === 'refusal') {
159    await tryBridge($, 'push_message', {
160      role: 'assistant',
161      text: `⚠️ ${answer || `turn ended: ${reason}`}`,
162      source: 'error',
163    })
164  }
165  await tryBridge($, 'push_status', { state: 'idle' })
166}
167
168async function stopTurn($: any) {
169  if (!turnRunning || !currentTurnId) {
170    $.ui.log('stop requested from SpaceNotes with no turn running', { to: 'debug' })
171    await tryBridge($, 'push_status', { state: 'idle' })
172    return
173  }
174  try {
175    await $.turn.abort({ turnId: currentTurnId })
176  } catch (err) {
177    $.ui.log(`stop failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
178  }
179}
180
181async function backgroundTool($: any) {
182  if (toolsRunning === 0) {
183    $.ui.log('background requested from SpaceNotes with no tool running', { to: 'debug' })
184    return
185  }
186  if (!herdrPane || !herdrBin) {
187    await tryBridge($, 'push_message', { role: 'assistant', text: '⚠️ background needs the session to run in a herdr pane', source: 'error' })
188    return
189  }
190  try {
191    await $.process.run({ argv: [herdrBin, 'pane', 'send-keys', herdrPane, 'ctrl+b'] })
192  } catch (err) {
193    $.ui.log(`background failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
194  }
195}
196
197async function inboundLoop($: any) {
198  while (true) {
199    const res = await tryBridge($, 'next_message', { timeoutMs: POLL_TIMEOUT_MS })
200    if (res === undefined) {
201      await $.clock.sleep(2000)
202      continue
203    }
204    const m: Inbound | undefined = res.message
205    if (!m) continue
206    if (m.source === 'control') {
207      if (m.text === 'stop') await stopTurn($)
208      if (m.text === 'background') await backgroundTool($)
209      continue
210    }
211    deliveries.push(m)
212    void drainDeliveries($)
213  }
214}
215
216async function relayQuestion($: any, questions: unknown): Promise<Record<string, string> | undefined> {
217  if (!Array.isArray(questions) || questions.length === 0) return undefined
218  const shaped = questions.map((q: any) => ({
219    question: String(q?.question ?? ''),
220    header: String(q?.header ?? ''),
221    options: Array.isArray(q?.options)
222      ? q.options.map((o: any) => String(o?.label ?? '')).filter((l: string) => l.length > 0)
223      : [],
224    multiSelect: q?.multiSelect === true,
225  }))
226  let requested: any
227  try {
228    requested = await withTimeout(bridge($, 'request_question', { questions: shaped }), BRIDGE_TIMEOUT_MS, 'request_question')
229  } catch (err) {
230    void tryBridge($, 'push_message', {
231      role: 'assistant',
232      text: `⚠️ request_question failed: ${err instanceof Error ? err.message : String(err)}`,
233      source: 'error',
234    })
235    return undefined
236  }
237  const ids: string[] = requested?.ids ?? []
238  if (ids.length !== shaped.length) return undefined
239
240  const answers: Record<string, string> = {}
241  const deadline = Date.now() + QUESTION_TIMEOUT_MS
242  for (let i = 0; i < ids.length; i++) {
243    let response: string | null | undefined
244    while (Date.now() < deadline) {
245      const polled = await tryBridge($, 'poll_question', { id: ids[i], timeoutMs: POLL_TIMEOUT_MS })
246      if (polled === undefined) return undefined
247      if (polled.status === 'answered') {
248        response = polled.response
249        break
250      }
251    }
252    if (response === undefined || response === null) return undefined
253    let selected: string
254    try {
255      const parsed = JSON.parse(response)
256      selected = Array.isArray(parsed) ? parsed.join(', ') : String(parsed)
257    } catch {
258      selected = response
259    }
260    answers[shaped[i]!.question] = selected
261  }
262  return answers
263}
264
265export const register: Register = on => {
266  on('session.start', async ($, e, next) => {
267    const started = await next(e)
268    const connected = await $.mcp.connect(BRIDGE)
269    if (!connected.isConnected) {
270      $.ui.log(`bridge not connected: ${connected.reason ?? ''} ${connected.message ?? ''}`.trim())
271      return started
272    }
273    server = connected.server
274    herdrPane = (await $.env.get('HERDR_PANE_ID')) || undefined
275    const home = await $.env.get('HOME')
276    if (home) herdrBin = `${home}/.local/bin/herdr`
277    void inboundLoop($)
278    return started
279  })
280
281  on('prompt.submit', async ($, e, next) => {
282    const kind = e.origin?.kind
283    if (kind === 'composer' || kind === 'bridge') {
284      touchActivity(false)
285      void tryBridge($, 'push_message', { role: 'user', text: e.text, source: 'terminal' })
286    }
287    void tryBridge($, 'push_status', { state: 'thinking' })
288    return next(e)
289  })
290
291  on('turn.start', async ($, e, next) => {
292    turnRunning = true
293    heldProgress = ''
294    currentTurnId = e.turnId
295    void tryBridge($, 'push_status', { state: 'thinking' })
296    return next(e)
297  })
298
299  on('turn.complete', async ($, e, next) => {
300    if (e.agentId) return next(e)
301    turnRunning = false
302    heldProgress = ''
303    currentTurnId = undefined
304    const answer = e.answer.trim()
305    const reason = e.reason
306    $.clock.after(0, () => reportTurnEnd($, reason, answer))
307    return next(e)
308  })
309
310  on('session.append', { door: 'response' }, async ($, e, next) => {
311    const stored = await next(e)
312    if (e.agentId || e.origin?.kind !== 'model' || e.message?.role !== 'assistant') return stored
313    const content = Array.isArray(e.message.content) ? e.message.content : []
314    const text = responseText(content)
315    if (!text) return stored
316    flushProgress($)
317    if (content.some((b: any) => b?.type === 'tool_use')) {
318      void tryBridge($, 'push_message', { role: 'assistant', text, source: 'progress' })
319    } else {
320      heldProgress = text
321    }
322    return stored
323  })
324
325  on('tool.call', async ($, e, next) => {
326    const fromModel = next.origin.plugin === 'engine'
327    if (!fromModel) return next(e)
328    if (!e.agentId) flushProgress($)
329    const internal = internalToolName(e.tool)
330    if (internal) {
331      return { deny: `${internal} is internal to the space-channel bridge; the session calls it, not you.` }
332    }
333    const input = toolArguments(e as Record<string, unknown>)
334    if (e.tool === 'AskUserQuestion') {
335      const skip = !server ? 'bridge not connected' : !lastPromptFromPhone ? 'last prompt was not from the phone' : ''
336      const answers = skip ? undefined : await relayQuestion($, (input as any).questions)
337      if (answers) return { result: { questions: (input as any).questions, answers } } as any
338      void tryBridge($, 'push_message', {
339        role: 'assistant',
340        text: `⚠️ question not relayed to the app: ${skip || 'relay failed'}; it is waiting in the terminal`,
341        source: 'error',
342      })
343    }
344    void tryBridge($, 'push_tool_event', { tool: e.tool, detail: JSON.stringify({ tool: e.tool, input }) })
345    void tryBridge($, 'push_status', { state: 'tool_use' })
346    toolsRunning++
347    try {
348      return await next(e)
349    } finally {
350      toolsRunning--
351      void tryBridge($, 'push_status', { state: 'thinking' })
352    }
353  })
354
355  on('tool.check', async ($, e, next) => {
356    if (internalToolName(e.tool)) return { decision: 'allow', reason: 'space-channel bridge call' }
357    if (e.tool === 'AskUserQuestion' && lastPromptFromPhone) return { decision: 'allow', reason: 'asked through SpaceNotes' }
358    const verdict = await next(e)
359    if (verdict.decision !== 'ask' || !lastPromptFromPhone || !server || !e.tool_use_id) return verdict
360    const id = e.tool_use_id
361    const requested = await tryBridge($, 'request_permission', {
362      id,
363      tool: e.tool,
364      input: JSON.stringify(e.input ?? {}),
365    })
366    if (requested === undefined) return verdict
367    const deadline = Date.now() + PERMISSION_TIMEOUT_MS
368    while (Date.now() < deadline) {
369      const polled = await tryBridge($, 'poll_permission', { id, timeoutMs: POLL_TIMEOUT_MS })
370      if (polled === undefined) return verdict
371      if (polled.status === 'allow') return { decision: 'allow', reason: 'approved from SpaceNotes' }
372      if (polled.status === 'deny') return { decision: 'deny', reason: 'denied from SpaceNotes' }
373    }
374    return verdict
375  })
376
377  on('session.measure', async ($, e, next) => {
378    const tokens = e.context?.tokens
379    const window = e.context?.window
380    if (e.changed.includes('context') && typeof tokens === 'number' && typeof window === 'number' && window > 0) {
381      void tryBridge($, 'push_context_usage', { used: Math.round(tokens), window: Math.round(window) })
382    }
383    return next(e)
384  })
385}
386