All notable changes to beachcomber will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
validate_providersrejected the documented legacy flat script-provider shape. A provider declared with a bare top-levelcommand(nobackendkey) legitimately carriesfieldsandinvalidationas provider-level sub-tables (ScriptProviderConfig::{fields, invalidation}) — config parsing itself accepts this shape — but validation walked those sub-tables as if they were per-source blocks and flagged both as unrecognized, so a daemon with such a provider refused to start.validate_providersnow carries the same skipparse_external_sourcesalready applied for the Phase 4 multi-source path, scoped to providers with nobackendkey so Phase 4backend = "…"validation is unchanged.- Config self-watch restart validation required
config.tomlto exist, even when a daemon's whole configuration lives inconf.d/*.tomldrop-ins. A conf.d-only change was detected but validation read the (absent) main config file and always failed, so the daemon never restarted into the change without a workaround emptyconfig.toml. A missing main config file is now treated as empty content, the same toleranceConfig::load()already gives daemon startup.
- One expression syntax:
{{ }}everywhere. A value expression written as exactly one{{ expr }}tag keeps the expression's natural type; literal text or more than one tag makes it a string; a bare expression (no tags) is still accepted and equivalent to the single-tag form (docs/canon/field_resolution.md§"Value resolution", invariant 14). libbeachcomber::eval— the module that classifies a value expression's form, discovers what it references, and evaluates it: the evaluation API (Form,classify,single_tag_expression,discover_refs,daemon_refs,fetch_daemon_data,evaluate,render_template— the workspace's one template render) plus the low-level tag scanner (Tag,TagKind,scan_tags) it's built on.bc_evalaccepts templates and single tags with the same typing rule as the CLI — and therefore every SDK'sevaldoes too.- Cross-binding conformance: an
evalop, with nine fixtures undertests/conformance/eval/run by the Rust reference runner and the five SDK runners with an FFI transport (Python, Node, Go, Ruby, C; Lua under LuaJIT); the PUC Lua subprocess tier reports them as skipped, never passed. Two of the nine pin the typing rule past the bool case every binding gets right by accident:single_tag_keeps_number({{ 1 + 1 }}is the number 2) andsingle_tag_trailing_whitespace_keeps_type(whitespace around a single tag is not literal text — invariant 14's scenario). - Path expressions accept
{{ }}too. A provider'spath = "…"is classified by the samelibbeachcomber::evalrules a value expression is, sopath = "{{ env.KUBECONFIG or '~/.kube/config' }}"and the barepath = "env.KUBECONFIG or '~/.kube/config'"are one expression, and a template form (path = "/srv/{{ env.SELECTOR }}") renders to its string. Canonfield_resolution.md§"env.*namespace" already saidenv.*was available in path expressions as{{ env.X }}; only the bare form actually compiled, and the tagged one silently collapsed the provider to the global slot. comb init --write-configwrites virtual field expressions in the{{ }}form.
- BREAKING (pre-1.0, CLI):
comb evalon plain text with no tags is now an expression, not literal text — a source with no{{ }}is two bare identifiers, and now exits 2 with a syntax error instead of printing itself back. Write{{ }}for literal text. comb eval: a single-tag result that is an object prints ascomb get -f textdoes (sortedkey=valuelines), not minijinja's map debug format.comb eval: a missing daemon ref at any depth ({{ p.f }},{{ p.f.sub }}) renders empty at exit 0 instead of erroring, and| default(…)now fires on a miss.comb evalnow follows virtual-field dependencies transitively. Withx.a = {{ b.y or cache.c.z }}andb.y = {{ cache.d.w }},comb eval '{{ x.a }}'fetchesd.w— 0.8.0 expanded only one level and rendered empty.-f fmt,watch -f fmtandstatus -frender a null value as empty where they used to print the literal wordnone; attribute access on a missing value renders empty instead of a template error; andcomb get -f fmt/comb watch -f fmterrors now readtemplate compile error: …/template render error: …(wereTemplate error: …).comb eval's error messages are now correctly kinded: a template syntax error now readstemplate compile error: …(was unconditionallytemplate render error: …, even for a compile failure), and a virtual-field failure readsp.f: …(wasError evaluating p.f: …).- BREAKING (pre-1.0, ABI):
bc_resolvereportsparse_error(wasserver_error) for a virtual field whose expression does not compile — a caller branching onkindfor that failure must be updated; it now fetches plainprovider.fieldrefs (not justcache.*) and follows virtual fields transitively; and it aborts on a transport failure instead of silently evaluating against an empty map (a daemon miss or unknown provider still stays falsy). bc_eval/bc_resolvefetch every daemon ref scoped to the suppliedcwd— path-scoped providers now resolve correctly, and a virtual fieldputglobally (no path) still resolves, through the new daemon global-slot fallback.- A resolved minijinja map or sequence is now a JSON object/array, not a debug-formatted string.
- BREAKING (pre-1.0,
libbeachcomberAPI):discover_expression_refsandVirtualFields::evaluate_expressionare now crate-private — useeval::discover_refsandeval::evaluate, which handle all three value-expression forms rather than only the bare one.RefderivesOrd, and every discovery path returns sorted output. comb watchdoes not take the daemon's global-slot fallback thatgetgained (below): its initial read and its subscription are keyed together, so a fallback read would emit the global value once and then never update.getandwatchat the same path can therefore differ for a virtual providerputglobally —getanswers,watchstays silent. Queued indocs/roadmap.md, and canonfield_resolution.md§"Path resolution" now states the carve-out.
geton a virtual (put-created) provider with a path now falls back to the global slot when the requested slot holds nothing. On 0.8.0 the daemon read only the path-keyed slot for a virtual provider, so any caller that supplied a path or connection context —comb get, whose default is the CWD — could not see a pathlessput, and the CLI masked it with a pathless retry. The daemon now falls back to the global slot, which is also what lets this release's cwd-scopedbc_eval/bc_resolvereach a globalput.- A deeply nested virtual-field chain now errors instead of exhausting the stack. Both recursions over the virtual-field graph — the daemon-ref closure and the evaluation itself — were cycle-guarded but not depth-guarded, and a cycle guard only stops a repeat: a chain of ~2000 distinct fields, each referencing the next, recursed once per link. Through the C ABI that was a SIGSEGV in the host process (an
overrides_jsonis caller-supplied, so any SDK user could trigger it), not an error anything could catch. Nesting deeper than 128 is nowvirtual field nesting too deep (limit 128). - Virtual-field errors now name the field (
p.f: …), including incomb get. On 0.8.0 a bad expression in a config virtual field surfaced as a bareexpression compile error: …with nothing pointing at which field to fix. scripts/conformance-all.shbuilds the Node SDK'sdist/up front, and its summary now names every runner that failed, could not run, or skipped fixtures — two distinct defects on 0.8.0. First, attribution: the exit code was already correct when a runner failed or could not run, but the summary named no runner, so a failure couldn't be pinned on a specific one — the Node runner's immediate exit on a clean checkout (dist/isn't checked in) scrolled past between other runners' output, invisible as a runner that never ran at all. Second, a runner that only skipped fixtures (e.g. Lua's subprocess tier skippingresolve/eval) exited 0 and still printed a greenconformance-all: all SDK runners passed.scripts/conformance-all.shnow checks that every runner accounts for every fixture. Its pass-count regex also captured a denominator for the Node and Go runners (Results: 28/43 passedread as 28 → 43), and nothing checked that a runner's pass + fail + skip added up to the number of fixtures on disk — so a runner that returns early from a fixture without counting it (the C runner's bail-out paths, Ruby'sreturn unless status_ok) or enumerates none at all and exits 0 on the empty set (Python) read as green. Both are fixed: the summary parse is per-runner, and a runner whose counts do not add up is named and fails the gate.cache.P/cache.P.Fref binding order was hash-order dependent — where a whole-object ref and a same-provider field ref disagreed about a key, which one won could vary run to run. The whole object now always wins, deterministically.
The client-unification cycle: the shared Rust client core is exposed as a C ABI, all six SDKs are rebuilt as thin bindings over it, and the singleton hardening work (orphan reaping, poll-guaranteed self-supervision, watch and reaper self-tests) ships alongside.
libbeachcomberC ABI (libbeachcomber.{so,dylib}). The shared Rust client (protocol, JSON mapping, client-side field resolution) is now exposed as a C-ABI cdylib built by the newlibbeachcomber-fficrate and shipped besidecombin every release artifact (tarball, deb, rpm, Homebrew), with a cbindgen-generatedbeachcomber.hkept fresh by a CI check. 22bc_*entry points cover lifecycle, all ops, client-side resolution (bc_resolve/bc_evalwith caller-supplied cwd/env), sessions (busy-guarded), and watch (cancellable, five machine-readable outcomes). Every call returns a JSON envelope; panics are caught at the boundary.- All six SDKs are now bindings over the shared library. Python (
ctypes), Ruby (fiddle), Go (purego, vendored), Lua (LuaJITffi; PUC Lua via a declared subprocess tier), Node (koffioptional peer dependency with a subprocess tier), and C (collapsed onto the generated header). Bindings discover the library via$BEACHCOMBER_LIB→../libbeside the resolvedcomb→ the platform linker path, check all symbols at load, expose the daemon's errorkindmachine-readably, and report their transport where more than one exists. The SDKs gain client-side resolution for the first time. - Version-skew detection. The shared client compares its build identity against the daemon's on first connection per client/session; a mismatch is surfaced to callers and named (both versions) in subsequent errors, without failing ops.
- Cross-binding conformance: resolution + typed-mapping fixture groups. The fixture format gains a
resolveop withvirtual/env/cwdinputs, plus mapping fixtures pinning the ambiguous JSON conversions (64-bit ints, whole-number floats, null, empty-vs-absent, depth boundary, embedded NUL). All six SDK conformance runners now run in CI against a builtcomb, enforce every documented expectation kind, fail loudly on unknown ones, and skip-not-pass what they don't implement.scripts/conformance-all.shruns the whole gate locally. - Restart on config change. The daemon's self-supervision extends to its configuration: the resolved
config.tomland anyconf.d/*.tomldrop-ins are watched (fs-event fast path, mtime-poll backstop), and a change restarts the daemon gracefully — parse-gated, so an edit that doesn't parse is logged and ignored, never restarted into. There is no in-place reload; restart is the application mechanism. - Config composition via
conf.d/. Any*.tomlin<config-dir>/conf.d/is deep-merged ontoconfig.tomlin lexical filename order (tables merge per key; scalars and arrays are last-wins), before typed parsing, so a drop-in can override a single provider key without clobbering the block. A broken drop-in is warned and skipped at startup; the restart gate requires the whole composed set to parse. comb status: POLL column, TTL total budget,~paths, and a--helplegend. A new POLL column (before TTL) shows seconds to the next poll — or the retry countdown while a source is failing; the TTL cell adds a humanized total time-to-eviction alongsideP×K; failing rows render⚠ #N;$HOME-prefixed paths compact to~in the human preset (machine formats keep real paths); andcomb status --helpdocuments every column and glyph. Backed by an additivenext_poll_in_secsstatus-row field.- Orphan reaping. The canonical daemon (bound socket equals its own env-free resolution) reaps orphaned
comb daemonprocesses at startup and hourly: uid-owned daemons reparented to PID 1, on sockets nothing resolves, carrying neither--exit-with-parentnor the new--no-reapflag, and older than 60s. Live test daemons, attended foreground runs, and flagged supervised daemons are exempt. Closes the leak class where daemons on session-scoped or deleted-worktree sockets accumulated for weeks (docs/canon/singleton.md§"Orphan reaping"). comb daemon --no-reap— marks a deliberate, supervised, non-canonical daemon exempt from reaping.- Poll-guaranteed self-supervision. The daemon's binary self-watch gains a 5s mtime poll alongside the fs-event watch. An fs-event stream can be created without error and then deliver nothing (sandboxed CI hosts, a degraded
fseventsd); with an event-only watch such daemons outlived every rebuild indefinitely. The poll bounds staleness at one interval regardless of backend health. - Watch self-test. At startup the daemon probes whether kernel fs events actually deliver (2s timeout, concurrent with the scheduler loop — no startup latency). If not, provider file-watching falls back to a polling backend (1s scan), and the degradation is surfaced via
comb check daemon(WARN verdict),comb status(stderr warning), and a newwatch_backendfield in the daemon introspect payload. - Reaper visibility self-test. The canonical daemon probes whether its process enumeration plausibly spans the system (PID 1 present in the raw listing) at reaper arming and on every sweep. A confined view (e.g. a daemon auto-spawned from a sandboxed client shell inherits the sandbox's process-visibility limits) is surfaced via
comb check daemon(WARN verdict),comb status(stderr warning), and a newreaperobject in the daemon introspect payload (armed,visibility,sweeps,reaped,kill_denied); sweeps continue reaping whatever is visible. Reap kills denied by the OS (EPERM) are counted and surfaced the same way (docs/canon/singleton.md§"Reaper visibility self-test", invariant 13). - Per-sweep reap summary log. Every reap sweep logs a debug-level summary — rows enumerated, candidates, exemption tallies by rule, reaped pids, denied/failed kills — so a sweep that found nothing eligible is distinguishable from one that could not see anything.
refresh/put_null/set_contextswallowed daemon rejections in the CLI client path, returning success onok:false.- Conformance runners for Ruby, Node, Lua, and C could not run at all (wrong daemon subcommand, ESM/CommonJS mismatch,
/tmpchmod EPERM against the singleton lock, socket-file readiness probe); the C runner also read fixtures from the wrong checkout in git worktrees and truncated its own test payloads (whole-number floats, embedded NULs). The Go and C runners treatedage_ms=0as absent. - The Lua SDK misclassified empty JSON objects as arrays after decode.
- Failed-bind daemon lingered alive and ignored SIGTERM. A daemon whose socket bind failed (e.g. path over the 104-byte
SUN_LENlimit) logged the error but then blocked forever awaiting a scheduler that was never told to shut down, with no remaining observer for the signal handler's cancel token — only SIGKILL removed it. The server-error path now shuts the scheduler down and the process exits.ensure_daemonandlibbeachcomberauto-start additionally reject over-long socket paths up front with the real cause instead of forking a doomed daemon and reporting a spawn timeout. - Reap fratricide under
$BEACHCOMBER_SOCKET. The reaper role is now decided against the env-free resolution (config override →/tmp/beachcomber-<uid>/sock), ignoring$BEACHCOMBER_SOCKET. Previously a daemon auto-spawned under the env override resolved itself as canonical — as did the default-path daemon — and each classified the other as an unflagged orphan: mutual reaping on alternating sweeps. Auto-spawn (ensure_daemonin the CLI,auto_startinlibbeachcomber) now also appends--no-reapwhen the spawn path was resolved from$BEACHCOMBER_SOCKET, so deliberate override daemons are spared without user action (docs/canon/singleton.md§"Who reaps", invariants 10/11). Corollary: an environment where every client uses an override socket runs no reaper. - Reaped orphans left corpse socket files. After a reaped orphan's confirmed death its socket and sibling pid files (
pid,daemon.pid) are now removed. A corpse socket re-latched old existence-probing clients (libbeachcomber≤0.1.1) into respawning a daemon on the dead path — the mechanism that kept one orphan path continuously resurrected for four months. Cleanup probes first and never unlinks a socket that is serving again (docs/canon/singleton.md§"Corpse cleanup", invariant 14). Sweep summaries and theSweepReportgain acorpses_unlinkedcount. - Sandbox-blind orphan reaping (macOS). The reaper's pid list now comes from
sysctl KERN_PROC_ALLinstead of libproc'sproc_listallpids, which seatbelt sandbox profiles silently filter to the session's own processes (observed: 51 of 737 pids). A canonical daemon that happened to be respawned from a sandboxed client shell could never see — and therefore never reaped — orphan daemons from other sessions; the 2026-07-16 investigation found one such orphan surviving 19 hours across ~19 sweeps with clean logs. - Orphan reaping was blind on Linux. The
/procstat parse read the state field as the ppid, so every row failed to parse and the uid-owned process listing was always empty — the reaper enumerated nothing, and bothboundaries_proc_tablesmoke tests failed on Linux CI. The parse now indexes the correct field. - The published Lua rock could not load its own submodules. The rockspec installed modules under a
libbeachcomber.*namespace while the code requiresbeachcomber.*; the module keys now match the code (the LuaRocks package name is unchanged). The 0.7.0 rock has the same defect. - Watch registration stalled the scheduler for seconds. Kernel FSEvents registration (1–3s per call under load) ran inline on the scheduler task, once per source even for a shared path — a whole-provider get could block every pending response for ~6s. Registration is now deduplicated per path and runs off the scheduler task.
comb get <virtual>.<field>missed put-created entries and failed silently. The CLI defaulted the request path to the process CWD, so global virtual entries missed while the raw socket answered; misses from that ambient default now retry pathlessly, and every error path writes its cause to stderr instead of a bare exit 2.- The Lua SDK dropped a JSON
nullnested in an object. Decode now produces an exportedbeachcomber.nullsentinel at any depth (encode emitsnullfor it); the wire's miss encoding still reads as a miss. The conformance fixture pinning this is now required to pass. - The Go and Ruby SDKs never autostarted the daemon. Go hardcoded
autostart: falsewith no override; Ruby defaulted it off. Both now defer to the shared library's default (on), with explicit opt-out; autostart applies only to the auto-discovered socket path, never an explicit per-client one.
- BREAKING (wire): the server-rendered
text/shsub-protocol is removed.getandwatchno longer accept aformatfield; the daemon always answers NDJSON, and text/sh rendering happens client-side. The old sub-protocol could not represent values beginningerror:(indistinguishable from failures) or containing blank lines (frame desync). A stale client sendingformatgets a normal JSON response.comb get/comb watch -f text|share unchanged for users — rendering moved, output didn't. - The hand-written C SDK protocol implementation is deleted (
beachcomber.c,json.c) — the C SDK is now the generated ABI header plus build glue. beachcomber-client/is renamedlibbeachcomber/, matching its package name.- BREAKING:
$XDG_RUNTIME_DIRno longer participates in socket path resolution. Canonical resolution is now config override →$BEACHCOMBER_SOCKET→/tmp/beachcomber-<uid>/sock, in the daemon and all clients (CLI,libbeachcomber, and the C/Go/Lua/Node/Python/Ruby SDKs). Session-scoped environments (sandboxes, containers, per-sessionXDG_RUNTIME_DIRshims) previously resolved distinct socket paths and auto-spawned one daemon per session; singleton enforcement is per-socket-path, so the default must be a stable per-user path (seedocs/canon/singleton.md). Environments that want a different placement (e.g./run/user/<uid>on systemd) set$BEACHCOMBER_SOCKETor the config override. Migration: a daemon running on an old XDG-derived socket is unreachable by upgraded clients; the first client invocation spawns a daemon at the stable path, and the old daemon exits on binary replacement (self-supervision) or can be killed manually.
- The Lua SDK cannot represent a JSON
nullnested inside an object (Luanilsemantics); tracked in the roadmap with a fix shape, reported as a named known-defect skip by its conformance runner. - Node without
koffiand PUC Lua run a reduced subprocess tier (documented per binding;transport()reports which tier is active).
The post-0.6.1 cycle: an env-cascade overhaul plus a broad provider-correctness sweep. Each environment-aware provider is split into a daemon-side data provider (pure on-disk/state enumeration, no environment reads) and a client-side consumer namespace (virtual fields + path/value expressions that fold in the querying shell's environment) — fixing the long-standing "frozen daemon environment" class of bugs where the daemon reported its own launch-time env instead of the caller's. Many changes are breaking; pre-1.0, they ship under a minor bump.
- New canon doc
docs/canon/field_resolution.md. Defines the client-side field-resolution model: field-type taxonomy (native/external/literal/virtual/env), thecache.*namespace, path expressions, and value expressions. This is now the authoritative spec for how consumers address and resolve provider fields. cache.*value-expression model. Expressions reference raw cached values ascache.<provider>.<field>(the stored value, bypassing any field expression) vs bare<provider>.<field>(the resolved field, with expression applied). A cached field's default expression is the identity; no rename is needed when a virtual field overrides a same-named cached value.- Path expressions. A provider's cache-key path is computed client-side by an expression over
cwdandenv.*([providers.<name>] path = "<expr>"). Empty/falsy ⇒ global slot. Built-in defaults compiled into the CLI; user config overrides per provider.
aws→aws_profilesdata provider. Theawsdaemon provider is now namedaws_profiles. It returns one field per profile (each anObject{region}) parsed from~/.aws/config. Theawsconsumer namespace is now composed of virtual fields that index the data provider by the active profile selector:aws.region = env.AWS_REGION or env.AWS_DEFAULT_REGION or cache.aws_profiles[env.AWS_PROFILE or env.AWS_VAULT or env.AWS_DEFAULT_PROFILE or "default"].region.comb get awsreturns computed fields;comb get aws_profilesreturns the raw profile enumeration.gcloud→gcloud_configsdata provider. Thegclouddaemon provider is now namedgcloud_configs. It returns one field per gcloud configuration (each anObject{project, account}) plus anactive_configfield, parsed from~/.config/gcloud/. Thegcloudconsumer namespace is virtual fields indexing the data provider by active config.comb get gcloudreturns computed fields;comb get gcloud_configsreturns the raw config enumeration.
kubecontextis now PathScoped. The provider reads the kubeconfig file named by the path expressionenv.KUBECONFIG or '~/.kube/config'. A:-joined list is merged (later file wins). The daemon watches each resolved file viaSource::watched_files; the daemon never reads$KUBECONFIGitself — the CLI resolves it to a path and sends that as the cache coordinate.- New
talosprovider. Same shape askubecontext: reads the Talos config file named byenv.TALOSCONFIG or '~/.talos/config'as a PathScoped source. Fields:context(string),endpoints(array),nodes(array). Watches the resolved file viaSource::watched_files. Source::watched_files. New trait method (default: empty) returning the explicit file paths this source needs to watch, given the resolved path. Used by Tier B env-selected-file providers to register per-file watches without relying on pattern-based watch registration.
- Client-side
env.*and virtual-field evaluation.comb get/comb evalresolveenv.*references and virtual expressions in the client; a query that references onlyenv.*skips the daemon round-trip entirely. Includes a typed evaluator with undeclared-variable discovery and abasenamefilter on the shared minijinja environment. comb init --write-configmaterialises the built-in virtual-field defaults intoconfig.toml(idempotent; safe to re-run).
--exit-with-parentflag: the daemon self-exits when the process that spawned it dies (e.g. an integration that owns the daemon's lifetime).
- BREAKING (pre-1.0):
awsdaemon provider renamed toaws_profiles.aws.config_regionfield removed.comb get aws_profilesreturns the raw profile dump;comb get awsevaluates the consumer virtual namespace. Scripts queryingaws.config_regionmust be updated. - BREAKING (pre-1.0):
gclouddaemon provider renamed togcloud_configs.gcloud.config_projectfield removed.comb get gcloud_configsreturns the raw config dump;comb get gcloudevaluates the consumer virtual namespace. Scripts queryinggcloud.config_projectmust be updated. - BREAKING (pre-1.0):
terraform.path_workspacerenamed back toterraform.workspace. The P1 rename was forced by a virtual-field self-reference cycle; thecache.*model eliminates the cycle, so the field returns to its natural name. - BREAKING (pre-1.0):
python.versionrenamed topython.venv_version; newpython.local_venv_namefield added. The daemon no longer reads$VIRTUAL_ENV. Scripts queryingpython.versionmust update. - BREAKING (pre-1.0): daemon providers no longer read environment variables. Under the env-cascade model the daemon enumerates on-disk state only; per-shell environment is applied client-side. Removed daemon env reads:
aws($AWS_PROFILE/$AWS_REGION/…),gcloud($CLOUDSDK_ACTIVE_CONFIG_NAME),terraform($TF_WORKSPACE),python($VIRTUAL_ENV). The user-facingaws.region,gcloud.project, andterraform.workspacevalues are now computed client-side fromenv.*plus the cached data provider, so they finally track the querying shell rather than the daemon's launch environment. - BREAKING (pre-1.0):
condaandopare now client-side virtual fields, not daemon providers. Both were structurally env-frozen as daemon providers (they read the daemon's process environment, which never reflects the querying shell). They are reborn as virtual fields evaluated in the client:conda.envresolves$CONDA_DEFAULT_ENV, andop.signed_inreflects whether$OP_SERVICE_ACCOUNT_TOKENis set — so both finally track the querying shell. The daemon no longer runs the conda/op providers (noop whoamisubprocess);op's previousaccountfield and live-session validity check are gone.
- git is now correct in worktrees and submodules. A shared
resolve_git_dirfollows.git-as-a-file (gitdir:pointers), sogit.state(rebase/merge/cherry-pick progress) andgit.stashreflect reality in linked worktrees and submodules instead of always reporting "clean"/0. The git executor no longer inherits$GIT_DIR/$GIT_COMMON_DIR/$GIT_WORK_TREE. Refs moved to a read-always mechanism with a splitGitHeadsource. - gcloud read a path that doesn't exist. It read
~/.config/gcloud/properties(absent on a standard install) and returned empty for essentially everyone; it now follows theactive_configtwo-level indirection to the realconfigurations/config_<name>/properties. - kubecontext merges a multi-file
$KUBECONFIG. A:-joined list is merged (later file wins) instead of reading only the first path, and context-name matching is anchored (no moreprodmatchingprod-east). user.nameis thread-safe. Replacedgetpwuidwithgetpwuid_r, removing undefined behaviour underspawn_blocking.- direnv reads the allow database directly instead of shelling out to
direnv status, sodirenv allowfrom any shell is reflected. - sudo omits the
activefield when the (root-only) timestamp file is unreadable, instead of silently reportingfalse. - asdf falls back to the global
~/.tool-versionsand emits a flat field schema consistent with the other providers. - network: removed the hardcoded
en0interface assumption, added an IPv6 field, and now detects Tailscale (tailscale0) as a VPN. - A wedged daemon no longer blackholes the socket. On startup, when another daemon holds the lock with the same build, the new process now probes the canonical socket before exiting: it exits silently only if that daemon is actually serving. A same-build owner that acquired the lock but never bound the socket (or whose socket was deleted) is superseded after a short grace, so a healthy daemon rebinds instead of clients hitting a permanently dead socket. Startup orphan-reaping (which could kill peer daemons on other socket paths) was removed in favour of this targeted probe.
cargo xtask set-version X.Y.Zrewrites all 14 version touchpoints (both Cargo manifests, both lockfiles, the 5 SDK manifests, 3 AUR PKGBUILDs, the nix flake, therelease.ymlrockspec reference, the 8 README download URLs, and the Lua rockspec including its versioned filename) in one command, with count-guarded digit-boundary replacement that aborts on any drift.- Releases fire on merge to
main.release.ymlnow triggers on push tomain, derives the version fromCargo.toml, tags the merge commit, and publishes — no tag-push trigger and no PAT/App token. The manualgit tagstep is gone from the release process.
- Env-cascade (env-selected file resolution, Tier B providers) adds no wire-protocol change. The existing
pathfield on theGetrequest carries the client-resolved file path as the cache coordinate. Seedocs/protocol-spec.md.
- Socket-path resolution now agrees across the daemon, CLI, and every client. All client SDKs (Rust, Go, Python, Ruby, Node, Lua, C) resolve the daemon socket as
$BEACHCOMBER_SOCKET→$XDG_RUNTIME_DIR/beachcomber/sock→/tmp/beachcomber-<uid>/sock, mirroring the daemon's bind path (minus the daemon-only config-file step). Previously the clients ignoredBEACHCOMBER_SOCKETand consulted$TMPDIR(a per-session/var/folders/...path on macOS), so on macOS withXDG_RUNTIME_DIRunset a client could look in$TMPDIRwhile the daemon bound/tmp/beachcomber-<uid>/sock— and they would fail to find each other. There is no longer an existence probe on theXDG_RUNTIME_DIRstep: clients resolve to the single path the daemon binds and rely on connect-retry; non-standard layouts useBEACHCOMBER_SOCKET. - Node SDK published type artifact (
dist/types.d.ts) regenerated so theIntrospectSubjectunion reflects thebackoff→lifecyclerename from 0.6.0. The npm publish step rebuildsdist/fromsrc/viaprepublishOnly, so the compiled artifact can no longer drift from source.
A large release. The daemon's internal model was rebuilt around a Provider→Source→Field architecture, the wire protocol and CLI were overhauled, and typed client SDKs reached parity across all six languages. Many changes are breaking; pre-1.0, these ship under a minor bump.
- Provider→Source→Field model. A
Provideris now a namespace declaring 1+Sourceobjects; eachSourcehas its ownInvalidationStrategy,KeepAlive,FailbackConfig,SourceScope, and field set. - Lifecycle keying moved from
(provider, path)to(provider, path, source)— each source instance has an independent Active/Decay/Evicted lifecycle. - Cache entries at
(provider, path)hold per-source sub-entries; field ownership is disjoint across sources, so flatten reads are unambiguous. - Per-source eviction: an evicting source removes only its own contribution; the
(provider, path)entry is dropped only when its last source evicts. InvalidationStrategy::Watchgainsabs_pathsfor absolute-path filesystem watches; global sources can watch$XDG_CONFIG_HOMEand other absolute roots directly.expand_abs_path()expands~,$HOME, and the XDG vars inSource::metadata().- Pure-watch global sources (
Watch + Global + KeepAlive::Never) execute once on first demand, re-execute only on fs events, and never decay. ProviderRegistrybuilds afield → sourcereverse map at registration;comb get git.branchroutes to the owninggit.refssource without a linear scan.
- New
src/query.rsrequest planner (QueryPlan/SourceDemand);getandwatchbuild one plan so they share identical key semantics. - A field query warms only its owning source —
comb get git.branchno longer warms siblinggitsources (diff,status). Whole-provider queries still warm all applicable sources. - New addressing forms
provider.sourceandprovider.source.fieldaccepted byget,refresh, andwatch;watchnow resolves source-qualified keys identically toget.
Request::Introspectwire op with subjectsdaemon,providers,config,cache,lifecycle,watches,timers,demand,procs.comb checkrewired onto it (top-level aggregation; each subject a subcommand).- Typed client surface across
beachcomber-clientand all six SDKs (Python, Go, Node, Ruby, C, Lua):status()returns typed cache rows directly;RowKinddiscriminator andFailureSnapshotexposed onCacheRow. - Provider conformance harness wired into the test suite; protocol spec / hello version negotiation documented.
- Client connect retry in the CLI and all six SDKs +
beachcomber-client: transientECONNREFUSED/ENOENTretried 3× with exponential backoff (250ms / 500ms / 1s), covering the daemon-restart window.
- Singleton enforcement via an exclusive
flockon a PID file: a same-version second daemon exits; a different-version one takes over the old (SIGTERM → SIGKILL). - Automatic restart on binary change (the daemon fs-watches its own executable) and orphan reaping of stale
comb daemonprocesses sharing its binary path. comb --versionreportsBEACHCOMBER_VERSION, including git sha for dev/dirty builds.BEACHCOMBER_SOCKETenv var overrides the socket path.
comb getvariadic keys,--force(immediate recompute) and--wait(block for a fresh value);comb put --nullclears a virtual entry.comb statustabular output (one row per warm entry) with aTTL/lifecycle column (★/3–0countdown, poll interval, keep-alive, fsevents-reinstate), a failure⚠indicator, and flags--filter,--sort(incl.lifecycle),--no-trunc,--max-width(int orauto),--color(auto/always/never),--ascii, and-f/--formatpresets (human/tsv/json/csv/table/sh).- minijinja templating in
comb evaland the.fformat suffix (filters:truncate,default,upper,lower,length); script provideroutput = "text".
- BREAKING (pre-1.0): daemon socket path no longer depends on
$TMPDIR— resolution is config override →$XDG_RUNTIME_DIR/beachcomber/sock→/tmp/beachcomber-<uid>/sock. On macOS all shells now share one daemon. - BREAKING (pre-1.0):
introspectsubjectbackoff→lifecycle, with state valuesActive/Decay1–Decay4replacingGrace/SlowPoll/Frozen/Evict; all SDK constants renamed (no legacy alias). - BREAKING (pre-1.0, all SDKs):
status_rows()removed;status()returns typed rows. The C SDKcomb_status_rowsis redesigned to heap-allocating (pair withcomb_free_cache_rows());comb_cache_row_tfields are now ownedchar*. - BREAKING (pre-1.0):
comb statusdefaults to thehumanpreset regardless of TTY (use-f tsv/-f jsonin scripts);--no-colorremoved in favour of--color=never|auto|always. comb statusdefault sort is(provider, path, field);--max-widthdefault raised 40 → 120; TSV/CSV gain one column perCacheRowfield.- Provider scope declared per field via
FieldSchema::scope;Provider::executereturnsVec<(Option<String>, ProviderResult)>(wire protocol unchanged).
- Cache decay works end-to-end (Active → Decay1–4 → Evicted with exponential backoff); previously the decay stages were unreachable and entries never evicted.
- Global providers no longer create ghost cache entries when queried with an explicit path;
mise.globalis no longer duplicated per project directory. - Library (FFI) provider dispatch is now strict UTF-8 (was silently lossy).
- Lua SDK:
Client:get_with_flagsreturnsnil, erroron server-side failures, matchingClient:get. :agemetadata suffix returns a JSON number, not a string. Watcher registrations are GC'd on cache eviction and on a periodic tick.
- BREAKING (pre-1.0): wire ops
poke→refresh,store→put;Request::Listremoved;statusreshaped to a cache-row array (old health fields viaintrospect daemon). - BREAKING (pre-1.0): CLI
comb refresh/r,comb fetch/f,comb list/lremoved; single-brace{field}template syntax removed (use{{ field }}). - BREAKING (pre-1.0):
InvalidationStrategy::Once,Watch::fallback_poll_secs, andPoll::floor_secsremoved; TOML moved to per-source[providers.<name>.<source>]blocks withpoll_*/fsevent_*/failback_*prefixes (old flat source-knob keys are rejected with a clear error). [lifecycle] cache_lifespan(now derived aspoll_interval × poll_live_count),poll_idle_interval,poll_live_interval(→poll_interval), andeviction_timeout_secsconfig keys removed.
- Test-suite-health initiative: dependency-injection seams for process / git / HTTP / library boundaries, golden CLI tests, mock-clock TTL tests, a shared git fixture, the provider conformance harness, and a coverage gate raised to 70%.
- Git provider: set defensive environment variables (
GIT_OPTIONAL_LOCKS=0,GIT_TERMINAL_PROMPT=0,LC_ALL=C) on all git subprocesses to prevent lock contention, interactive credential prompts, and locale-dependent output parsing - Sudo provider: gate
check_timestamp_diron macOS only — the Linux path does not use/var/db/sudo - CLI: collapse nested
if letchains in the Linux/procscanner (clippycollapsible_ifunder Rust 1.88+) - Release workflow: attach daemon
.deb/.rpmpackages to the GitHub Release (previously only the C SDK packages were attached)
- Git provider:
commit_summaryfield — first line of HEAD commit message, extracted from the existinggit logcall (no additional subprocess) - Git provider:
push_aheadandpush_behindfields — commits ahead/behind the push remote (distinct from tracking remoteahead/behind) - Synchronous cache miss —
comb geton a cold cache executes the provider inline viaspawn_blockingand returns data immediately instead of returning empty - Single-letter command aliases:
daemon,get,put,refresh,watch,status,list,kill - Format suffix syntax — append
.p(plain text),.j(json),.s(sh),.c/.C(csv),.t/.T(tsv),.f(template) to a command for quick output format selection without-fflag - New output format:
sh—key=valuepairs, sourceable in shell scripts (replaces oldtextbehavior for objects) - New output formats:
csv/tsv(values only),CSV/TSV(with header row) for structured data export - New output format:
fmt—{field_name}template interpolation per field, e.g.comb g.f '{branch} ({dirty})' git . comb kill(aliask) — stop the running daemon via SIGTERM; socket-activates fresh on the next query. Queries the daemon's pid via the status socket so it works even when the pid file is stalestatusresponse now includespidandversionfieldscomb eval(aliase) — template interpolation across providers:comb eval "branch: {git.branch} load: {load.one}" .comb fetch(aliasf) — batch get, query multiple keys in a single connection with format-aware output- Field metadata access — colon delimiter on key (
git.branch:age,git.branch:stale,git.branch:source) returns cache metadata instead of data sudoprovider — detect active sudo timestamp. Global, poll 30s. Fields:active(bool)opprovider — detect active 1Password CLI session. Global, poll 60s. Fields:signed_in(bool),account(string)comb init(aliasi) — auto-detect installed tools (p10k, starship, tmux, neovim, polybar, waybar, sketchybar, oh-my-zsh) and print integration snippetscomb check(aliasc) — health check with subcommands:all,daemon,config,providers,cache,procscomb check procs— process exec tracing via eslogger (macOS) or /proc scanning (Linux) to measure beachcomber's potential impact- Shell integration scripts:
scripts/chpwd.sh(directory change hook for zsh/bash/fish),scripts/polyfill.sh(POSIX fallback function) - Help screen branding with NavistAu authorship, beachcomber.sh URL, MIT license, format suffix usage hint
- CLI:
comb pokerenamed tocomb refresh - CLI:
comb storerenamed tocomb put - Breaking:
textoutput format for objects now returns raw values only (no key= prefix). Useshformat for the oldkey=valuebehavior - Breaking: Default output format is now
text(wasjson).comb g git.branch .prints just the branch name. Pass-f jsonor use the.jsuffix for the old JSON envelope - Breaking: Format suffixes remapped for better ergonomics:
.t→.p(plain text, now the default —.pis rarely needed),.sh→.s(shell),.s→.t(tsv),.S→.T(tsv+header),.fmt→.f(template)..j,.c, and.Care unchanged - The daemon now exits cleanly on SIGTERM as well as SIGINT (SIGTERM is what
comb killsends)
- Shared library provider backend via
libloading— load.so/.dylibplugins as providers with a C ABI contract (beachcomber_provider_metadata,beachcomber_provider_execute,beachcomber_provider_free). Configure withtype = "library"andlibrary_pathin[providers.<name>]. - Scheduler watchdog — monitors the scheduler heartbeat and triggers a clean daemon shutdown on stall detection. Configure with
watchdog_intervalandwatchdog_thresholdin[daemon]. Disabled by default. aarch64-unknown-linux-gnupre-built binary,.deb, and.rpmpackages in release workflow via cross-rs (pinned to 0.2.5)
- Linux C SDK packages:
libbeachcomber-dev(deb),libbeachcomber-devel(rpm),libbeachcomber(AUR) - pkg-config support for the C SDK (
libbeachcomber.pc) - C SDK release workflow: builds deb/rpm, smoke-tests in containers, attaches to GitHub Release
- Virtual providers via
comb store— external processes can write data into the cache, creating data-only providers with no execute function - Namespace hierarchy for providers: builtin > script > virtual (higher priority providers cannot be shadowed)
comb watch <key> [path]— server-push streaming over long-lived connections, NDJSON line emitted on each cache update- WatcherRegistry with broadcast channels for field-level change notification
storeandwatchprotocol operationsstoreandread_watch_linemethods on ClientSession- Store and watch integration tests
- Core daemon with Unix socket server and socket activation
- Concurrent cache with 157ns read latency
- Scheduler with filesystem watching, poll timers, and poke triggers
- Provider execution timeouts (configurable, default 10s)
- Execution deduplication (prevents thundering herd on filesystem bursts)
- Provider failure backoff (exponential delay after 3 consecutive failures)
- Subscription manager with multi-tenant cadence resolution
- Backoff/drain lifecycle (Grace -> SlowPoll -> Frozen -> Evict)
- Graceful shutdown via CancellationToken + SIGINT handling
- Daemon auto-shutdown after configurable idle timeout
- Connection context for implicit path resolution
- Staleness computation in cache responses
- CLI:
comb daemon | get | poke | subscribe | list | status - 16 built-in providers: hostname, user, git, battery, load, uptime, network, kubecontext, aws, gcloud, terraform, direnv, python, conda, mise, asdf
- Script provider backend for custom providers via config.toml
- Provider enabled/disabled flag in config
- JSON and text output formats
- ClientSession for persistent connections (15µs/query)
- Comprehensive benchmark suite (cache, protocol, providers, socket, throughput)
- Linux support for battery provider (sysfs + UPower), network provider (nmcli/iw SSID, tun/wg VPN detection), and uptime provider (/proc/uptime)
- Pre-built binaries for aarch64-unknown-linux-gnu and aarch64-unknown-linux-musl
- Debian/Ubuntu (.deb) and Fedora/RHEL (.rpm) packages published as GitHub Release assets
- AUR packages:
beachcomber(source) andbeachcomber-bin(prebuilt) - Nix flake for building from source
- Linux CI job (cargo check, test, clippy, fmt on ubuntu-latest)
- Network provider refactored into platform submodules (network/mod.rs, network/macos.rs, network/linux.rs)
- npm and PyPI binary installers now support Linux arm64