npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

diffstalkerd

v0.13.6

Published

diffstalker daemon: REST API + SSE git state over a unix socket

Readme

diffstalkerd

diffstalkerd is the diffstalker daemon: a Node http server that exposes @diffstalker/core (git status, diffs, staging, live change events) behind a REST API + Server-Sent Events. It lets non-terminal clients (editors, web UIs, scripts) talk to the same git state engine the TUI uses.

Published to npm as diffstalkerd, bin-only: it exposes the diffstalkerd executable, not an importable API. The diffstalker CLI depends on it and pulls it in automatically, so most users never install it directly.

Its primary client is the diffstalker CLI (packages/cli), which spawns or attaches to this daemon over a unix socket and consumes it through the typed @diffstalker/client. A read-only web UI (Vue) ships inside this package and is served at GET / when the daemon binds a TCP port (--port N) — same-origin, since a browser can't reach the unix socket and the daemon sends no CORS headers.

Running

From the repo root:

bun packages/daemon/src/index.ts            # development (source)
bun run build                               # compile all packages
node packages/daemon/dist/index.js          # run the compiled daemon
packages/daemon/bin/diffstalkerd            # same, via the bin wrapper

Arguments:

REPO_PATH...         Repository to open on startup, so a client finds it
                     already there. Relative paths resolve against the
                     current directory and ~ expands; a path that will not
                     open stops the daemon before it listens

Options:

--socket PATH        Bind a unix socket at PATH
--instance NAME      Bind <NAME>.sock in the runtime dir, so several daemons
                     can run side by side (clients select it with the same
                     name: diffstalker --instance NAME)
                     (default: $XDG_RUNTIME_DIR/diffstalker/diffstalkerd.sock)
--no-socket          Do not bind a unix socket (requires --port)
--port N             Also bind TCP port N (loopback only) for the web UI.
                     The port serves the web API subset; the unix socket
                     keeps the full API
--follow-file PATH   Hook file to follow (created when missing)
                     (default: ~/.cache/diffstalker/target)
--no-follow          Disable follow mode (no hook-file watcher)
--web-root PATH      Directory of built web-UI assets to serve at GET /
                     (default: ./web next to the daemon bundle; the published
                     package ships it there. Missing dir → API-only, non-fatal)
--no-update-check    Never ask npm which version is latest; GET /version
                     then reports the running version only
--debug              Also log debug lines (every refused request, every
                     ignored follow target) to stderr. See docs/logging.md
--version, -v        Print the running version and exit
--help, -h           Show this help

--socket and --port are not mutually exclusive — binding both is the normal deployment. The unix socket is the daemon's identity (one per user) and is always bound unless --no-socket; --port adds the browser's transport on top, over the same git state. --follow-file and --no-follow are exclusive, as are --socket and --no-socket.

diffstalkerd --port 7337      # socket for the TUI + :7337 for the browser

That single daemon is what both clients talk to: diffstalker finds the socket and attaches instead of spawning its own, so the TUI and the browser observe one state over one event stream. Binding only a port (--no-socket --port N) leaves the CLI nothing to attach to, and it will start a second, separate daemon.

A browser can only reach the daemon over TCP (--port), not a unix socket, and the daemon sends no CORS headers — so the web UI is served same-origin from the daemon itself. GET / and any unmatched non-API GET path return the SPA's index.html (client-side routing); hashed /assets/* are served with a long immutable cache. The REST/SSE API always takes precedence over static files, and an unknown path under an API prefix (/health, /version, /repos, /resolve, /worktrees, /events, /follow, /settings, /discovered, /browse) is a JSON 404, never the SPA page.

Without --socket, the daemon binds a unix socket at $XDG_RUNTIME_DIR/diffstalker/diffstalkerd.sock. The directory is created with mode 0700 and the socket is chmod'd to 0600 (owner only). If XDG_RUNTIME_DIR is unset, the daemon refuses to start — there is deliberately no /tmp fallback. Pass --socket PATH explicitly instead. (With a --port to fall back on, an unset XDG_RUNTIME_DIR is a warning rather than a failure: the web UI still works, only the CLI transport is missing.)

--port 0 asks the kernel to pick a free port; the daemon prints the one it actually got.

If a live daemon already answers on the socket path, startup fails; a stale socket file (nothing listening) is removed and reused.

