heph.nvim: plug-and-play managed daemon (autostart, self-heal, client/server guardrail)

The plugin now manages its own hephd by default (autostart = true): if nothing
is serving the socket it spawns a local daemon against the default XDG paths,
kills only what it spawned on VimLeavePre, and self-heals — rpc.call retries
once through a respawn hook when the connection drops (the prior owner releases
the DB lock on exit, so a respawn can claim it).

- daemon.ensure() connects to an already-running daemon (any mode) or spawns one
  we own; stop_spawned()/is_managed() track lifecycle.
- A server/client daemon you started is always respected (spawn only when nothing
  serves the socket). autostart = false → connect-only, warns/errors if down,
  and clears the self-heal hook so it fails loudly.
- config: autostart defaults true; new `db` option; $HEPH_SOCKET / $HEPH_DB
  fallbacks isolate a dev Neovim onto a separate daemon + DB.

e2e: managed_daemon_spec covers autostart spawn, self-heal-after-kill, and
connect-only error. 10 specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 09:32:32 -07:00
commit e3db2ac550
11 changed files with 277 additions and 18 deletions

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/scheduled_tasks.json
# Python
__pycache__/

View file

@ -17,3 +17,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph.nvim` slice 11a (§8) — the primary surface begins: a Neovim plugin that is a thin client of the local `hephd` over its unix socket. A `vim.uv` JSON-RPC client (blocking `call` via `vim.wait`, id-demuxed, partial-line buffered, JSON `null`→Lua `nil`); buffer-backed nodes (`heph://node/<id>` with `BufReadCmd``node.get` / `BufWriteCmd``node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `<CR>` via a new exact `node.resolve {title}` RPC (alias-then-title, the same mapping that materializes `wiki` links — unresolved links allowed); the daily journal (`:Heph today`); and the `:Heph` command surface. Headless e2e (§9) drives the plugin against a real daemon over a temp socket with a self-contained busted-style runner (no external plugins, no network): journal round-trip, follow-link, and link-two-docs/backlink.
- `heph.nvim` slice 11b (§8) — task views: `list` is enriched to return titled rows (the same shape as `next`, with the canonical-context id) so the Organizational survey needs no per-row `node.get`. The plugin gains the Tactical **`:Heph next`** and Organizational **`:Heph list`** views (`<CR>` opens a task's canonical-context doc), task **capture**, **set-attention**, **done/drop**, **skip**, and per-task **`log`** append — each resolving "the current task" from the buffer (a task node, or a context doc via its `canonical-context` backlink). A `vim.ui.select` picker (Telescope auto-upgrade when installed) backs `:Heph search`/`capture`/`attention`. Headless e2e adds the capture→next→context→checklist→done workflow and the recurring fresh-checklist workflow (completing a recurring task rolls it forward and the next occurrence presents an all-unchecked checklist).
- `heph.nvim` slice 11c (§8) — promotion + CI: `task.promote` mints a committed task from a `- [ ]` context-item line (addressed by its 1-based index) and rewrites that line into a `[[link]]` to the new task; `:Heph promote` does this for the line under the cursor. Wiki-link resolution now excludes a task's canonical-context doc, so `[[Task Title]]` resolves to the task itself (not its identically-titled context doc). The headless e2e suite runs in CI via a Dagger function that bakes a pinned, arch-detected Neovim onto a Rust image and runs the same self-contained suite developers run natively with `mise run test-nvim`; the runner fails on a zero-spec discovery so a misconfigured path can't pass silently.
- `heph.nvim` managed daemon — plug-and-play by default: `require("heph").setup({})` spawns and supervises a local `hephd` against the default paths when none is running, kills only the daemon it spawned on exit, and self-heals (respawns + reconnects if the daemon dies mid-session). A daemon you started yourself (a `server`/`client` architecture, or a service) is always respected — the plugin only spawns when nothing is serving the socket; with `autostart = false` it connects only and warns if unreachable. `$HEPH_SOCKET` / `$HEPH_DB` isolate a development Neovim onto a separate daemon + DB.

View file

@ -24,7 +24,7 @@ buffers; the daemon owns all storage and sync. Built in checkpointed slices on
| `node` | Buffer-backed nodes. A node is a buffer named `heph://node/<id>` with `buftype=acwrite`; `BufReadCmd` loads the body via `node.get`, `BufWriteCmd` saves the whole buffer via `node.update`. |
| `link` | Parse the `[[wiki-link]]` under the cursor (mirroring `extract.rs` grammar) and follow it via `node.resolve` (exact, never fuzzy `search`). Unresolved links are allowed. |
| `journal` | Open/create a dated journal node (idempotent — deterministic id). |
| `daemon` | Locate / spawn / readiness-poll `hephd` (shared with the e2e harness). |
| `daemon` | Managed-daemon lifecycle: `ensure` (connect if a daemon already serves the socket, else spawn one we own), `stop_spawned` (kill only what we spawned, on exit), readiness-poll. Shared with the e2e harness. |
| `config` / `init` | `setup(opts)`, socket resolution, default keymaps. |
| `command` | The `:Heph <subcommand>` dispatch + completion. |
@ -32,6 +32,18 @@ Surfaces never touch SQLite — every operation is a daemon RPC (tech-spec §3).
The plugin is **mode-agnostic**: Tactical/Strategic/Organizational are
plugin-side compositions of daemon primitives, not daemon concepts.
## Daemon lifecycle
`setup({})` is **plug-and-play** by default (`autostart = true`): if nothing is
serving the socket, the plugin spawns a local `hephd` against the default XDG
paths, kills only the daemon *it* spawned on `VimLeavePre`, and **self-heals**
`rpc.call` retries once through a respawn hook if the connection drops. It only
ever spawns when nothing is already serving the socket, so a `server`/`client`
daemon you started is respected. With `autostart = false` the plugin **connects
only** and warns/errors if unreachable — for when you run your own daemon. The
`$HEPH_SOCKET` / `$HEPH_DB` env knobs (and `mise run dev`) isolate a dev Neovim
onto a separate daemon + DB so real data is never touched.
## Daemon RPC dependencies
Beyond the existing methods (tech-spec §6), the plugin relies on

View file

@ -23,16 +23,35 @@ ordinary Neovim buffers; saving routes through the daemon.
## Setup
Requires a running `hephd` (`hephd --mode local`) and Neovim ≥ 0.10.
Requires Neovim ≥ 0.10 and `hephd` on `PATH` (e.g. `cargo install`ed). By
default the plugin is **plug-and-play** — it starts and manages its own `hephd`:
```lua
require("heph").setup({}) -- spawns a local hephd against the default XDG paths
```
- **`autostart = true`** (default): if nothing is serving the socket, the plugin
spawns a local `hephd`, kills only what *it* spawned on exit, and **self-heals**
(respawns + reconnects if the daemon dies mid-session).
- **Running your own daemon** (a `server`/`client` architecture, or a launchd
service)? Set `autostart = false` and point at its socket — the plugin then
**connects only**, never spawning over your daemon, and warns if it's
unreachable. A daemon already serving the socket is always respected, even with
`autostart = true` (the plugin only spawns when nothing is there).
```lua
require("heph").setup({
-- socket = "/run/user/1000/heph/hephd.sock", -- defaults to hephd's path
-- keymaps = true, -- <leader>h* maps
-- autostart = false, -- spawn hephd if absent
-- socket = "...", -- default: $HEPH_SOCKET, else hephd's XDG path
-- db = "...", -- DB for an autostarted daemon ($HEPH_DB, else default)
-- autostart = true, -- false = connect-only (you run hephd yourself)
-- bin = "hephd", -- daemon binary for autostart
-- keymaps = true, -- <leader>h* maps
})
```
**Dev isolation:** set `$HEPH_SOCKET` / `$HEPH_DB` (or run `mise run dev`) so a
development Neovim drives a separate daemon + DB and never touches real data.
## Commands
| Command | Action |

View file

@ -3,19 +3,25 @@
local M = {}
M.defaults = {
--- Path to hephd's unix socket. `nil` → resolved to the daemon default.
--- Path to hephd's unix socket. `nil` → `$HEPH_SOCKET`, else the daemon default.
socket = nil,
--- Spawn a local hephd if the socket is not ready (off by default in v1).
autostart = false,
--- hephd binary for autostart.
--- DB path for an autostarted local daemon. `nil` → `$HEPH_DB`, else hephd's default.
db = nil,
--- Plug-and-play: spawn (and manage) a local hephd when none is serving
--- `socket`. Set `false` when you run your own daemon (server/client): the
--- plugin then connects only, and warns if nothing is reachable.
autostart = true,
--- hephd binary for autostart (on PATH for an installed heph).
bin = "hephd",
--- Set the default `<leader>h*` keymaps. `false` to opt out.
keymaps = true,
}
--- Resolve the socket path, mirroring hephd's `default_socket_path`:
--- `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to the temp dir.
--- Resolve the socket path: explicit opt, then `$HEPH_SOCKET`, then hephd's
--- default (`$XDG_RUNTIME_DIR/heph/hephd.sock`, temp-dir fallback). The env knob
--- lets a dev Neovim target a `mise run dev` daemon without touching real data.
function M.resolve_socket(opt)
opt = (opt and #opt > 0) and opt or vim.env.HEPH_SOCKET
if opt and #opt > 0 then
return opt
end
@ -24,6 +30,17 @@ function M.resolve_socket(opt)
return (base:gsub("/+$", "")) .. "/heph/hephd.sock"
end
--- Resolve the DB path for an autostarted daemon: explicit opt, then `$HEPH_DB`,
--- else nil (let hephd pick its default). Pairs with `resolve_socket` for dev
--- isolation.
function M.resolve_db(opt)
opt = (opt and #opt > 0) and opt or vim.env.HEPH_DB
if opt and #opt > 0 then
return opt
end
return nil
end
--- Apply the default keymaps (no-op when `opts.keymaps` is false).
function M.apply_keymaps(opts)
if not opts.keymaps then

View file

@ -6,6 +6,10 @@ local uv = vim.uv or vim.loop
local M = {}
-- The daemon THIS nvim spawned (nil if we connected to an existing one).
-- `{ handle, exited = { done }, socket, db, bin }`.
M._managed = nil
--- Spawn a `local`-mode hephd against `opts.db` listening on `opts.socket`.
--- `opts.bin` defaults to `hephd` on PATH. Returns `{ handle, pid }`.
function M.spawn(opts)
@ -55,4 +59,71 @@ function M.wait_ready(socket, timeout)
return true
end
--- Ensure a daemon is reachable at `opts.socket`. If one is already serving the
--- socket (any mode — local/server/client), connect to it and do NOT spawn. Else
--- if `opts.autostart`, spawn a local hephd we own (and manage its lifecycle).
--- Returns `reachable, spawned_by_us`.
function M.ensure(opts)
-- Already serving? A quick probe respects a daemon someone else started.
if M.wait_ready(opts.socket, opts.probe_ms or 400) then
return true, false
end
if not opts.autostart then
return false, false
end
local exited = { done = false }
local d = M.spawn({
bin = opts.bin,
socket = opts.socket,
db = opts.db,
on_exit = function()
exited.done = true
end,
})
local ok, reason = M.wait_ready(opts.socket, opts.ready_ms or 5000)
if not ok then
pcall(function()
if not d.handle:is_closing() then
d.handle:kill("sigterm")
end
end)
error("heph: spawned hephd but it never became ready: " .. tostring(reason))
end
M._managed = {
handle = d.handle,
exited = exited,
socket = opts.socket,
db = opts.db,
bin = opts.bin,
}
return true, true
end
--- True if this nvim currently owns a live spawned daemon.
function M.is_managed()
return M._managed ~= nil and not M._managed.exited.done
end
--- Stop the daemon this nvim spawned (no-op if we connected to an existing one).
function M.stop_spawned()
local m = M._managed
if not m then
return
end
M._managed = nil
if m.handle and not m.exited.done then
pcall(function()
m.handle:kill("sigterm")
end)
vim.wait(2000, function()
return m.exited.done
end, 20)
end
pcall(function()
if m.handle and not m.handle:is_closing() then
m.handle:close()
end
end)
end
return M

View file

@ -14,15 +14,44 @@ M.config = nil
function M.setup(opts)
local cfg = vim.tbl_deep_extend("force", config.defaults, opts or {})
cfg.socket = config.resolve_socket(cfg.socket)
cfg.db = config.resolve_db(cfg.db)
M.config = cfg
require("heph.rpc").setup(cfg.socket)
local rpc = require("heph.rpc")
local daemon = require("heph.daemon")
rpc.setup(cfg.socket)
if cfg.autostart then
local ok = require("heph.daemon").wait_ready(cfg.socket, 500)
-- Plug-and-play: bring up a managed local daemon if none is serving, and
-- self-heal a dropped connection on later calls.
local ok = pcall(daemon.ensure, {
socket = cfg.socket,
db = cfg.db,
bin = cfg.bin,
autostart = true,
})
if not ok then
require("heph.daemon").spawn({ bin = cfg.bin, socket = cfg.socket, db = nil })
require("heph.daemon").wait_ready(cfg.socket, 5000)
require("heph.util").notify(
"could not start hephd; will retry on first use",
vim.log.levels.WARN
)
end
rpc.set_respawn(function()
pcall(daemon.ensure, {
socket = cfg.socket,
db = cfg.db,
bin = cfg.bin,
autostart = true,
})
end)
else
-- Explicit architecture: connect only, never spawn over the user's daemon.
rpc.set_respawn(nil)
if not daemon.ensure({ socket = cfg.socket, autostart = false }) then
require("heph.util").notify(
"no hephd reachable at " .. cfg.socket .. " (autostart disabled)",
vim.log.levels.WARN
)
end
end

View file

@ -182,9 +182,34 @@ function M.session()
return M._default
end
--- Blocking call on the default session.
--- Register a hook that (re)ensures the daemon — called once to self-heal a
--- dropped connection before a single retry. `nil` disables self-heal (used when
--- autostart is off, so a connect-only setup fails loudly instead of respawning).
function M.set_respawn(fn)
M._respawn = fn
end
local function is_connection_error(msg)
msg = tostring(msg)
return msg:find("connect", 1, true) ~= nil
or msg:find("connection", 1, true) ~= nil
or msg:find("timeout", 1, true) ~= nil
end
--- Blocking call on the default session. If the call fails because the
--- connection is dead and a respawn hook is set, ensure the daemon and retry
--- once (the prior owner releases the DB lock on exit, so a respawn can claim it).
function M.call(method, params, opts)
return M.session():call(method, params, opts)
local ok, result = pcall(M.session().call, M.session(), method, params, opts)
if ok then
return result
end
if M._respawn and is_connection_error(result) then
pcall(M._respawn)
M.session():close() -- drop the dead connection so the retry reconnects
return M.session():call(method, params, opts)
end
error(result)
end
--- An isolated session for a socket — used by tests for independent assertions.

View file

@ -31,13 +31,16 @@ vim.api.nvim_create_autocmd("BufWriteCmd", {
end,
})
-- Release the socket cleanly on exit.
-- Release the socket and stop any daemon this nvim spawned, cleanly, on exit.
vim.api.nvim_create_autocmd("VimLeavePre", {
group = grp,
callback = function()
pcall(function()
require("heph.rpc").close()
end)
pcall(function()
require("heph.daemon").stop_spawned()
end)
end,
})

View file

@ -34,6 +34,17 @@ local function unique_dir()
return dir
end
--- A fresh temp dir + short socket/db paths, WITHOUT spawning a daemon (for
--- tests that drive the plugin's own autostart/lifecycle). `rm` removes it.
function M.tmp()
local dir = unique_dir()
return { dir = dir, sock = dir .. "/s", db = dir .. "/db", rm = function()
pcall(function()
vim.fn.delete(dir, "rf")
end)
end }
end
--- Start a fresh daemon and bind the plugin's rpc to it. Returns a `ctx` with:
--- `dir, sock, db, daemon, exited, q` (an isolated session for assertions).
function M.start()
@ -81,6 +92,7 @@ function M.stop(ctx)
pcall(function()
rpc.close()
end)
rpc.set_respawn(nil) -- don't let a managed-daemon spec leak self-heal here
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("^heph://") then
pcall(vim.api.nvim_buf_delete, b, { force = true })

View file

@ -0,0 +1,68 @@
-- The plugin-managed daemon lifecycle (tech-spec §8): plug-and-play autostart,
-- self-heal on a dropped connection, and connect-only when autostart is off.
local h = require("e2e.helpers")
describe("managed daemon", function()
local t
before_each(function()
t = h.tmp() -- temp paths; no daemon spawned by the harness
end)
after_each(function()
pcall(function()
require("heph.daemon").stop_spawned()
end)
pcall(function()
require("heph.rpc").close()
end)
require("heph.rpc").set_respawn(nil)
t.rm()
end)
it("autostart spawns a local daemon and connects plug-and-play", function()
require("heph").setup({
socket = t.sock,
db = t.db,
bin = h.hephd_bin(),
autostart = true,
keymaps = false,
})
assert.is_true(require("heph.daemon").is_managed())
-- A real call works because the plugin brought the daemon up itself.
assert.is_truthy(require("heph.rpc").call("health", {}))
end)
it("self-heals: respawns and reconnects when the daemon dies", function()
require("heph").setup({
socket = t.sock,
db = t.db,
bin = h.hephd_bin(),
autostart = true,
keymaps = false,
})
require("heph.rpc").call("health", {})
-- Kill the managed daemon out from under the plugin.
local m = require("heph.daemon")._managed
m.handle:kill("sigterm")
vim.wait(2000, function()
return m.exited.done
end, 20)
-- The next call transparently respawns the daemon and succeeds.
assert.is_truthy(require("heph.rpc").call("health", {}))
assert.is_true(require("heph.daemon").is_managed())
end)
it("connect-only (autostart=false) errors when no daemon is running", function()
require("heph").setup({
socket = t.sock,
autostart = false,
keymaps = false,
})
assert.is_false(require("heph.daemon").is_managed())
-- No daemon, no autostart, no self-heal → a call fails loudly.
local ok = pcall(require("heph.rpc").call, "health", {})
assert.is_false(ok, "expected connect-only to fail with no daemon running")
end)
end)