
CLI tool for inspecting and managing services listening on localhost ports
<div align="center">
```
██████ ███ ██████ ██ ██████
██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ████
██ ██ ██ ██ ██ ██ ██ ██
██████ ███ ██ ██ ██ ██ ██ ██
```
Know what's running on your machine.
</div>
Sonar shows everything listening on localhost and puts it in order: every port
belongs to a **group** — normally the repository it was started from — and
inside that group to a named **service**. Start your dev servers with
`sonar start` and the whole project becomes one thing you can list as a tree,
wait for, tail, and stop with a single command. Docker containers, Compose
projects and processes you started by hand are picked up too, without any
configuration.
```
$ sonar list --tree
my-app (3 ports, running) ~/code/my-app
├─ 5432 db postgres:17 http://localhost:5432
├─ 5173 frontend vite (v5.4) http://localhost:5173
└─ 8000 api uvicorn app:app http://localhost:8000
ungrouped (1 port)
└─ 3000 next-server (v16.1.6) http://localhost:3000
```
## Install
### Homebrew (macOS / Linux)
```sh
brew install raskrebs/sonar/sonar
```
Homebrew 6 refuses formulae from third-party taps until you trust the tap once
(`Error: Refusing to load formula raskrebs/sonar/sonar from untrusted tap`):
```sh
brew trust raskrebs/sonar
```
### Install script
```sh
curl -sfL https://raw.githubusercontent.com/raskrebs/sonar/main/scripts/install.sh | bash
```
Downloads the latest binary to `~/.local/bin` and adds it to your PATH if needed. Restart your terminal or `source ~/.zshrc`.
On Windows (PowerShell):
```powershell
irm https://raw.githubusercontent.com/raskrebs/sonar/main/scripts/install.ps1 | iex
```
Custom install location:
```sh
curl -sfL https://raw.githubusercontent.com/raskrebs/sonar/main/scripts/install.sh | SONAR_INSTALL_DIR=/usr/local/bin bash
```
Install a specific version:
```sh
curl -sfL https://raw.githubusercontent.com/raskrebs/sonar/main/scripts/install.sh | SONAR_VERSION=vX.Y.Z bash
```
```powershell
$env:SONAR_VERSION="vX.Y.Z"; irm https://raw.githubusercontent.com/raskrebs/sonar/main/scripts/install.ps1 | iex
```
### Using Go
```sh
go install github.com/raskrebs/sonar@latest
```
Shell completions (tab-complete port numbers):
```sh
sonar completion zsh > "${fpath[1]}/_sonar" # zsh
sonar completion bash > /etc/bash_completion.d/sonar # bash
sonar completion fish | source # fish
```
## Sixty seconds
Describe how your project runs in a `sonar.yaml` at the repository root, in
place of a `dev.sh`:
```yaml
name: my-app
services:
- name: db
cmd: docker compose up db
port: 5432
- name: api
cmd: uv run uvicorn app:app --port ${port}
port: auto
depends_on: [db]
- name: frontend
cmd: npm run dev -- --port ${port} --strictPort
port: auto
depends_on: [api]
env:
VITE_API_URL: ${api.url}
```
Then start it:
```sh
sonar start
```
```
✓ db http://localhost:5432 pid 41022 ~/.config/sonar/logs/my-app/db.log
✓ api http://localhost:21408 pid 41040 ~/.config/sonar/logs/my-app/api.log
✓ frontend http://localhost:21409 pid 41077 ~/.config/sonar/logs/my-app/frontend.log
3 started
following the logs; Ctrl+C stops the 3 services started here
api | INFO: Uvicorn running on http://127.0.0.1:21408
frontend | VITE v5.4 ready in 312 ms
```
Sonar picks a free port for every `port: auto` service and tells each service
where the others are, so a second worktree runs next to the first without a
single port clashing. `sonar start -d` does the same in the background, and
`sonar down` stops it and gives the ports back. In another terminal:
```sh
sonar list --tree
```
```
my-app (3 ports, running) ~/code/my-app
├─ 5432 db postgres:17 http://localhost:5432
├─ 21408 api uvicorn app:app http://localhost:21408
└─ 21409 frontend vite (v5.4) http://localhost:21409
```
No `sonar.yaml`? `sonar start -- npm run dev` runs one command as a named
service, and `sonar list` and `sonar kill` work on anything that is listening.
Examples below marked `# check` are executed against a fresh build by
`scripts/readme-check.sh` on every CI run.
## Commands
### `sonar list`
```sh
sonar list
sonar list --tree
sonar list --group my-app
sonar list --json
# check
```
```sh
sonar list --stats # CPU, memory, threads, uptime, state
sonar list --health # HTTP health checks
sonar list --filter docker # only Docker ports
sonar list --sort name # port | pid | name | type
sonar list -a # include desktop apps
sonar list -c port,process,group,cpu,mem
sonar list --host user@server # scan a remote machine over SSH
```
Default columns are `port`, `process`, `group`, `container`, `image`,
`containerport`, `url`, where `process` shows the name you gave the port
(`sonar rename`), then the service name, then what was detected.
Available columns: `port`, `process`, `pid`, `type`, `url`, `group`, `cpu`,
`mem`, `threads`, `uptime`, `state`, `connections`, `health`, `latency`,
`container`, `image`, `containerport`, `compose`, `project`, `user`, `bind`,
`ip`.
Desktop apps and system services that happen to listen — Figma, Discord,
Spotify, ControlCenter, macOS `.app` bundles, `/System/Library/` daemons — are
hidden unless you pass `-a`.
### `sonar start`
Start a project from its `sonar.yaml`:
```sh
sonar start # every service in the nearest sonar.yaml
sonar start api frontend # only these (they still wait for their dependencies)
sonar start ../other-project # the sonar.yaml in or above another directory
sonar start -d # in the background, like `sonar up`
```
In the foreground, sonar starts the services through the daemon, follows their
logs with the service name in front of every line, and when you press Ctrl+C
stops the services it started. A service that was already running is left
alone, and sonar returns by itself once every service it started has exited.
Arguments before `--` name a directory or services; everything after `--` is a
command. Or run one command as a named service in a group:
```sh
sonar start -- npm run dev
sonar start --group my-app --name frontend -- npm run dev
sonar start --port 5173 -- npm run dev # expected port, before it binds
sonar start --detach --name api -- uv run uvicorn app:app
sonar start --list
```
Nothing has to be passed:
- **Group** — `--group`, else the `name` in the nearest `sonar.yaml`, else the
git root's directory name (a worktree becomes `repo@worktree`), else the name
of the current directory.
- **Name** — `--name`, else the `sonar.yaml` service whose `cmd` matches, else
inferred from the command (`npm run dev` → `dev`, `uv run api` → `api`,
`python -m uvicorn` → `uvicorn`, `./dev.sh` → `dev.sh`).
- **Port** — `--port` is a hint, not a binding: the run shows as `starting`
until the port is actually listening, and the daemon uses it to match the
process to the port.
The child inherits stdin, stdout, stderr, cwd and environment, plus
`SONAR_GROUP`, `SONAR_NAME` and `SONAR_RUN_ID`. It gets its own process group,
so `sonar kill` takes down the whole tree — a dev server with its watchers and
workers. Ctrl+C is forwarded, and sonar exits with the child's exit code.
`--detach` returns immediately and writes the output to
`~/.config/sonar/logs/<group>/<name>.log`. `--list` shows what sonar started,
and under it what has since ended — with the exit code, and whether it crashed
or was stopped (`--json` for the machine-readable form):
```sh
sonar start --list
sonar start --detach --name demo --port 8123 -- sleep 5
sonar start --list --json
# check
```
### `sonar.yaml`
A project names itself and its services in a `sonar.yaml` at the repository
root. It is optional — sonar groups by git root without it — and it is meant to
be committed:
```yaml
name: my-app
services:
- name: db
cmd: docker compose up db
port: 5432
health: /
description: Postgres 17
icon: database
color: "#4f8cc9"
- name: api
cmd: uv run uvicorn app:app --port 8000
cwd: backend
port: 8000
health: /healthz
depends_on: [db]
- name: frontend
cmd: npm run dev
port: 5173
depends_on: [api]
ports: [9229] # ports that belong to this project without a service
```
- `name` — the group name. No slashes, no whitespace.
- `cmd`, `cwd`, `port` — how `sonar start` starts the service. `cwd` is relative to
the file and may not escape its directory.
- `health` — an HTTP path the daemon polls while the service is up, so a
service can be *running* but not yet *healthy*. It reports `ok`, `fail` or
`unknown`, with the reason for a failure.
- `description`, `icon`, `color` — free-form metadata for the desktop app; sonar
never infers them.
- `depends_on` — start order. Naming a service that is not in the file, or a
cycle, is an error; an invalid file is reported once and never stops a scan.
- `env` — variables set for the service when sonar starts it.
#### `port: auto` and references
A service can leave its port to sonar, and find the others through its
environment instead of a hard-coded `localhost:8080`:
```yaml
services:
- name: api
cmd: uv run uvicorn app:app --port ${port}
port: auto
- name: frontend
cmd: npm run dev -- --port ${port} --strictPort
port: auto
depends_on: [api]
env:
VITE_API_URL: ${api.url}
```
`sonar start` claims a free port for every `port: auto` service it starts: the
same one each time in the same checkout, and a different one in every other
checkout, so a worktree never collides with the main one. The service gets it
as `PORT`, `SONAR_PORT` and `${port}`, and it has to use it — read `PORT`, or
pass `${port}` on its command line. A service that is already running keeps
its port, so one started later still finds it. The claim behind an assigned
port lasts a day from the last start; `claims.ttl` changes that (see The
daemon).
`${port}` and `${url}` (`http://localhost:<port>`) are the service's own;
`${<service>.port}` and `${<service>.url}` are another's. They work in `cmd`
and in `env`. A reference to a service that is not in the file, or to one with
no port, is an error when the file is loaded. Anything else between `${` and
`}`, like `${HOME}`, is left exactly as written.
`sonar.yml` is read if that is how you spell it; `sonar init` always writes
`sonar.yaml`. The file used to be the dotfile `.sonar.yaml`, and that name
still works: sonar looks for `sonar.yaml`, `sonar.yml`, `.sonar.yaml` and
`.sonar.yml`, in that order, and reads the first one it finds. Edits go back
to the file sonar read, so nothing is renamed behind your back. `sonar doctor`
points out a file under the old name, and `sonar doctor --fix` renames it
(with `git mv` when the file is tracked). The daemon watches the projects it knows about and picks up
edits to the file without a restart. Every edit sonar makes — from the desktop
app, from `sonar groups add`, `rename` and `remove`, from an agent — goes
through the daemon, which re-renders the file from its own syntax tree, so
comments, key order and layout survive an edit that adds, renames or removes a
service just as they survive a metadata change. The one exception: extra spaces
lining a trailing comment up (`cmd: x # note`) collapse to one, because the
YAML library keeps the comment but not its column.
#### `sonar env`
What the file resolves to is readable without starting anything:
```sh
sonar env # every service: port, url and its expanded env
sonar env frontend # one service, as export lines
sonar env --json # for tools
```
```
SERVICE PORT URL ENV
db 5432 http://localhost:5432
api 21408 http://localhost:21408 DB_URL=postgres://localhost:5432/app
frontend 21409 http://localhost:21409 VITE_API_URL=http://localhost:21408
```
A `port: auto` service that is not running is given its claim here, the same
claim `sonar start` makes, so what `sonar env` prints is what a later start
binds; a service that is already running keeps the port it is on. Named, a
service comes out as `export` lines, so a shell can see what the service would:
```sh
eval "$(sonar env frontend)" && npm test
```
Behind it is `groups.env` on the daemon, for an editor or an agent that wires
a worktree up before its services exist.
### `sonar up`
```sh
sonar up # the sonar.yaml at or above this directory
sonar up my-app # a group by name
sonar up --only api,frontend
sonar up --json
```
`sonar up` is `sonar start -d`, which can also name a group from anywhere and
start it on another host. It starts every service the group's `sonar.yaml`
declares, in `depends_on` order:
a service waits for the ports its dependencies declare before it is started, and
one that is already listening is skipped. Each runs detached in its own process
group, with its output in `~/.config/sonar/logs/<group>/<service>.log`, and with
the environment of the shell you ran `sonar up` in, plus `PORT` for a service
with a port.
```
✓ db http://localhost:5432 pid 41022 ~/.config/sonar/logs/my-app/db.log
- api already running
✓ frontend http://localhost:5173 pid 41108 ~/.config/sonar/logs/my-app/frontend.log
2 started, 1 already running
```
A service that fails to start is reported on its own line and makes the command
exit non-zero, whatever else came up. Stop them all again with `sonar down`.
`sonar up` needs the daemon and starts it if it is not already running.
### `sonar down`
```sh
sonar down # the project in the nearest sonar.yaml
sonar down my-app # a group by name
sonar down --only api,worker # only these services
```
Stops every service of the project — every port it listens on, and every
service sonar started for it that holds no port, like a worker — and releases
the ports sonar claimed for its `port: auto` services. `--only` narrows all of
that to the named services, the way `sonar up --only` starts only some; a name
the file does not declare is an error. `sonar kill -g my-app` stops the ports
and releases nothing.
### `sonar groups` and `sonar init`
```sh
sonar init --dry-run
sonar init --service api:8000:/healthz --service web:5173
sonar groups
sonar groups --json
group=$(basename "$PWD") # sonar init names the group after the directory
sonar groups add "$group" worker --port 9000 --cmd 'uv run worker' --depends-on api
sonar groups rename "$group" worker jobs
sonar groups remove "$group" jobs
# check
```
`sonar groups` lists every group sonar can see and where each name came from:
`manual` (you pinned it with `sonar assign`), `start` (a `sonar start` run),
`file` (a `sonar.yaml`) or `auto` (the git root or the Compose project).
`sonar groups <name>` shows one group's ports and services, and the services
that are declared but not running.
`sonar init` writes a `sonar.yaml` at the git root from what it can state: the
ports listening inside the project right now (desktop apps and ports below 1024
left out), plus what a compose file or a `package.json` declares outright — one
service per compose service, with its published port and its `depends_on`, and
the `dev` script run by whichever package manager your lockfile commits you to.
Where the two disagree, the listening port wins: it is evidence, not a
statement. The file it writes says what it could not work out.
Nothing else is detected. A Makefile target, a Procfile line, an entry point
buried in a framework — those are guesses about your repository, and a table of
them would never be finished. That part goes to an agent:
```sh
sonar init --agent # whichever agent is installed
sonar init --agent codex # name one
sonar init --agent claude -- --allow-dangerously-skip-permissions
```
`--agent` opens the coding agent you already have, in this terminal, with the
prompt already sent: what sonar knows, the draft it would have written, the
format, and the rule that it writes the file and starts nothing. You watch it
read the repository and approve its edits exactly as you always do — it is your
agent, under your permissions — and anything after `--` is passed to it. sonar
knows `claude`, `codex`, `cursor-agent` and `opencode`; with several installed,
name one. When the agent exits, sonar loads the file and prints the services it
declares, or why it does not load. Without a terminal — a pipe, CI — `--agent`
prints the prompt instead of starting anything.
It refuses to overwrite without `--force`, and `--dry-run` prints the file
instead of writing it. `--merge` appends to a file that is already there
instead of refusing, and `--service name:port[:health]` — repeatable — writes
the services you name instead of the ones it found, keeping the command it
guessed for a port you kept. `--force` and `--merge` are mutually exclusive.
`sonar groups add <group> <name> --port N` appends a service to that group's
`sonar.yaml`, with `--cmd`, `--cwd`, `--health`, `--description`, `--icon`,
`--color` and a repeatable `--depends-on` for the rest of it. `sonar groups
rename <group> <old> <new>` renames one everywhere in the file, `depends_on`
references included, and `sonar groups remove <group> <name>` deletes one and
drops it from every `depends_on` that named it. All three refuse an edit that
would leave the file invalid — a duplicate name, a port another service already
claims, a service that is not there — and none of them writes a byte until the
whole edit is known to be good.
The daemon does the writing, which is why the file comes back with its comments
and key order intact whether the edit came from the CLI, the desktop app or an
agent. `sonar groups add`, `rename` and `remove` need the daemon and start it if
it is not already running; the three names are subcommands, so a group actually
called `add` is read with `sonar groups --json`.
### `sonar kill`
```sh
sonar kill 3000 # SIGTERM, then SIGKILL after 5s
sonar kill 3000 5432 -f # SIGKILL both straight away
sonar kill 3000 --tree # the listener and everything below it
sonar kill --pid 12345 --tree # by process id
sonar kill -g my-app # a whole group, confirms unless -y
sonar kill --all --filter docker -y # every container publishing a port
sonar kill --all --project my-app # one Compose project
sonar kill 3000 --ip 127.0.0.1 # one bind address of several
sonar kill --all --dry-run --json # the plan for the whole machine
```
`--dry-run` takes any selector and changes nothing: it prints the actions the
kill would take, children first, and leaves everything running. End to end,
against a listener of your own:
```sh
sonar start --detach --name plan --port 8231 -- sonar map 3000 8231
sonar wait 8231
sonar kill 8231 --dry-run --json # the plan; the mapping keeps running
sonar kill 8231 -y # and now for real
# check
```
A positional argument is read as a port, and as a pid only when nothing is
listening on that number. `-g` matches the resolved group, a legacy run tag or
id, and the Compose project, case-insensitively.
A process that ignores SIGTERM is sent SIGKILL once the port is still listening
after `--grace` (5s); `--no-escalate` turns that off. Children are signalled
before parents, so a tree comes down in order. Docker containers are stopped
with `docker stop` and never signalled. A listener started by `sonar start` is
always stopped together with its process group.
`--json` prints one row per process:
`{port, bind_address, pid, name, method, ok, error}`, where `method` is
`sigterm`, `sigkill`, `docker_stop`, `map_stop` or `none`. An empty sweep exits
0; an unknown group exits 1.
### `sonar map`
```sh
sonar map 6873 3002 # also serve the service on 6873 from port 3002
```
Runs a TCP proxy in the foreground until you stop it. `sonar kill` reports a
mapping it stopped as `map_stop`.
### `sonar rename`, `sonar assign`, `sonar history`
```sh
sonar rename 3000 storefront # a name of your own, survives restarts
sonar rename 3000 --clear
sonar assign 3000 my-app # pin a port to a group by hand
sonar assign 3000 --clear
sonar history # everything that came up, went down, restarted
sonar history 3000 --since 24h --limit 20
```
```sh
sonar history --since 1h
sonar history --json
# check
```
Names and pins are stored in sonar's database, keyed by the most specific thing
known about the port: the run (`run:<group>/<name>`), the container
(`docker:<project>/<service>`), the working directory, and the port number
last. A renamed dev server keeps its name across restarts; a name pinned to
port 3000 alone applies to whatever answers there. These three commands need
the daemon and start it if it is not running.
### Reading a port
```sh
sonar info 3000 # command, user, bind, stats, health
sonar logs 3000 # tail; docker logs for containers
sonar wait 5432 3000 --timeout 60s # block until ready
sonar wait 5432 --http=/health # wait for HTTP 200-399, not just TCP
sonar next 3000 # first free port from 3000
sonar next 3000-3100 -n 3 # three consecutive free ports
sonar graph # who is connected to whom
sonar graph --dot # Graphviz
sonar open 3000 # open in the browser
sonar attach 3000 # shell into the container, or TCP
sonar watch # live view
sonar watch --stats --notify
```
```sh
sonar next 3000
sonar next 3000-3100 -n 3 --json
sonar graph --json
sonar info --help
# check
```
`sonar wait` exits `0` (ready), `1` (timeout) or `2` (interrupted), which makes
it the thing to put between starting something and testing it:
```sh
docker compose up -d
sonar wait 5432 3000 --timeout 60s && npm run migrate && npm run test
```
**Daemon or direct scan.** Every read command asks the daemon if one is
running, because it already has the answer and does not have to fork `lsof`.
If none is running they scan directly and print one note on stderr saying so.
`sonar kill` follows the same rule: a reachable daemon does the killing, so it
rescans immediately and its next answer — and the port history — already knows
the port is gone. Neither reads nor kills start a daemon behind your back.
`--no-daemon` forces the direct scan silently and works on any command:
```sh
sonar list --no-daemon --json
# check
```
### `sonar host`
```sh
sonar host # cpu, load, memory and disk of the machine sonar watches
sonar host --json
```
```sh
sonar host
# check
```
The daemon measures its own machine on the scan cadence and publishes it as the
`localhost` row of the snapshot's `hosts` collection: os and kernel, uptime, cpu
percent, load average, memory and the disk holding `/`. CPU percent is the work
done between two scans, so it is null until the daemon has scanned twice; a
figure a platform cannot produce — the load average on Windows, which has none —
is null rather than zero. Every host registered with `sonar remote add` joins
the same table with its own load. The command needs a running daemon: it is the
daemon that holds the previous sample a percentage is measured against.
GPUs are listed under their host with utilization and memory: from `ioreg` on
macOS (Apple silicon memory is unified, so only what the GPU holds is shown),
and from `nvidia-smi` or amdgpu's sysfs counters on Linux. The daemon reads them
every five seconds in the background, never on a command's path; `gpus` is null
where there is no source (Windows, Intel-only Linux) and `[]` on a machine with
no GPU.
### `sonar remote install`
```sh
sonar remote install [email protected] # same version as this sonar
sonar remote install hetzner --version v0.6.0 # a Host from ~/.ssh/config
sonar remote install deploy@box --no-service # the binary, no daemon
```
Puts sonar on a host you can already ssh to and starts its daemon there. The
release archive is downloaded and checksummed **on the remote host** — nothing
is copied from this machine — and the binary lands in `~/.local/bin/sonar`, so
none of it needs root. The daemon runs as a systemd user unit where the host
has one (`~/.config/systemd/user/sonar.service`), and detached where it does
not; `loginctl enable-linger` is printed as advice when the user session would
end at logout and take the daemon with it.
The version installed is the version of the sonar you ran it from, so the two
ends speak the same protocol. Running it again upgrades in place and restarts
the daemon, which is what makes an install and an update the same command.
The target goes to `ssh` untouched: a `Host` alias from `~/.ssh/config` works,
and so do the `ProxyJump`, `IdentityFile` and `Port` it sets. `--identity` and
`--ssh-arg` are there for the flags a config does not cover.
### `sonar remote`
```sh
sonar remote add [email protected] # name taken from the target
sonar remote add hetzner [email protected] # or given
sonar remote list # status, latency, version, load
sonar remote remove hetzner
sonar list --host hetzner # that host's ports
sonar list --host "*" # every host, with a HOST column
sonar info 3000 --host hetzner
```
A registered host runs the same daemon, and the daemon on this machine keeps
one SSH connection to it — `ssh <target> sonar daemon stdio`, looking in
`~/.local/bin` before the remote `PATH` — and multiplexes
what it reports into the state every client already reads. Nothing new listens
anywhere: the remote daemon's socket stays private to the SSH user, and clients
never speak SSH themselves.
Every row now carries the host it came from. Local rows say `localhost` and
keep the keys they always had, so nothing that reads sonar today changes;
remote rows say the registered name and are keyed `<host>/<port>:<bind>`, which
is what lets port 3000 on two machines be two rows. A subscriber sees only
localhost unless it asks for more (`state.subscribe {"hosts": ["*"]}`).
The target goes to `ssh` untouched, so `~/.ssh/config` aliases, `ProxyJump` and
identities all apply; `--ssh-arg`, `--identity` and `--port` cover what a config
does not, and `--remote-bin` names a sonar that is somewhere else. The default
command runs through `sh`, so a Windows host, whose sonar comes from the
PowerShell installer, needs `--remote-bin sonar`. sonar stores no password and no key. A host that goes away keeps its
row and its status while the daemon retries, backing off from one second to
thirty for as long as it stays registered.
`--host` also still takes a bare `user@host` that sonar knows nothing about: it
falls back to the agentless `ssh` + `ss`/`lsof` scan and prints a hint to
`sonar remote install`.
#### Acting on another machine
Every write takes `--host` too, and does there exactly what it does here:
```sh
sonar kill 3000 --host hetzner # stop a port on that machine
sonar kill -g api --host hetzner # a whole group of its services
sonar kill --all --filter docker --host hetzner # its containers
sonar up api --host hetzner # start a group from its sonar.yaml
sonar logs 3000 --host hetzner # tail its output here
sonar rename 3000 storefront --host hetzner # its name, in its database
sonar assign 3000 storefront --host hetzner
```
The local daemon forwards the call over that host's bridge and hands back what
the remote daemon answered, in the same envelope a local call returns — every
result row says which host it happened on, and a kill's `affected` carries the
`<host>/<port>:<bind>` keys the stream uses for those rows. A streaming command
streams: `sonar up --host` prints each service as the far side starts it, and
Ctrl-C stops the remote work rather than just this terminal.
Because a row's key already names its host, a client can hand one straight back
as a selector — `{"key": "hetzner/3000:127.0.0.1"}` is the whole of a selector,
host included. One call acts on one machine; naming two is an error rather than
half a kill on each.
Two things stay local. `sonar attach` puts *this* terminal in front of a
process, so it refuses `--host` and says to ssh over and attach there. And an
agent session is state this daemon holds, so `sonar kill --session` has no
remote form. Everything else needs the daemon running here — it is where the
connection to the other machine lives — and says so instead of quietly scanning
this machine instead.
`sonar up --host` needs the group named: the `sonar.yaml` at your working
directory is a path on this machine, and it is the remote daemon that reads the
file and starts the services.
### The daemon
One background process scans ports, resolves groups, polls health, keeps the
database and streams changes to whoever is subscribed — the CLI, the desktop
app, and editors.
```sh
sonar serve # in the foreground
sonar serve --detach # in the background
sonar daemon status # pid, uptime, subscribers, scans, intervals, claim ttl
sonar daemon path # the socket it listens on
sonar daemon log -n 50 -f # what it is doing
sonar daemon restart
sonar daemon stop
```
```sh
sonar daemon path
sonar daemon status --json
sonar daemon log -n 5
# check
```
| What | Where |
|---|---|
| Socket | `$XDG_RUNTIME_DIR/sonar/daemon.sock`, else `~/.config/sonar/daemon.sock`; `\\.\pipe\sonar` on Windows |
| Database | `~/.config/sonar/sonar.db` (`SONAR_DB` overrides) |
| Daemon log | `~/.config/sonar/daemon.log`, rotated at 5 MiB, three kept |
| Run logs | `~/.config/sonar/logs/<group>/<service>.log` |
| Config | `~/.config/sonar/config.yaml` |
`SONAR_SOCKET` overrides the socket path everywhere, for both the daemon and
its clients — useful for a second isolated instance. The socket is created
0600 in a 0700 directory, so only you can talk to it. Only one daemon runs at a
time; a socket left behind by a crash is cleaned up on the next start.
The daemon stops on its own after 30 minutes with no clients and no
subscribers. Set `daemon.idle_timeout` in the config file to change that, or
`0` to keep it running.
Ports are scanned every 2 seconds while something is changing; when nothing is,
the scanner slows itself down to 5 seconds with a subscriber connected and 10
seconds without one. `daemon.scan_interval` moves that base — minimum 1s — and
both ceilings scale with it, so raising it to `5s` backs off to 12.5s and 25s
rather than pinning the curve at the old caps. `daemon.stats_interval` is the
separate cadence at which cpu, memory and the host load strip refresh while
something is subscribed. Both are read when the daemon starts: edit the file,
then `sonar daemon restart`. `sonar daemon status` prints the values in
effect (`scan base`, `stats tick`) next to the adaptive interval the scanner is
on right now.
A port sonar claims, for a `port: auto` service or through `sonar claim` or
`claim_port`, is kept for a day after it was last claimed. `claims.ttl` in the
config file changes that. It is also read when the daemon starts, and
`sonar daemon status` prints it as `claim ttl`.
A subscriber that asks for `include: ["health"]` makes the daemon probe **every
listening port** on a slower cadence, not only the services that declare a
`health:` path — those are polled on every tick and reach every subscriber
whether or not health was asked for.
### Configuration
`~/.config/sonar/config.yaml` is optional; flags always win.
```sh
sonar config path
sonar config init
# check
```
```sh
sonar config edit # open it in $EDITOR
```
```yaml
list:
columns: [port, process, group, container, image, containerport, url]
sort: port # port | pid | name | type
```
---
[Read more](https://github.com/raskrebs/sonar)