The daemon has no authentication, so it binds loopback only — a unix socket, or --port on 127.0.0.1. There is no option to bind a routable interface (bearer-token auth would be a prerequisite for that, and is not implemented). On a port an origin guard runs: a Host allow-list rejects rebound hostnames (DNS-rebinding defense, 421), and state-changing requests with a cross-site Sec-Fetch-Site or a non-loopback Origin are rejected (CSRF defense, 403). Non-browser clients (the CLI, curl) send neither header and a loopback Host, so they pass. Every response also carries hardening headers (X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy, and a strict Content-Security-Policy for the served SPA). To reach the daemon from another machine, forward the port over SSH (ssh -L). See .github/SECURITY.md at the repo root.

The API surface is chosen per listener, by how well that transport is protected — not per daemon. A unix socket (or an inherited activation fd) is owner-only at the filesystem layer and carries the full API. A TCP port is reachable by any process on the host and carries a reduced API (least privilege): only the endpoints the web UI uses — reads, POST/DELETE /repos, and POST /repos/:id/stage/unstage. The CLI-only mutations (commit, discard, hunk staging, PUT /compare/base, and every remote/branch operation) are not routed on a port at all; requesting one there returns 404 Unknown route.

So a daemon bound to both transports simultaneously is still safe to expose to a browser: the write-capable surface stays behind 0600 file permissions, and no origin-guard bypass can reach it, because those routes are not in the port's routing table to begin with. Each listener gets its own routing table over one shared registry, SSE hub and follow controller.

Embedders can override this with createDaemon({ apiMode }), which forces one surface onto every listener regardless of transport.

Environment

  • XDG_RUNTIME_DIR — base for the default socket path. Required when no --socket/--port is given.
  • XDG_CACHE_HOME — base for the default follow hook file ($XDG_CACHE_HOME/diffstalker/target, falling back to ~/.cache).
  • LISTEN_FDS / LISTEN_PID — systemd socket activation. When systemd hands the process a pre-bound socket (LISTEN_PID matches and LISTEN_FDS >= 1), the daemon listens on the inherited fd (fd 3) and does not create, chmod, or unlink any socket file. An explicit --socket (or --no-socket) takes precedence; --port does not suppress it, since the port is additive. Only fd 3 is used — a .socket unit with several ListenStream= entries would have all but the first ignored.

systemd (user service)

The Arch package installs a user unit at /usr/lib/systemd/user/diffstalkerd.service:

systemctl --user enable --now diffstalkerd
# web UI at http://diffstalker.localhost:7337/

It runs diffstalkerd --port 7337, which binds the default unix socket and the port — so the terminal UI attaches to the same daemon the browser is talking to, and systemctl --user status diffstalkerd is the whole story.

A user unit, never a system one: the socket lives under $XDG_RUNTIME_DIR (per-user, 0700), and every git call runs as the invoking user with their config, ssh keys and worktrees. A system service would be the wrong uid for all three.

Deliberately not socket-activated, even though the daemon implements the sd_listen_fds protocol. The CLI health-probes the socket with a 250 ms budget before falling back to spawning its own daemon; a cold activated start overruns that, so the TUI would try to spawn a second daemon and hit already running. An always-warm service answers the probe immediately. The daemon also has no idle timeout — it holds watchers on open repos and is meant to stay warm for the session — so lazy activation buys little here.

API

Current surface. All bodies are JSON; errors are non-2xx with {"error": "..."}.

Naming rule: mutations are POST /repos/:id/<verb-kebab> (switch-branch, soft-reset, stage-hunk); nouns with GET/PUT (/branches, /compare/base) are resources.

Every mutation responds with the unified envelope {state, result?}: state is the refreshed shared state (same shape as GET /status) and result is the optional human-readable outcome text of remote/branch/undo operations. Status codes: 400 for invalid input (missing fields, flag-shaped refs, resets past the root), 404 for unknown repo ids / files not in status, 409 when the repo state refuses the operation (conflicts, rejected pushes, an operation already in progress, nothing to abort), 410 when the repo is open but its directory is gone or is no longer a git repo ({error} says which; GET /status, GET /repos/:id/events and DELETE /repos/:id still answer, so a client can see why and let go), 500 for real failures.

Ref-like values (the body fields name, hash, branch and the base query param) must not be empty or start with -; such values are rejected with a 400 so they can never be parsed as git flags.

Reads

