A heading that names a platform applies only to that platform. Everything else applies everywhere.
The daemon writes and rotates its own log file on every platform:
tail -f ~/.vsc-relay/agent.log
On Windows the same file is %USERPROFILE%\.vsc-relay\agent.log. Older generations are
agent.log.1 through agent.log.12.
On Linux this is the instruction most often given wrong. The tracing output goes to that
file, not to stdout, so the systemd user service has almost nothing in the journal:
journalctl --user -u vsc-relay -f shows the unit starting, stopping and crashing and
little else. Anything about sessions, cards, routes or gates is in the file:
tail -f ~/.vsc-relay/agent.log
grep 'stage="route"' ~/.vsc-relay/agent.log | tail
Use journalctl --user -u vsc-relay for the unit's own lifecycle, and agent.log for what
the relay did. The Linux and Windows GUI tails the same file in its Diagnostics pane, with a
text filter and a Gate trace only toggle, so you can read it without a terminal.
Send /auth <key> again from the private chat you want to use. Make sure the key matches
the app setting or RELAY_PAIR_SECRET in .env.
This one is macOS only; it is about the Keychain, which no other platform uses.
The GUI keeps the bot token in the macOS Keychain. The Keychain grants access by code
signature, and the local build signs the app ad hoc, so every rebuild produces a new
signature that the stored token no longer trusts. On the next launch the GUI reads an empty
token and starts the agent with telegram=false, and no messages are sent or received. The
log line vsc-relay-agent starting ... telegram=false confirms it.
To restore service without the Keychain, run the agent from the console with the token in
.env:
./svc.sh start
Quit the GUI first so only one agent runs, otherwise both poll Telegram and conflict. Confirm
with ./svc.sh status that exactly one agent is up and that the log shows telegram=true.
Re opening the token in the GUI once and approving the Keychain prompt also re authorizes the
new signature.
Check before anything else whether the process is there:
pgrep -x vsc-relay-agent || echo "not running"
A relay that is not running sends nothing and logs nothing, so the last log line is simply the last thing that happened before it died. Nothing restarts it on its own unless something supervises it, and that is a different thing on each platform.
macOS. The launchd job, from a clone:
./launchd.sh install # keeps it alive across crashes, logouts and reboots
./launchd.sh status
./launchd.sh uninstall
The job reads everything it needs from ~/.config/vsc-relay/relay.env, including the bot token,
so it does not depend on the shell that started it or on the app being open. With the job
installed, the Stop button in the app stops the process and launchd starts it again within
about ten seconds; use ./launchd.sh uninstall when you want it to stay down.
Linux. A systemd --user unit does the same job:
systemctl --user enable --now vsc-relay
systemctl --user status vsc-relay
loginctl enable-linger "$USER"
The unit is ~/.config/systemd/user/vsc-relay.service when the tarball's install.sh wrote
it, and /usr/lib/systemd/user/vsc-relay.service when it came from the .deb. It reads
~/.config/vsc-relay/relay.env for the token and the pairing key, and restarts the daemon on
failure after three seconds. loginctl enable-linger is what keeps it alive after you log out
of the desktop; without it the user manager and the relay stop with your session. To take it
down for good, use systemctl --user disable --now vsc-relay.
From a dev clone on Linux, ./systemd.sh is the mirror of ./launchd.sh and supervises the
binary in the clone:
./systemd.sh install
./systemd.sh status
./systemd.sh logs
./systemd.sh uninstall
install writes ~/.config/systemd/user/vsc-relay.service with ExecStart pointing at
target/release/vsc-relay-agent in the current directory, imports DISPLAY, XAUTHORITY,
WAYLAND_DISPLAY and XDG_SESSION_TYPE into the user manager, stops a running GUI or agent
first, then enables and starts the unit; build the binary with cargo build --release -p
relay-agent first or it refuses. Run it from the clone's root, because it takes that path from
the working directory. start, stop and restart are there too, and logs follows
~/.vsc-relay/agent.log, not the journal. That unit restarts on failure after ten seconds.
It carries the same name as the packaged one, and a unit in ~/.config/systemd/user shadows
/usr/lib/systemd/user, so a clone installed this way takes over from a .deb install until
you run ./systemd.sh uninstall.
With no unit at all, ./svc.sh start builds the release binary and runs it in the background,
./svc.sh status reports it and ./svc.sh logs follows agent.log. Nothing restarts it that
way; install the unit when you want it supervised.
Windows. The installer registers a logon task named VSCRelay that starts the daemon in
your interactive session.
Two relays cannot share one bot. Telegram hands the long poll to whichever asked last and tells the other:
WARN getUpdates: Conflict: terminated by other getUpdates request
Count them and keep one:
pgrep -x vsc-relay-agent
ps -o pid,ppid,command -p <pid>
More than one line from pgrep is the fault. Reading the parent tells you who started each
copy, and that reading differs per platform.
On macOS, a launchd-owned daemon has ppid 1; anything else was started by a shell or by the app.
On Linux, ppid 1 means nothing: a process started by the systemd --user manager has that
manager as its parent, not init, and a daemon started by the GUI has vsc-relay-gui as its
parent. Ask systemd and the kernel instead:
systemctl --user show -p MainPID --value vsc-relay
cat /proc/<pid>/cgroup
The cgroup line ends in vsc-relay.service for the copy the unit owns, and in an
app-...scope for one started from the desktop or a terminal.
In any case the second copy should not survive: the daemon takes a machine lock at
~/.vsc-relay/vsc-relay-agent.lock and a copy that cannot claim it writes one line and exits.
another vsc-relay-agent already holds the machine lock; exiting so the two never share one bot
If the log fills with getUpdates: error sending request: operation timed out every minute or
so, the relay is healthy and the route to Telegram is not. Check it from the same machine:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://api.telegram.org/
Then check whether a plain TCP connection to Telegram's data centre gets through. Use the
form for your platform; nc is not the same program on both.
On Linux, bash and timeout from coreutils are enough, and no netcat is needed:
timeout 4 bash -c '</dev/tcp/149.154.167.220/443' && echo open || echo blocked
With netcat-openbsd installed, nc -z -w 4 149.154.167.220 443 does the same. Do not use
-G on Linux. It is a BSD flag: netcat-openbsd rejects it outright, which makes the check
print blocked while the route is perfectly fine, and netcat-traditional takes it as a
source-routing pointer, which is not a timeout at all.
On macOS, nc is the BSD one and -G is its connect timeout:
nc -z -G 4 149.154.167.220 443 && echo open || echo blocked
A 000 with the rest of the internet working means the path to Telegram is cut rather than the
relay being stuck. Point the relay through a proxy by adding one line to the .env the service
reads, then restart it:
VSC_RELAY_PROXY=socks5://127.0.0.1:1080
ALL_PROXY and HTTPS_PROXY are honoured too, in that order, so an existing shell setting
works without changing anything; VSC_RELAY_PROXY wins when both are present. HTTP, HTTPS and
SOCKS5 addresses are all accepted. The startup line in the log reports which one is in use, or
direct when there is none. A proxy address that cannot be parsed stops the relay at startup
with the reason rather than silently falling back to a direct connection.
A configured proxy is a second route, not the only way out. With one configured the relay starts
on it and switches to the other route after two failures in a row. While on the proxy it checks
the direct route every fifteen minutes with a harmless getMe call and moves back as soon as that
answers. Each move is one line in the log:
WARN telegram is unreachable on this route, switching from="proxy" to="direct"
INFO the direct route answered again, leaving the proxy
A message that fails before it reaches Telegram is not dropped. It is tried up to three times, and because two failures switch the route, the last try goes out the other way. Count what that saved and what was still lost:
vsc-relay-agent decisions --since 24h --by outcome | grep send_
Losses that remain almost always mean both routes were down at once, typically a proxy that only
answers while a VPN is connected. The proxy address is a local setting and belongs in
~/.config/vsc-relay/relay.env on that machine.
The other way in needs no tunnel on this machine: point the relay at a different Bot API endpoint that can reach Telegram itself.
VSC_RELAY_TELEGRAM_API=https://your-endpoint.example.com
Both the API calls and file downloads follow that base, so a small HTTPS reverse proxy in front
of api.telegram.org, or a self-hosted telegram-bot-api server, works without any other
change. The path layout stays the same as Telegram's own, /bot<token>/<method> and
/file/bot<token>/<path>, so anything that forwards requests unchanged is enough. The bot
token travels to that endpoint, so it must be one you control.
Check which card it is. The interactive question card lists the answer options as buttons and is delivered through the shim; that one works. A plain card that only says the agent needs permission comes from the Notification hook and its buttons press Enter in the window through Accessibility. That path cannot answer an AskUserQuestion, because the answer lives inside a webview the platform does not expose, so tapping it does nothing visible. The relay now suppresses that second card while an interactive one is live, and logs the reason:
an interactive card already owns this prompt; notification card suppressed
If you see the plain card alone, the session is not tapped: the shim is loaded by the editor, so a chat started in a terminal has no send path. Answer it in the window.
Open the workspace in VS Code and start a Claude Code or Codex chat. Then run /windows or
open /menu again.
For Claude Code, install the shim and start a new Claude Code chat after installation. Already-open chats keep using the helper binary they started with.
For Codex, this is expected. Codex background control is not implemented yet.
session not tapped; permissions cannot be approved from Telegram means the session was
started outside VS Code, typically as claude in a terminal. Hooks still fire, so the relay
sees the session and every tool call it makes, and the session shows up in the chat list. Only
the send path is missing, because that one goes through the shim the editor loads. Start the
chat in VS Code if you need to answer its permission prompts from Telegram.
On macOS, grant Accessibility permission to the app or terminal that runs the relay. Then
restart the relay and try /focus <workspace> again.
On Linux, window focus and GUI fallback need an X11 (or XWayland) session and xdotool
(sudo apt install xdotool xclip xdg-utils). Under a native Wayland session the compositor
usually blocks key injection into other windows; prefer an X11/Xorg session for the GUI
fallback. If the relay runs as a systemd user service, it must inherit DISPLAY. The
installer runs systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY
XDG_SESSION_TYPE; re-run it and restart the service if you started the session differently.
The background shim path does not need a display and keeps working regardless, so sending,
answering questions, permissions, and model/effort/mode work even when /focus does not.
On Windows, window focus and GUI fallback need an interactive desktop session. The logon task
the installer registers runs there; a Session-0 service cannot focus windows. The background
shim path over named pipes needs no session and keeps working, so sending, answering
questions, permissions, and model/effort/mode work even when /focus does not.
The two Linux failures read differently in the log. A missing tool says so by name:
required tool 'xdotool' not found; install it (e.g. sudo apt install xdotool xclip xdg-utils)
A daemon with no display at all says this instead, and no amount of installing helps until it has one:
no X display (DISPLAY is unset); window focus / GUI control needs an X11 or XWayland session. The background shim path still controls Claude Code without a display.
The systemd --user manager starts the unit with a deliberately small environment. Look at
what it actually passes:
systemctl --user show-environment
The PATH there is usually just /usr/local/bin:/usr/bin:/bin and the games directories. It
has no ~/.local/bin, no ~/.npm-global/bin and no nvm node, which is where claude,
codex, gemini and friends normally live, so a CLI that works in your shell is invisible to
a daemon started by systemd.
The relay compensates for this itself. It searches PATH plus ~/.local/bin, ~/bin,
~/.npm-global/bin, $NPM_CONFIG_PREFIX/bin, ~/.volta/bin, ~/.bun/bin, ~/.deno/bin,
~/.cargo/bin, ~/.yarn/bin, the newest ~/.nvm/versions/node/*/bin, /usr/local/bin,
/opt/homebrew/bin and /snap/bin, and it hands that same augmented PATH (with TERM) to
every CLI agent it spawns. That is what makes a node-shebang CLI work under the service.
If a CLI still is not found, it lives somewhere outside that list. Either symlink it into
~/.local/bin, or give the unit a drop-in with a [Service] section that sets
Environment=PATH=... including its directory:
systemctl --user edit vsc-relay
systemctl --user restart vsc-relay
Confirm what the unit ended up with, rather than what your shell has:
systemctl --user show vsc-relay -p Environment
A hand off to an agent CLI writes the prompt and a launcher script under
~/.vsc-relay/handoff/, then opens a terminal to run it. It tries x-terminal-emulator,
ptyxis, kgx, gnome-terminal, konsole, xfce4-terminal, mate-terminal, tilix,
terminator, alacritty, wezterm, kitty, foot, lxterminal, deepin-terminal,
qterminal, urxvt, st and xterm, in that order, and takes the first one it finds.
With none of them installed it says so and names the script, which you can run yourself:
no terminal emulator found on PATH; install one (xterm, konsole, gnome-terminal, alacritty, kitty, foot) or run ~/.vsc-relay/handoff/launch-<stamp>.sh yourself
With no graphical session at all, neither DISPLAY nor WAYLAND_DISPLAY, there is nowhere to
open a window, and the message names the same script:
no graphical session to open a terminal in; run ~/.vsc-relay/handoff/launch-<stamp>.sh yourself
Run that script from any terminal you do have, over SSH included, and the hand off proceeds
normally. A hand off to an editor does not need a terminal; those are found on PATH as
code, code-insiders, code-oss, codium, vscodium, cursor, windsurf and
antigravity. An editor that is missing is reported as <name> is not installed or not on
PATH on this machine, and a missing agent CLI as <name> is not installed.
None of this touches the background shim path. Sending, answering questions, permissions and model/effort/mode work on a headless machine.
Run one or the other, never both. The daemon takes a single-instance lock at
~/.vsc-relay/vsc-relay-agent.lock, so a second copy exits immediately instead of polling the
same bot. On top of that, the GUI's Start sends SIGTERM to every running vsc-relay-agent
before starting its own, and the unit restarts the one systemd owns three seconds later, so
with both in play they take turns killing each other and losing the lock. The visible symptoms
are a service that looks enabled but owns nothing, a Start button that appears to do nothing,
and a restart count that keeps climbing in systemctl --user status vsc-relay.
Pick one:
systemctl --user disable --now vsc-relay
then use the GUI; or quit the GUI and let the unit own the daemon.
Linux builds have no local ONNX semantic backend. The local-onnx cargo feature is disabled
for Linux targets because the prebuilt ONNX Runtime needs a recent glibc and has no musl build
at all, which would undo "runs on any distribution". The default semantic backend on Linux is
therefore off, not local, and a config that asks for local fails at classification time,
and equally under automation smart check, with:
this build has no local ONNX semantic backend (it is not available on Linux, where the prebuilt ONNX Runtime cannot link against every glibc and cannot link statically against musl at all); set smart.semantic.backend in ~/.vsc-relay/automation.json to off, ollama, openai_compatible or agent_cli
Check what the backend is doing, then point it at something this build can run:
vsc-relay-agent automation smart check
vsc-relay-agent automation smart provider ollama
vsc-relay-agent automation smart model <model-id>
vsc-relay-agent automation smart check
check prints semantic backend off when nothing is configured, ready: ... when the
backend answered a probe, and the failure otherwise. The model id is whatever the backend you
picked calls its model, for example the tag your Ollama server has pulled.
The providers that work on Linux are off, ollama (a localhost Ollama keeps every
transcript on the machine), openrouter, nvidia and openai-compatible (these send
transcript text off the machine), and the agent CLIs claude, codex, gemini, cursor and
antigravity (these relay it to that vendor's cloud). The relay prints a warning naming what
leaves the machine when you pick one of those. automation smart install-local and
automation smart train-local only matter for macOS and Windows builds; on Linux nothing can
run the model they produce.
WARN fs watcher unavailable; falling back to polling every 15s
Nothing is broken: the relay could not open an inotify instance, so it scans on a timer instead of reacting to writes. Sessions and turns are still picked up, just up to the poll interval later. The cause is almost always the per-user inotify instance limit, which editors and CLI agents spend quickly:
cat /proc/sys/fs/inotify/max_user_instances
find /proc/*/fd -lname anon_inode:inotify 2>/dev/null | wc -l
If the second number is at or above the first, raise the limit:
echo 'fs.inotify.max_user_instances=512' | sudo tee /etc/sysctl.d/90-inotify.conf
sudo sysctl --system
Restart the relay afterwards. A machine that runs several VS Code windows alongside Codex or Cursor will hit the default of 128 routinely.
On macOS and Linux:
./shim.sh status
./shim.sh uninstall
On Windows:
.\shim.ps1 status
.\shim.ps1 uninstall
Restart VS Code after uninstalling. If the Claude Code extension still fails to start, reinstall or update the extension.
If the session shows a [VSC_RELAY_SESSION_HEALTH_PROTOCOL] block, or the agent ends its
replies with a [VSC_RELAY_HEALTH_RESULT] envelope, that is the optional Compass feedback
protocol. It asks the agent to state its own terminal status in a fixed machine-readable
shape so the relay can tell "still working" from "claims complete" without calling a model.
It carries no instructions about the work itself and cannot override anything you asked for.
The block is delivered by the SessionStart hook as additional context. It is sent only when the session context is built or rebuilt, that is on startup, clear, and compact. It is not resent on resume, because the transcript already carries it and repeated copies waste context and read as noise to the agent. Before 0.4.1 it was sent on every SessionStart including resume; one long session accumulated 288 copies.
To turn it off entirely, open the automation menu in Telegram and toggle Feedback, or set
smart.feedback_protocol to false in ~/.vsc-relay/automation.json. Turning off Compass
(smart.enabled) also disables it.
A single Claude Code session appends to one transcript file forever. On this machine a long-running session reached 1.6 GB in the transcript plus 722 MB in its sidecar directory, with individual JSON lines of 4.5 MB. Nothing rotates or prunes those files; the agent process, the editor, and every tool that reads the transcript pay for that size.
Check the largest transcripts:
du -sh ~/.claude/projects/* | sort -h | tail
find ~/.claude/projects -name '*.jsonl' -size +200M -exec ls -lh {} \;
If the machine became unresponsive or the daemon simply disappeared, ask the kernel who it killed. The record is in a different place on each platform.
macOS. Jetsam writes a report that names every process and its peak footprint:
ls /Library/Logs/DiagnosticReports/JetsamEvent-*.ips
Read the lifetimeMax field per process (it is in 16 KB pages) to see which process actually
grew, rather than guessing.
Linux. There is no Jetsam. The kernel OOM killer logs one line naming the victim, its pid and how much it had:
sudo dmesg -T | grep -i "out of memory"
journalctl -k --since -1d | grep -i "killed process"
Debian and Ubuntu set kernel.dmesg_restrict=1, so dmesg needs sudo, and journalctl -k
needs membership in adm or systemd-journal (or sudo); without either it prints nothing
and that is not evidence of nothing happening.
If the relay ran under the systemd user unit, systemd recorded the death itself, including a kill by signal 9:
systemctl --user status vsc-relay
systemctl --user show vsc-relay -p NRestarts -p Result -p ExecMainCode -p ExecMainStatus
journalctl --user -u vsc-relay --since -1d
Result=oom-kill, or a Main process exited, code=killed, status=9/KILL line, says the
machine ran out of memory rather than the relay crashing. Remember that the relay's own log
is ~/.vsc-relay/agent.log and simply stops mid-line when the process is killed.
On any platform, total demand across all processes is what matters: on a 16 GB machine a total of roughly 30 GB means heavy swapping, and the editor plus browser plus the agent together usually explain it.
The relay's own contribution is bounded. It reads each transcript incrementally, caches at most 64 transcript reducers and evicts the least recently used one, scans only newly appended bytes for line breaks, and releases any oversized line buffer after use. Before 0.4.1 the cache was unbounded, a partially received line was rescanned from its start on every 1 MB chunk, and the whole partial line was JSON-parsed on every chunk, so one 4.5 MB line cost several full parses and the buffer stayed allocated for the life of the process.
To recover space, close finished sessions and archive or delete their transcripts.
The app is ad-hoc signed. On first launch, right click VSCRelay.app and choose Open. If
the downloaded file looks corrupted, download it again and compare its SHA256 checksum with
the release SHA256SUMS file.