Reference for the repo-local CodeLens launchd daemons — deploy, redeploy, and
codesigning recovery. Extracted from CLAUDE.md to keep the per-turn context lean.
Dev daemon vs consumption daemon (blast-radius isolation)
Each runtime is a single writer. Reviewer/planner and builder/refactor
sessions share one HTTP endpoint and select their profile/RBAC per session; a
second process for the same project is rejected by the project-writer lease.
The machine's consumption endpoint is :7838, while this repo keeps a separate
working-tree endpoint on :7736 so rebuilding the dev binary does not drop
other projects' sessions:
| Role | Label | Port | Binary | Who uses it |
|---|---|---|---|---|
| Consumption | dev.codelens.mcp-mutation |
7838 | codelens-mcp-http |
global Codex/Claude/Cursor host attach overrides |
| Dev | dev.codelens.mcp-dev-mutation |
7736 | codelens-mcp-http-dev |
this repo's .mcp.json — rebuild freely |
The two can only coexist if the consumption daemon does not use this repo
as its default project: the default project's writer lease is held for the
daemon's lifetime, so a consumption daemon started on the repo keeps the dev
daemon in a project_writer_busy respawn loop. Install the consumption daemon
with an empty default project (the working directory, telemetry and logs stay
in the repo); unbound sessions then see an empty project instead of this repo:
bash scripts/install-http-daemons-launchd.sh . --default-project ~/.codelens/daemon-default --run-at-load --load
bash scripts/install-http-daemons-launchd.sh . --label-prefix dev.codelens.mcp-dev \
--bin-path "$PWD/.codelens/bin/codelens-mcp-http-dev" --mutation-port 7736 \
--semantic --run-at-load --load
Only the daemon whose default project is the repo writes the repo-local
.codelens/config.json host attach URLs, so the consumption install no longer
flips them away from :7736. After redeploying either daemon, /mcp reconnect
all (Claude Code 2.1.284+) re-attaches every failed server at once.
The old *-readonly labels are compatibility leftovers only. The installer and
redeploy script disable and boot them out before touching the canonical writer;
they never generate or bootstrap a readonly plist.
Shared-daemon LSP execution
Reviewer/planner/read-only profiles share the same LSP pool as mutation
profiles, so profile selection is not the subprocess authorization boundary.
All profiles use the engine-wide registered-recipe policy documented in
runtime-knobs.md: exact
recipe arguments and a canonical executable captured from operator-controlled
paths. A bound repository's node_modules/.bin is never trusted merely because
the repository contains a same-named server shim.
Keep the launchd PATH and CODELENS_LSP_PATH_EXTRA values operator-owned.
For mutually untrusted repositories, use OS/container isolation as well; a
trusted language server may still execute language-specific project hooks even
though callers cannot choose the process or its arguments.
codelens-mcp-plugin/.mcp.json points at :7736, so dogfooding the
working-tree build never touches the shared consumption daemon. Iterate with:
bash scripts/redeploy-dev-daemon.sh # rebuild + resign + restart :7736 only
First-time setup (creates one canonical plist) is documented at the top of
scripts/redeploy-dev-daemon.sh. Use redeploy-daemons.sh for the deliberate
consumption release path.
HTTP Daemon Operations (this repo)
The canonical repo-local launchd agent shares the on-disk index and uses the
project-writer lease plus register_agent_work / claim_files for session
coordination:
dev.codelens.mcp-mutation→:7838, default profilebuilder, modemutation-enabled— canonical writer shared by all host sessions
The consumption clients (global ~/.claude.json, ~/.codex/config.toml and
repo-local host attach overrides) all use http://127.0.0.1:7838/mcp. Session
initialization selects readonly/review/builder (and RBAC principal) on that
same endpoint. Restart cycle (preferred path):
bash scripts/redeploy-daemons.sh --probe # quick: cp + xattr/codesign + kickstart + LISTEN + tools/list
bash scripts/redeploy-daemons.sh --build --probe # also runs cargo build --release --features http,semantic
bash scripts/daemon-stale-check.sh # read-only: compare daemon binary git sha to source HEAD (exit 1 if stale; exit 0 when no LaunchAgent is installed)
What the script does: cp target/release/codelens-mcp → .codelens/bin/codelens-mcp-http, xattr -dr com.apple.provenance ${target} (otherwise macOS gatekeeper SIGKILLs the daemon with OS_REASON_CODESIGNING), codesign (ad hoc by default, or with CODELENS_CODESIGN_IDENTITY, see below; the copy is signed as <target>.staging and swapped in only after signing succeeds), disable/bootout gui/$UID/dev.codelens.mcp-readonly, then launchctl bootout/bootstrap plus kickstart -k gui/$UID/dev.codelens.mcp-mutation, wait for LISTEN on :7838, and (with --probe) issue one tools/list request.
Stable signing identity (keeps macOS folder consent across redeploys)
An ad-hoc signature is keyed to the binary's content hash, so macOS treats
every rebuild as a new app. The first time the redeployed daemon opens a file
in ~/Downloads, ~/Documents or ~/Desktop, macOS shows "‘codelens-mcp-http’
would like to access files in your Downloads folder", and that open() blocks
until someone answers. Binds for projects there return binding_status:
"building" until then (CODELENS_BIND_BUDGET_SECS); measured 2026-10-10:
234.8 s, then over ten minutes, of an index open waiting on the dialog.
Sign with a certificate instead, so one consent survives rebuilds:
- Keychain Access → Certificate Assistant → Create a Certificate…: name
CodeLens Local, Identity Type Self-Signed Root, Certificate Type Code Signing. (Any code-signing identity works; list them withsecurity find-identity -v -p codesigning.) - Export it for the deploy shell, e.g. in
~/.zshrc:export CODELENS_CODESIGN_IDENTITY="CodeLens Local". - Redeploy (
bash scripts/redeploy-daemons.sh --build --probe, andscripts/redeploy-dev-daemon.sh). The first signing may ask for keychain access to the key (choose Always Allow), and the next protected-folder access asks for consent one last time.
The binary is signed with identifier dev.codelens.mcp-http
(CODELENS_CODESIGN_IDENTIFIER overrides it). A named identity that is
missing or fails to sign aborts the redeploy before the binary is replaced;
it never falls back to ad hoc, which would bring the dialog back silently.
A keychain other than the login keychain locks again after a reboot (or on
its own lock timeout). codesign then waits on a password dialog that a
non-interactive shell cannot answer, so the signing step gives up after
CODELENS_CODESIGN_TIMEOUT_SECS (default 60) and prints the keychain to
unlock: run security unlock-keychain <that keychain> and redeploy. The
dialog stays on screen after the timeout; cancel it.
Deprecation removal-gate telemetry (ADR-0018 D3)
The installer sets CODELENS_TELEMETRY_ENABLED=1 on the daemon plist by
default (--telemetry 0 to opt out), so every dispatched call appends one
JSONL line to <repo>/.codelens/telemetry/tool_usage.jsonl. This is the
evidence stream for tool-removal gates: a deprecated tool may be removed
after one release of telemetry shows no legitimate callers. To evaluate the
coordination-quartet gate:
jq -r 'select(.tool == ("register_agent_work","list_active_agents","claim_files","release_files"))
| [(.timestamp_ms/1000 | todate), .tool, .surface, .success] | @tsv' \
.codelens/telemetry/tool_usage.jsonl.1 .codelens/telemetry/tool_usage.jsonl 2>/dev/null
No output over the observation window ⇒ the removal gate is satisfied.
Read both generations. The log rotates by size: once
tool_usage.jsonl reaches CODELENS_TELEMETRY_MAX_BYTES (16 MiB by default)
it is renamed to tool_usage.jsonl.1 and a fresh file starts. The rename
replaces any older .1, so exactly two generations survive and disk is
bounded at twice the ceiling. Querying only the current file can therefore
show a window shorter than you think and report a false zero — always pass
both paths, oldest first, as above.
Sizing: a daemon in daily use wrote about 193 KB/day when measured on
2026-09-05, putting one generation near 85 days and the pair well clear of
the 30-day window ADR-0010 evaluates gates over. Raise
CODELENS_TELEMETRY_MAX_BYTES if your traffic is heavy enough that two
generations would not span the window.
Project-binding precedence and lifetime
HTTP sessions resolve competing project declarations in this order:
- an explicit
prepare_harness_session(project=...)oractivate_project(project=...)request initialize.params.project- the recurring
x-codelens-projectrequest header - the daemon's startup project
Higher-precedence bindings are not overwritten by a lower-precedence recurring header. Requests with the same source can still switch projects. Header updates and the request-local metadata snapshot are captured atomically, so concurrent requests for one session cannot execute against each other's workspace.
prepare_harness_session reports the resolved path as
project.effective_project, its owner as project.binding_source, and the
binding lifetime in project.persistence_semantics. Explicit tool and initialize
bindings persist across calls for the live HTTP session and are restored from the
session journal after an idle expiry or a daemon restart (see
runtime-knobs.md).
A repeated x-codelens-project header re-establishes a header-owned binding during
resurrection; the response carries x-codelens-session-resurrected: 1 when that
fallback occurs. A header path that does not resolve to a project root (the home
directory, a missing path, or an unmarked folder when
CODELENS_ALLOW_MARKERLESS_ROOT=0) is treated as no binding and the request uses
the daemon's project; an explicit project= with such a path still fails.
Binding Claude Code sessions to their launch directory
Claude Code expands ${VAR} in MCP url and headers, so one user-scope entry
can send each session's launch directory. Put it in the URL, not a header:
"codelens": {
"type": "http",
"url": "http://127.0.0.1:7838/mcp?project=${PWD}",
"headers": { "x-codelens-client": "claude-code" }
}
The daemon reads ?project= exactly like x-codelens-project (the header
wins when both are present). A header cannot carry a non-ASCII path: from a
directory named in Korean, Claude Code 2.1.296 with
x-codelens-project: ${PWD} sent no request at all, so the server silently
disappeared from that session. In the URL the same value is percent-encoded
(/mcp?project=/Users/.../%EA%B9%A1...) and arrives intact. Measured
2026-10-10 against a header-capturing stub: the value came first on
server/discover (with MCP-Protocol-Version: 2026-07-28) and then, after the
stub's 400, on initialize. A subdirectory resolves to the enclosing project
root. Only %XX is decoded, so + stays +; a path containing & or #
is cut short and falls back to the daemon's project.
Before adopting it:
- Deploy a daemon that degrades unresolvable header paths (above). An older daemon
fails every call of a session launched in
$HOME. - Keep
CODELENS_ALLOW_MARKERLESS_ROOT=0in the daemon's environment. The installer writes it by default (--markerless-root deny); markerless directories are otherwise accepted, so a session launched in, say,~/Downloadswould index that whole folder. An existing plist needs the key added (or a reinstall) and alaunchctl bootout/bootstrap, sincekickstartdoes not reread the environment.
Manual fallback (if the script is unavailable):
cp -f target/release/codelens-mcp .codelens/bin/codelens-mcp-http
xattr -dr com.apple.provenance .codelens/bin/codelens-mcp-http
codesign --force --sign - .codelens/bin/codelens-mcp-http
launchctl kickstart -k "gui/$(id -u)/dev.codelens.mcp-mutation"
sleep 4 && pgrep -fl codelens-mcp
If pgrep shows nothing after restart, the binary is missing --features http (see the Feature Flag Matrix in ../../CLAUDE.md) — check .codelens/reports/launchd/dev.codelens.mcp-mutation.err.log. If the err log shows last exit reason = OS_REASON_CODESIGNING, the xattr/codesign step was skipped.
Drift signals (why the daemon asks to be restarted)
prepare_harness_session / get_capabilities can attach a restart_recommended
warning. Two independent signals feed it (crates/codelens-mcp/src/build_info.rs):
reason_code |
Trigger | Meaning |
|---|---|---|
stale_daemon_binary |
on-disk executable mtime is newer than the daemon's start time | the daemon outlived its own binary — a rebuild landed but the process was never restarted |
head_git_sha_mismatch |
daemon's compile-time BUILD_GIT_SHA differs from the project's HEAD |
a commit merged after the binary was built is silently absent |
Precedence: stale_daemon_binary wins the reason_code slot when both fire, so
existing consumers keep their semantics.
Three refinements keep this signal from crying wolf:
- Common-prefix SHA comparison (issue #221).
git rev-parse --shortwidens its output on prefix collisions, so the two sides can describe the same commit at different widths. SHAs match when one is a prefix of the other, subject to a 4-character minimum. "unknown"sentinel — emitted bybuild.rsfor non-git builds — is treated as no signal, not as a mismatch.- Binary-relevant classification. Commits that touch only hooks, docs, scripts,
or
.github/produce a byte-identical rebuild; warning on them prompts a restart that drops live MCP sessions for no gain. The daemon runsgit diff --name-only <binary_sha>..<HEAD> -- crates Cargo.toml Cargo.lock— the same path setscripts/daemon-stale-check.shuses for its "binary-equivalent" verdict — and suppresseshead_git_sha_mismatchwhen that diff is empty. Fail-open: if git is unavailable or either SHA is unreachable the classification is unknown and the warning is kept. The mtime signal is never suppressed by this path.
The HEAD comparison only runs when the daemon executable and the project resolve
to the same git root (should_compare_project_head). Because the repo-local
daemons run from .codelens/bin/ inside this repo, the comparison is active here;
a daemon installed outside the project tree simply reports no HEAD signal. Set
CODELENS_HEAD_GIT_SHA_OVERRIDE to force the comparison on.
launchd exit 78: the spawn-failure wedge
Symptom — every service is dead at once and refuses to come back:
launchctl print "gui/$(id -u)/dev.codelens.mcp-mutation" | grep -E 'state|last exit'
# state = not running
# last exit code = 78
launchctl kickstart -k reports success and schedules a spawn, but no process
ever appears (pgrep -fl codelens-mcp stays empty) while running the same binary
by hand works fine. Once launchd has wedged this way, kickstart cannot clear it —
only a full re-registration does:
for label in dev.codelens.mcp-mutation dev.codelens.mcp-dev-mutation; do
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$label.plist"
done
sleep 4 && pgrep -fl codelens-mcp
Note that com.apple.provenance reappears on the binary after modern macOS
touches it; its presence alone is not the blocker here. Reach for the
xattr/codesign path only when the err log actually names
OS_REASON_CODESIGNING (see above) — otherwise re-registration is the cure.