| Method | Path | What | | ------ | -------------------------- | ------------------------------------------------ | | GET | /health | {ok, ready, home, http: {port}, symbols} — http.port is the loopback port a browser reaches the web UI on, or null when only a socket is bound (what diffstalker link needs to build a URL at all) | | GET | /version | {current, latest, status, install} — the running version against npm's latest dist-tag (current | outdated | ahead | unknown). The npm lookup is cached (6h, 5min after a failure) and only ever runs when this endpoint is called; --no-update-check skips it, leaving latest: null. install is {method, package, command} — how this daemon was installed (npm | bun | pnpm | yarn | pacman | unknown) and the one command that updates it, detected once from where the daemon's own files live. An install nothing owns reports unknown with a null command rather than a guess | | GET | /repos | List open repos (id, path, branch) | | POST | /repos | Open a repo: {"path": "/abs/path"} → {id, path} (201 created, 200 already open). 400 for a relative path or a directory that is not a git repository, 404 when the path does not exist. One missing last segment under a repo still opens the repo above it (<repo>/nope opens <repo>); a path missing more than one level deep (<repo>/nope/deeper) is a 404 | | DELETE | /repos/:id | Close a repo (refcounted per open) | | GET | /repos/:id/worktrees | Registered worktrees (path, branch, head, isBare, lastActivity, aheadOfBase): main worktree, linked worktrees, and the bare entry in a bare-worktree layout | | GET | /worktrees?path= | Same as above, but for a raw filesystem path instead of an already-opened repo id (e.g. a recently-visited repo a client hasn't opened on this daemon) | | GET | /resolve?path= | {openable, root} — can this exact path be opened, and as which worktree, without opening it. Same resolver as POST /repos, but the path must EXIST (git resolves a missing path to its parent worktree, which would make a typo look openable). A relative path is a 400; not-a-repo is a 200 answer | | GET | /repos/:id/status | Shared state: status, hunk counts, stash list, in-progress operation, error, working-file mtimes (path → mtimeMs; what browser clients build mtime-based auto mode on) | | GET | /repos/:id/diff?path=&staged=&whole= | Diff; whole tree without path, staged side with staged=true. whole=true draws ONE file in full instead of as hunks (wide context, so every unchanged line is present and the changed ones are marked in place) — it requires path (400 without one: a wide whole-tree diff is unbounded work behind an unthrottled GET), it is never annotated with hunk edit times (see the note under this table), and it is ignored for an untracked file, whose diff is the whole file already. Per-file cap: a file's diff over 256 KB or 5,000 lines is withheld — its headers are kept and its body becomes one Large file — diff not shown (…) line, the same shape git uses for Binary files … differ. Applies to every diff-bearing response (compare, commit diffs, journal), and it is what bounds a whole-file diff | | GET | /repos/:id/history?count= | Commit history (CommitInfo[], default 100, ISO dates) | | GET | /repos/:id/commits/:hash | One commit by hash or short hash (404 on unknown) — what a link to a commit outside a client's loaded log resolves through | | GET | /repos/:id/commits/:hash/diff?path=&whole= | Diff introduced by one commit (404 on unknown hash; merge and --allow-empty commits are 200 with an empty diff, matching the CLI). path narrows to one file and whole=true widens its context to the whole file (400 without path). The pathspec carries BOTH sides of a rename, because scoping to the new path alone makes git report a rename as a plain add | | GET | /repos/:id/commits/:hash/files | The same commit's changes split per file, each with git's status and +/- counts (same row shape as /compare's files). 404 on unknown hash; a merge commit is 200 with no rows | | GET | /repos/:id/head-message | HEAD commit message for amend prefill: {message} ("" when the repo has no commits) | | GET | /repos/:id/branches | Local branches (name, current, tracking) | | GET | /repos/:id/base-branches | Candidate compare bases (remote branches in recent history) | | GET | /repos/:id/compare/base | Effective compare base: {base} (persisted choice or discovered default, null when none) | | PUT | /repos/:id/compare/base | Persist the compare base: {"branch": "origin/main"} → {base}; the ref is validated first (400 on an unknown ref, nothing persisted) | | GET | /repos/:id/compare?base=&head=&staged=&unstaged=&untracked= | Base-vs-head CompareDiff (three-dot: merge-base(base, head)...head); base defaults to the effective base — a stale persisted base falls back to the discovered default, 422 when nothing usable resolves; an explicit unknown base is a 400; a base with no common history is a 422. head defaults to HEAD and is checked exactly as base is (400 Unknown head ref: …); naming one reads a single layer of a stack (see /compare/stack). The three uncommitted categories are independent flags, each defaulting to false; a row folded in carries uncommitted: 'staged' \| 'unstaged' \| 'both' \| 'untracked'. staged and unstaged together are read as ONE git diff HEAD (side both), never two diffs. Uncommitted work only exists against HEAD, so any of the three flags together with head is a 400 | | GET | /repos/:id/compare/count?base=&head= | How many commits /compare would list, as {baseBranch, commits}, without building the diff (what badges the Compare tab). Same base and head resolution and the same 400/422 answers as /compare | | GET | /repos/:id/compare/stack?base= | The stack of branches from the base (the trunk) up to HEAD and above it, as git topology shows it: {trunk, layers, forkedAbove}, layers bottom to top, each {name, refs, tip, commits, isHead} (CompareStack in @diffstalker/core/types/stack; rules in docs/stacked-compare.md). base resolves exactly as on /compare; a base with no common history is an empty layers, not a 422. The HEAD layer is always present, named after the checked-out branch or HEAD when detached. Branches above HEAD that fork are left out and forkedAbove says so. A remote-tracking copy of a local branch is folded into that branch's layer, never a layer of its own. The walk is capped at 1000 first-parent commits from the merge-base: a layer deeper than that is not seen, and the lowest layer that is seen then counts its commits from the cut | | GET | /repos/:id/compare/file?path=&base=&head=&uncommitted=&whole= | One file's diff inside a comparison, as a DiffResult. The stacked views pull a range whole and split it client-side, so they have no per-file request of their own to widen. uncommitted names the row's own comparison with the same word the row carries in /compare's files[].uncommitted — staged (HEAD vs index), unstaged (index vs working tree), both (HEAD vs working tree) or untracked (read from disk, git diff would answer empty) — and absent means a committed row against the base. Compare's stack mixes them and they are genuinely different comparisons. (It is not called side: on this API side is always one tree, worktree | index | head, as on /blob and /media.) Same rename-preserving pathspec as above. head narrows the committed range as on /compare, and is a 400 together with uncommitted. 400 without path, or on an unknown uncommitted | | GET | /repos/:id/tree?dir=&hidden=&ignored= | One directory level (name, path, type), gitignored/hidden filtered by default (hidden=true / ignored=true include them, mirroring the TUI toggles), files annotated with gitStatus + staged, changed dirs with hasChanges (400 outside the repo root or on the git directory; 404 when the dir does not exist; 400 Not a directory (a file): … when the path exists but is not a directory). The git directory is never listed, even with hidden=true | | GET | /repos/:id/file?path= | File content for display with flags: {content, binary, truncated, tooLarge, size, totalLines, media?} — binary/oversized come back with empty content and the flag set, never prose; media carries the image verdict ({image, refusal, version}) when the bytes say something about it; 404 when the path does not exist; 400 Not a regular file (a directory): … (or a FIFO, a socket, a device) when it exists but is not a regular file, and 400 when it resolves outside the repo root or addresses the git directory | | GET | /repos/:id/media?path=&staged= | Image metadata for one changed file, both sides resolved server-side (renames included): {old, new}, each side {path, side, bytes, oid, image, refusal, version} or null when the file does not exist there. staged is required and spelled true or false like every boolean param. 404 when the path is not in status. See Image bytes | | GET | /repos/:id/blob?path=&side=&v= | Raw image bytes for <img src>; side is worktree | index | head, v is an ignored cache key. Only PNG/JPEG/GIF, typed from magic bytes. Status-independent (no git status), so a clean committed image is reachable. See Image bytes | | GET | /repos/:id/files | All tracked + untracked (not ignored) paths: the fuzzy-finder source | | GET | /repos/:id/journal?since= | Append-only edit journal: {epoch, prunedBefore, entries}; since=<seq> returns only entries with a higher seq (all when omitted) | | GET | /repos/:id/events | SSE: snapshot on connect, then state-change events from the file watcher and journal-append {entries} per journal observation | | GET | /events?repo= | Daemon-scope SSE: snapshot (open repos) on connect, then repo-opened / repo-closed / follow-change / settings-change / discovery-change. With repo=<id> the same stream also carries that repo's events — repo-snapshot right after the daemon snapshot, then state-change / journal-append as on /repos/:id/events. An id that is not open is still a 200: one repo-missing {id} follows the snapshot; an empty repo= is the same as none. See One stream per tab | | GET | /follow | Follow state: {targetFile, enabled, followedRepoId, followedPath} | | GET | /settings | Daemon settings: {watchRoots, persisted}. persisted: false means this daemon holds them in memory only (no settings file) | | GET | /discovered | Repos found under the watch directories: {roots: [{path, repos: [{path, name, branch, lastActivity}], error, capped}]}. See Watch directories | | GET | /browse?path= | One directory level of the daemon's filesystem, for picking a watch directory: {path, parent, home, entries: [{name, path, isRepo}]}. Directory names only — no files, no dot directories. No path starts at the daemon's home; 400 for a relative path, 404 for one it cannot read |

