Repository navigation
One workspace daemon shared by every frontend - #105
Merged
Merged
Conversation
Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through `daemon.Ensure` and the control-method registries instead of owning its own store, watchers, and initial index pass (`docs/daemon.md` maps the ten tools onto the recommended daemon seams). Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it; `dexter lsp` is a raw stdio proxy onto it, and `lookup`, `references`, `reindex`, and `stop` are control clients. Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers. ## Highlights - Ownership is an advisory kernel lock held for the process lifetime, so a crash releases it at once. The socket binds before the first index pass, so opening an editor never waits on a cold build, and the daemon exits after 15 minutes with no clients (`DEXTER_DAEMON_IDLE_TIMEOUT`, `0` keeps it). - The handshake carries a single contract version, bumped together with `IndexVersion` only when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those. - `dexter stop [path] [--force]` ends a workspace daemon by workspace, not by pid. Plain `stop` goes through the protocol and refuses while clients are attached; `--force` skips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period. - `--root`/`-C` on every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, a `reindex` target included, resolve from the named root. - A guard refuses to treat a directory with no `mix.exs`, `.git`, or `.dexter` as a workspace unless `-y/--yes` is passed, so a mistyped command in the home directory cannot build an index over everything. `lsp` warns and serves instead, because an editor is authoritative about what the user opened. ## Resilience - One unwatchable directory no longer disables the whole native watcher; the runtime adds a periodic reconciliation whenever watching is missing or partial, which keeps a daemon-only workspace fresh without an editor. - Runtime files prefer `$XDG_RUNTIME_DIR/dexter`, then `/tmp/dexter-<uid>`, then `$TMPDIR/dexter-<uid>`. `$TMPDIR` is deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long for `sockaddr_un` are skipped. - Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor-launched daemon with a stripped PATH no longer disables stdlib indexing or formatting. - Spawned daemons are reaped; a panic in one connection is contained; a daemon whose socket disappears exits so a replacement can start; transient accept errors back off instead of taking the daemon down; CLI control calls are bounded so an unavailable filesystem cannot hang a shell forever. ## Follow-ups - The CLI's name-based `lookup` remains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiring `workspace/lookup` to the shared language services is the next step, alongside the MCP control methods sketched in `docs/daemon.md`. - `Version` and `IndexVersion` are intentionally not bumped here; the release PR bumps them together with `daemon.ContractVersion`. ## Testing `go test ./...`, `go test -race ./...`, `golangci-lint`, and `gofmt` are clean. New coverage includes the daemon transport and registries, ownership and symlink identity, contract-mismatch directions and replacement, forced stop with SIGKILL escalation, the non-project guard, socket-disappearance exit, connection-panic isolation, watch degradation and the periodic fallback, and end-to-end CLI/LSP runs against real daemons.
JesseHerrick
force-pushed
the
workspace-daemon
branch
from
September 22, 2026 05:11
0f49727 to
62b819a
Compare
CI caught that `stop --force` could report success while the target process was still alive: the escalation waited for the ownership lock to be released, and a process that never held it (a wedged daemon whose lock file was removed, or the test's trap-TERM helper) made that wait look satisfied immediately, so no SIGKILL was sent. The wait now polls the process itself, with the lock as a zombie-safe fallback at the end, which is both the correct condition and deterministic under Linux timing.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 27f4c84. Configure here.
JesseHerrick
added a commit
that referenced
this pull request
Oct 4, 2026
Main now has one workspace daemon for every frontend (#105) and one name navigation for the CLI and the editor (#107). This merge makes `dexter mcp` a frontend of that daemon, like `dexter lsp` and the CLI: - The frontend keeps the MCP protocol and the root negotiation. It opens no store and starts no watcher. For each root it holds one control connection to the daemon (daemon.Ensure), and sends each tool call as the new control method "mcp/tool". - The tool bodies run in the daemon, against its store and its headless language service. Definitions and references use LookupName and ReferenceNames, the same navigation as the editor and `dexter lookup`. - A tool call waits for a cold index for a short time, then answers with a note when the index is still building or has a warning or an error condition (#114). The rename tool refuses until the index is complete. - dexter_reindex uses the daemon's reindex barrier. - Negotiated and fallback roots resolve with the CLI's project-root search. A launch directory that is not a project is refused as a fallback. Removed because the daemon now does this work: the MCP file watcher, the per-root store bindings, the git HEAD watch stop, the blocking reindex, attached mode (`dexter lsp --mcp-listen`, which needs an in-process LSP), deliverEdits and its text-edit applier, CollectReferences, and Serve. The rename tools keep the editor rename machinery. RenameFunction and RenameModule run it on the headless language service, report the files changed, moved, and not written, and return when the index shows the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JesseHerrick
added a commit
that referenced
this pull request
Oct 4, 2026
## What A built-in MCP server, modeled on [gopls mcp](https://go.dev/gopls/features/mcp), so AI agents can navigate Elixir codebases through dexter's index instead of grep: ```sh claude mcp add dexter -- dexter mcp ``` Ten tools, deliberately coarse and agent-oriented rather than 1:1 LSP methods, and addressed by module/function name rather than file+position (Elixir modules are not tied to files, which makes name-based addressing the natural fit for agents): | Tool | What it does | |---|---| | `dexter_workspace` | project layout, index state and active conditions, stdlib status | | `dexter_search` | fuzzy workspace symbol search | | `dexter_definition` | definition with `@doc`/`@spec` and source snippet; follows defdelegate chains; at most 20 clauses shown | | `dexter_references` | references including use-chain injected call sites | | `dexter_module_api` | moduledoc, public functions with signatures and doc summaries, delegates, types, callbacks, submodules | | `dexter_file_outline` | modules/functions a file defines (fresh parse); only regular files inside the project, up to 10 MB | | `dexter_implementations` | behaviour implementors and protocol defimpls | | `dexter_call_hierarchy` | incoming/outgoing calls | | `dexter_reindex` | forces a reindex through the daemon (the index also updates automatically via the daemon's file watcher) | | `dexter_rename_symbol` | workspace-wide rename of a module or function with the editor rename machinery: writes changes, moves convention-following files, reports every file touched, returns when the index shows the rename | Transports: stdio (`dexter mcp`) and streamable HTTP (`dexter mcp --listen ADDR`). `dexter mcp --instructions` prints an agent-facing guide covering Elixir-specific behavior (modules vs files, defdelegate following, use-chain injection, behaviours vs protocols). Uses the official `github.com/modelcontextprotocol/go-sdk` (v1.6.1, stable), the same SDK gopls uses. Tool input schemas are inferred from Go param structs. ## Why a built-in MCP server rather than an LSP bridge? Agent frontends can already drive `dexter lsp` through a generic LSP bridge (Claude Code's LSP tool, for example), so the real question is what built-in tools add over bridging. A bridge inherits LSP's request shapes. Apart from workspace symbol search, every operation is position-based: the agent must find the file, locate the exact line and column, make the call, then open each returned location. Every step is a round trip, and a wrong position silently returns nothing. A bridge also cannot expose anything the protocol does not define. Built-in tools have neither limit: - Name-based: `dexter_definition {module: MyApp.Accounts, function: fetch_user}` answers directly. - Coarse: `dexter_module_api` summarizes a whole module in one call; references include source lines, so no second pass. - Beyond LSP's surface: workspace overview and explicit reindexing have no LSP method to bridge. - Elixir-aware: server instructions cover defdelegate, use-chain injection, and modules vs files. - Client-agnostic: one-line registration in anything that speaks MCP; bridges exist only in some clients. Editors keep the LSP; both share the same index. ## How it works: a frontend of the workspace daemon `dexter mcp` is a frontend of the shared workspace daemon (#105), like `dexter lsp` and the CLI. - **No workspace state in the MCP process.** It opens no index and starts no watcher. For each workspace root it holds one control connection to the daemon (`daemon.Ensure`), which also keeps the daemon alive while the session is open. Freshness comes from the daemon's file watcher and git HEAD poll. When the daemon goes away, the frontend reconnects once per call; a rename is never sent twice. - **Tool bodies run in the daemon.** Each tool call goes to the daemon as one control method, `mcp/tool`, with the tool name and its arguments. The daemon runs the tool against its store and its headless language service. One method (instead of one per tool) keeps each tool's parameter type in one place, shared by the input schema and the body. - **Cancellation.** A canceled tool call sends `$/cancel` to the daemon, which cancels that request's context and frees its slot. A frontend runs at most 32 calls at a time per workspace, so one agent cannot fill the daemon's request limit that editors share. The daemon `ContractVersion` is now 4: a newer frontend replaces an older daemon, and an older frontend gets a clear upgrade error from a newer daemon. - **Shared navigation.** `dexter_definition` and `dexter_references` use `LookupName` and `ReferenceNames`, the same navigation as go-to-definition, find-references, and `dexter lookup` (use chains, injected aliases, generated functions and their declaring lines). - **Index state in the answers (#114).** A tool call waits up to 30 s for a cold index (bounded by the call's context), then answers from what is indexed. An answer from an index that is still building, or that has an active warning or error condition, ends with a note. The rename tool refuses until the index is complete. - **Unsaved editor buffers.** Tools that show source text use an editor's buffer for a file only when that buffer has unsaved edits and the file on disk is not newer than those edits. Otherwise they read the disk; when both changed, the answer says that an editor also has unsaved changes. Index lines are mapped into the buffer with a bounded line diff, and the answer names the files that came from unsaved buffers. - **Roots.** Client roots and the fallback root resolve with the CLI's project-root search, so every frontend reaches the same daemon. A root that is not a project (for example a home directory) is refused, for client roots and for the launch directory. When a daemon already serves the directory through another spelling, the frontend uses the daemon's root. A session serves the first usable root. - **HTTP mode.** `--listen` accepts only loopback addresses unless `--listen-unsafe` is given (which logs a warning). The handler refuses cross-origin requests and keeps the SDK's localhost Host check. Request bodies are limited to 4 MB (413), and an idle HTTP session ends after 30 minutes. - **Rename.** Renames from every frontend run one at a time (`renameSerial`), so two parallel renames cannot lose each other's edits. A canceled rename stops if it has not started to write; one canceled during its writes completes, and the agent is told to check `git status`. The MCP rename does not yet apply the shared rule for buffers that an editor has open (never write over unsaved editor work); the README asks users to save before an agent renames, and the rename tool adopts that rule when it lands in `internal/lsp`. - **Recovery hints** say `dexter stop --force`, which works while frontends are attached. Co-authored-by: Jesse Herrick <jesse@remote.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through
daemon.Ensureand the control-method registries instead of owning its own store, watchers, and initial index pass (docs/daemon.mdmaps the ten tools onto the recommended daemon seams).Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it;
dexter lspis a raw stdio proxy onto it, andlookup,references,reindex, andstopare control clients.Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers.
Highlights
DEXTER_DAEMON_IDLE_TIMEOUT,0keeps it).IndexVersiononly when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those.dexter stop [path] [--force]ends a workspace daemon by workspace, not by pid. Plainstopgoes through the protocol and refuses while clients are attached;--forceskips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period.--root/-Con every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, areindextarget included, resolve from the named root.mix.exs,.git, or.dexteras a workspace unless-y/--yesis passed, so a mistyped command in the home directory cannot build an index over everything.lspwarns and serves instead, because an editor is authoritative about what the user opened.Resilience
$XDG_RUNTIME_DIR/dexter, then/tmp/dexter-<uid>, then$TMPDIR/dexter-<uid>.$TMPDIRis deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long forsockaddr_unare skipped.Follow-ups
lookupremains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiringworkspace/lookupto the shared language services is the next step, alongside the MCP control methods sketched indocs/daemon.md.VersionandIndexVersionare intentionally not bumped here; the release PR bumps them together withdaemon.ContractVersion.Testing
go test ./...,go test -race ./...,golangci-lint, andgofmtare clean. New coverage includes the daemon transport and registries, ownership and symlink identity, contract-mismatch directions and replacement, forced stop with SIGKILL escalation, the non-project guard, socket-disappearance exit, connection-panic isolation, watch degradation and the periodic fallback, and end-to-end CLI/LSP runs against real daemons.Note
High Risk
This is a major architectural change to process ownership, indexing, and LSP startup; regressions could affect index correctness, multi-editor sessions, upgrades, and shutdown/replacement behavior across the whole toolchain.
Overview
Introduces a per-workspace daemon that is the sole owner of the SQLite index, file/Git watchers, mutation queue, and shared language caches. Every frontend attaches to it:
dexter lspbecomes a raw stdio proxy (bytes copied, not re-parsed), whilelookup,references, andreindexcall the daemon over a multiplexed control protocol instead of opening.dexter/dexter.dbdirectly.dexter initstill builds the index locally but takes the same advisory lock and asks an idle daemon to exit.New CLI surface: global
--root/-C,dexter stop(polite shutdown vs--force),--wait/--quieton queries, and a project-root guard (mix.exs,.git, or.dexter;-yto override; LSP warns only). Runtime lives under/tmp/dexter-<uid>with contract versioning (ContractVersion+ handshake) to replace incompatible daemons on upgrade.Adds
internal/daemon(transport, ownership, client/server) andinternal/workspace(shared runtime), fsnotify/FSEvents watching with degraded-path recovery, plusdocs/daemon.mdand broad integration/unit tests.Reviewed by Cursor Bugbot for commit f106ab2. Bugbot is set up for automated code reviews on this repo. Configure here.