Mutations (all respond {state, result?})

| Method | Path | Body / behavior | | ------ | -------------------------- | ------------------------------------------------ | | PUT | /settings | Replace the settings: {"watchRoots": ["/abs/dir", "~/projects"]} → {watchRoots, persisted}. Whole document, not a patch. Each path is expanded, made absolute and checked to be an existing directory; a refusal is a 400 naming the path, and nothing is saved | | POST | /discovered/rescan | Re-walk every watch directory now and return the new {roots} (filesystem only, no git processes) | | POST | /repos/:id/stage | {"path": "file.txt"} — stage one file (404 when not in status) | | POST | /repos/:id/unstage | {"path": "file.txt"} — unstage one file | | POST | /repos/:id/stage-all | Stage everything | | POST | /repos/:id/unstage-all | Unstage everything | | POST | /repos/:id/discard | {"path": "file.txt"} — discard an unstaged change / delete an untracked file; 409 on a staged file (unstage first) | | POST | /repos/:id/stage-hunk | {"patch": "<unified diff>"} — apply one hunk to the index; a stale patch is a 409 | | POST | /repos/:id/unstage-hunk | {"patch": "<unified diff>"} — reverse-apply one hunk | | POST | /repos/:id/commit | {"message": "...", "amend"?: bool} — 400 on an empty message or nothing staged; a commit that creates no commit is never a 200 | | POST | /repos/:id/push | Push the current branch; rejected push is a 409 | | POST | /repos/:id/fetch | Fetch from the remote | | POST | /repos/:id/pull | Pull with rebase; a conflict is a 409 and leaves the repo mid-rebase — see /abort | | POST | /repos/:id/stash | {"message"?: "..."} — stash the working tree | | POST | /repos/:id/stash-pop | {"index"?: 0} — pop a stash entry (see stashList in the state); a conflicting pop is a 409 and keeps the entry | | POST | /repos/:id/switch-branch | {"name": "main"} — switch branches | | POST | /repos/:id/create-branch | {"name": "feat-x"} — create and switch | | POST | /repos/:id/soft-reset | {"count"?: 1} — move HEAD back, keep changes staged; past the root commit is a 400 | | POST | /repos/:id/cherry-pick | {"hash": "abc123"} — apply a commit; a conflict is a 409 | | POST | /repos/:id/revert | {"hash": "abc123"} — revert a commit with a new commit | | POST | /repos/:id/abort | Abort whatever multi-step operation the repo is stopped in (rebase, cherry-pick, revert, merge); 409 when nothing is in progress | | POST | /repos/:id/rebase-continue | Continue a stopped rebase after conflicts are resolved and staged; 409 when no rebase is in progress |

A conflicted pull/cherry-pick leaves the repo stopped mid-operation; GET /status reports it in operationInProgress and POST /abort returns the repo to its pre-operation state — no shell required. The failing mutation refreshes the shared state before it answers, so the state-change event carries the wedge too, and /status serves that same cached state rather than re-reading the git dir. For an operation started from a shell the value lags by the watcher's debounce plus one refresh (roughly 150–250 ms after HEAD or the index changes): a /status read in that window still shows the previous value. A failed git operation's {error} is git's own message (a rejected push, a patch that does not apply); the daemon log records the same text.

History, compare, and explorer data are stateless, pulled on demand: the daemon never holds a client's selection, loaded history, or tree expansion. A commit or branch switch fires the working-tree state-change event (the git watcher covers HEAD/refs), which is the client's signal to re-pull.

Note on /file's truncated flag: it strictly means the content was cut at the display line limit. A merely large file that fits within the limit is served whole with truncated: false (the old TUI code conflated the two: a >100KB file used to be flagged truncated regardless).

Note on whole=true and hunk edit times: a whole-file diff is never stamped. Stamping WRITES into the repo's shared hunk-time map, keyed by a hash of each hunk's +/- body, and a whole-file diff is one hunk per file — so it would write keys the manager's own -U3 refresh never produces, and the file would read back as edited just now. /commits/:hash/diff skips stamping for the same reason. Whole-file mode therefore shows no per-hunk age; the -U3 read is a separate request and keeps all of it. Requesting whole=true on an untracked file changes nothing at all, stamping included: the response is byte-identical to the plain one.

Repo ids are stable hashes of the worktree root, so a cached id still addresses the same repo after a daemon restart.

Every path on the wire — in status entries, hunk counts, compare and commit rows — is the raw path, exactly as git status spells it, even when git C-quotes it in patch text (a tab, a double quote, a backslash, a non-ASCII name): the daemon reads git's list outputs with -z and unquotes diff --git headers. The header lines inside a DiffResult keep git's quoting, because a hunk patch built from them must be one git apply accepts.

One stream per tab

There are two event streams: GET /events for the daemon (open repos, follow, settings, discovery) and GET /repos/:id/events for one repo (status, journal). The CLI opens both. A browser must not: Chrome allows six HTTP/1.1 connections per host, so two streams per tab meant three tabs used them all up and every later request queued forever — the page looked hung. GET /events?repo=<id> folds both into ONE stream:

  • On connect: the daemon snapshot first, then the repo's repo-snapshot (renamed only because snapshot is already taken on this stream). After that, state-change and journal-append exactly as on /repos/:id/events, with the same payloads and the same dedup.
  • An id that is not open (the daemon restarted and the browser's EventSource reconnected before the page re-POSTed /repos) is still a 200 with the normal daemon stream, plus one repo-missing {id} after the snapshot. It is never a 404: a non-200 makes EventSource give up for good, which would also cut off the daemon events.
  • If the repo is closed while the stream is live (its last ref released), the stream stays up: it gets repo-missing {id} (before the daemon's repo-closed) and stops carrying repo events. A /repos/:id/events stream is ended in that case, as before.

Even one stream per tab fills the pool once six tabs are open, so the web UI also closes its stream in a tab that has been hidden for ten seconds and reopens it when the tab is shown again (the repo ref is kept; the daemon resends both snapshots on the reopen). A browser client of your own should do the same.

Journal

GET /repos/:id/journal serves the repo's append-only edit journal: an immutable, seq-ordered log of per-hunk diff blurbs plus boundary dividers (commits, checkouts, stashes, operation transitions). Entries are JSON-native; the embedded diff is a DiffResult in the same wire shape as /diff responses.

  • entries — those with seq > since (all when since is omitted); seq is the only ordering axis, ts (epoch ms) is a display label.
  • epoch — opaque string minted per journal store. A mismatch with a client's cached epoch means the store was reset (daemon restart, LRU eviction): discard the cache and refetch from scratch.
  • prunedBefore — highest pruned seq, 0 when nothing is pruned. A value above a client's last seen seq is a gap ?since can never fill: full refetch.

New entries stream as journal-append {entries} on the per-repo GET /repos/:id/events channel (and on a combined GET /events?repo= stream), one event per observation (possibly several hunks), batched so clients apply them atomically.

The journal store lives above the repo's manager lifecycle: closing a repo's last client (a browser F5) disposes the manager but keeps the store, so a reopen resumes the same chronology under the same epoch. Stores are LRU-capped daemon-wide; an open repo's store is never evicted.

Image bytes

GET /repos/:id/blob is the only endpoint that hands raw repository bytes to a browser, and GET /repos/:id/media is the metadata that tells the web UI which bytes to ask for. Both are routed on every listener (the socket and the port), because the web UI is what needs them.

The rule the whole design rests on: what we say a file is comes from its magic bytes, re-derived from the exact buffer the route is about to write, on every request. Never from the extension, never from a query parameter, never from a verdict an earlier /file or /media response cached. A repo file called logo.png holding <svg><script> is same-origin script the moment we agree with its name, so it gets zero bytes and a 415.

Allow-list

Three formats, and nothing else is ever served:

| Format | Content-Type | Structural gate | | ------ | ------------ | --------------- | | PNG (still) | image/png | PNG signature, IHDR dimensions, a legal bit-depth/colour-type pair, a bounded walk to the first IDAT. An acTL chunk (APNG) is refused — it is a second animation decoder | | JPEG (baseline / progressive) | image/jpeg | SOI plus a legal marker, a bounded segment walk to the first SOF; SOF0/SOF1/SOF2 only (lossless, arithmetic and hierarchical SOFs are refused) | | GIF (still or animated) | image/gif | GIF87a/GIF89a, a walk over the whole block stream counting image descriptors. Validated only with the entire file in hand, which is what MAX_GIF_BYTES bounds |

Everything else is refused by default: SVG, HTML/XML, PDF, WebP, AVIF/HEIC, TIFF, BMP, ICO, JPEG XL, fonts, wasm/ELF/PE, archives, video and audio, and anything unrecognized or recognized-but-invalid. The daemon never decodes, transcodes, re-encodes or strips metadata — validation is fixed-offset integer reads and bounded walks. No image library (sharp, jimp, canvas, libvips) is a dependency of any package, and the browser stays the sandbox.

Caps

Enforced before any byte is written, and again on the buffer being written:

MAX_IMAGE_BYTES     = 8 MiB    any allowed format
MAX_GIF_BYTES       = 2 MiB    GIF only, bounds the frame walk
MAX_IMAGE_DIMENSION = 8192     px per side
MAX_IMAGE_PIXELS    = 16 MPix  width * height, and any one GIF frame rect
MAX_GIF_FRAMES      = 256
MAX_ANIMATED_PIXELS = 32 MPix  frames * screen, and the sum of the frame rects
MAX_ICC_BYTES       = 64 KiB   PNG iCCP / JPEG APP2 run

The pixel budget, not the byte cap, is the real control: compression ratio is unbounded, so a 300-byte PNG can declare 60000×60000 and cost the renderer gigabytes of RGBA. MAX_FILE_SIZE (1 MiB) is untouched — that is the text cap for /file.

A GIF declares its dimensions twice — the logical screen in the header, and a rectangle on every frame's image descriptor — and a rect may legally be bigger than the screen it composites into. So both are charged: every rect against the per-side and per-image caps a still image gets, and the sum of the rects against the animated cap on top. Checking the header alone would let a 1×1 GIF carry an 8000×4000 frame.

Concurrency is bounded too: 4 requests in flight per daemon with a queue of 64 waiters, then 503. One budget for the whole daemon, shared by every listener and by both endpoints — /media inspects up to two sides, so it costs the same spawns and the same buffers as /blob and takes the same slot. One changed binary file costs up to three of those requests: a /media for the verdict plus a /blob per side.

A slot is held until the response is finished on the socket, not just until the read is done: the bytes stay resident while the client drains them, so an earlier release would bound reads and leave memory unbounded. The release is wired to the request and the response, because the two runtimes report the end differently — node destroys the response when a client aborts, while bun signals the abort only on the request. Every path either ends the response or destroys the request, so the slot always comes back.

The two endpoints read different amounts, but they always reach the same verdict about what a file is. /media only reports that verdict, so it reads the window that decides the file and no more — a 64 KiB header for PNG and JPEG, the whole file for a GIF, whose frame walk spans it. When that window is inconclusive (a JPEG whose frame header sits behind a maximal EXIF segment, say) the read is extended to the whole blob, which MAX_IMAGE_BYTES already bounds, so /media never refuses a file /blob would serve. Only /blob reads the bytes it is going to write.

Refusals

| Status | Cause | | ------ | ----- | | 400 | empty/NUL path, a leading - (git option) or : (pathspec magic), traversal, a symlink escaping the root, any .git path, a non-regular file (directory, symlink, gitlink, FIFO), a bad side, a staged that is missing or not true/false | | 403 | Sec-Fetch-Site present and cross-site, or Sec-Fetch-Dest present and not image (/blob only — /media is JSON and the SPA's own fetch sends Sec-Fetch-Dest: empty). Both absent passes, so curl and the Node client are unaffected | | 404 | unknown repo, or the path does not exist on that side. /media also 404s a path with no status entry | | 413 | over the byte, dimension, pixel or frame cap | | 415 | not allow-listed, or the signature matched and the structure failed. An APNG lands here too: it is not over any budget, it is a format we refuse | | 421 | non-loopback Host (the global origin guard) | | 503 | the blob queue overflowed |

Every refusal writes zero bytes and a plain {"error": "..."} body.

A 200 carries the magic-derived content-type, content-disposition: inline with no filename parameter (a repo-supplied string is never interpolated into a header), a re-asserted x-content-type-options: nosniff, cross-origin-resource-policy: same-origin, vary: sec-fetch-site, sec-fetch-dest, and either cache-control: no-store (side=worktree, whose bytes can change under us) or cache-control: private, no-cache plus an etag of the object id (side=index|head, which are immutable).

Watch directories

A watch directory is a folder the user keeps projects in. The daemon scans each configured one for git repositories and keeps that list live, so a client can offer "your projects" instead of an empty path field — a browser cannot browse the daemon's filesystem, and this is what replaces typing an absolute path every time.

Discovery LISTS repos; it never opens them. Opening is what starts a watcher and git state per repo, so a folder with fifty projects would otherwise cost fifty of each. A discovered repo is {path, name, branch} until a client calls POST /repos for it.

The scan is filesystem-only — no git process. A directory holding a .git entry is a repo, and its branch is read from .git/HEAD (a short sha when detached). It looks at the root's children, and one level further inside a child that is not itself a repo (so ~/projects/work/thing is found), and stops descending as soon as it finds a repo — a repo's own subdirectories, submodules included, are never listed as projects. Dot directories, node_modules and friends are skipped, symlinked directories are not followed, and the list is capped (500 repos, reported as capped: true rather than silently trimmed). A leftover worktree directory whose git dir has been pruned is dropped: it would only produce "not a git repository" when clicked.

A client picks a watch directory through GET /browse, which walks the daemon's directories one level at a time. That has to happen here: a browser is never handed a real path by its own file pickers (webkitdirectory gives relative names, showDirectoryPicker a bare handle), so the one thing this API needs — an absolute path on the daemon's machine — is exactly what the page cannot produce.

Each root also gets a chokidar watcher one level deep, so a clone appearing in the folder shows up without anyone reloading; directory add/remove is the only thing it reacts to, debounced. The watcher deliberately does not look inside a repo's .git, so a branch label can lag a checkout made elsewhere — POST /discovered/rescan is how a client refreshes it.

Settings persist in ~/.config/diffstalker/daemon.json (or $XDG_CONFIG_HOME), written atomically and re-read at startup. A root that is configured but currently unreadable (an unmounted disk) keeps its place in the settings and is reported with an error — it is never quietly dropped. Both /settings and /discovered are served on every transport, including a --port listener: the settings UI lives in the web client.

Follow mode

The daemon owns the truth, clients decide policy. It watches ONE hook file (--follow-file, default ~/.cache/diffstalker/target; created when missing); external tools append a repo or file path to it to signal "focus this". On change the daemon resolves the path to a worktree root (same normalization as POST /repos, including bare containers), auto-opens the repo, and broadcasts follow-change {repoId, path, rawContent} on GET /events — path is the resolved hook-file content (it may point at a file inside the repo), rawContent the literal line. Content that does not resolve to a git repository broadcasts nothing and leaves the follow state untouched.

The daemon holds one internal "follow" reference on the followed repo so it stays open with no clients attached; switching targets releases the previous follow-ref (closing that repo when nothing else holds it — clients see repo-closed). Whether a client reacts to follow-change is entirely client-side; the daemon keeps no per-client follow state. A daemon restart re-follows whatever the hook file last pointed at. GET /follow reports the current state; with --no-follow it reports {enabled: false} and no watcher exists.

Examples over the default unix socket:

SOCK="$XDG_RUNTIME_DIR/diffstalker/diffstalkerd.sock"

curl --unix-socket "$SOCK" http://localhost/health
curl --unix-socket "$SOCK" -X POST -d '{"path": "/home/me/repo"}' http://localhost/repos
curl --unix-socket "$SOCK" http://localhost/repos/<id>/status
curl --unix-socket "$SOCK" 'http://localhost/repos/<id>/diff?path=file.txt'
curl --unix-socket "$SOCK" -X POST -d '{"path": "file.txt"}' http://localhost/repos/<id>/stage
curl --unix-socket "$SOCK" -N http://localhost/repos/<id>/events
curl --unix-socket "$SOCK" -N http://localhost/events
curl --unix-socket "$SOCK" http://localhost/follow
curl --unix-socket "$SOCK" -X PUT -d '{"watchRoots": ["~/projects"]}' http://localhost/settings
curl --unix-socket "$SOCK" http://localhost/discovered